Feature: add connection information

This commit is contained in:
Dreamacro 2021-07-02 00:02:16 +08:00
parent df0bfb5e10
commit 610d63cf7d
16 changed files with 290 additions and 63 deletions

View File

@ -1,4 +1,4 @@
import * as React from 'react' import React, { MouseEventHandler } from 'react'
import classnames from 'classnames' import classnames from 'classnames'
import { BaseComponentProps } from '@models' import { BaseComponentProps } from '@models'
import { noop } from '@lib/helper' import { noop } from '@lib/helper'
@ -6,18 +6,20 @@ import './style.scss'
interface ButtonProps extends BaseComponentProps { interface ButtonProps extends BaseComponentProps {
type?: 'primary' | 'normal' | 'danger' | 'success' | 'warning' type?: 'primary' | 'normal' | 'danger' | 'success' | 'warning'
onClick?: React.MouseEventHandler<HTMLButtonElement> onClick?: MouseEventHandler<HTMLButtonElement>
disiabled?: boolean
} }
export function Button (props: ButtonProps) { export function Button (props: ButtonProps) {
const { type = 'normal', onClick = noop, children, className, style } = props const { type = 'normal', onClick = noop, children, className, style, disiabled } = props
const classname = classnames('button', `button-${type}`, className) const classname = classnames('button', `button-${type}`, className, { 'button-disabled': disiabled })
return ( return (
<button <button
className={classname} className={classname}
style={style} style={style}
onClick={onClick} onClick={onClick}
disabled={disiabled}
>{children}</button> >{children}</button>
) )
} }

View File

@ -7,6 +7,10 @@
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
transition: all 150ms ease; transition: all 150ms ease;
&:focus {
outline: none;
}
} }
.button-primary { .button-primary {
@ -84,3 +88,14 @@
box-shadow: 0 0 2px rgba($color: darken($color-orange, 5%), $alpha: 0.5); box-shadow: 0 0 2px rgba($color: darken($color-orange, 5%), $alpha: 0.5);
} }
} }
.button.button-disabled {
color: $color-gray-dark;
background: linear-gradient(135deg, $color-gray-light, darken($color-gray-light, 5%));
box-shadow: 0 2px 8px rgba($color: darken($color-gray-light, 5%), $alpha: 0.5);
cursor: not-allowed;
&:active {
box-shadow: 0 0 2px rgba($color: darken($color-gray-light, 5%), $alpha: 0.5);
}
}

View File

@ -1,15 +1,17 @@
import * as React from 'react' import React, { forwardRef } from 'react'
import { BaseComponentProps } from '@models/BaseProps' import { BaseComponentProps } from '@models/BaseProps'
import classnames from 'classnames' import classnames from 'classnames'
import './style.scss' import './style.scss'
type CardProps = BaseComponentProps interface CardProps extends BaseComponentProps {
ref?: React.ForwardedRef<HTMLDivElement>
}
export function Card (props: CardProps) { export const Card = forwardRef<HTMLDivElement, CardProps>((props: CardProps, ref) => {
const { className, style, children } = props const { className, style, children } = props
return ( return (
<div className={classnames('card', className)} style={style}> <div className={classnames('card', className)} style={style} ref={ref}>
{ children } { children }
</div> </div>
) )
} })

View File

@ -0,0 +1,31 @@
import React, { useLayoutEffect, useRef, RefObject } from 'react'
import { createPortal } from 'react-dom'
import classnames from 'classnames'
import { BaseComponentProps } from '@models/BaseProps'
import { Card } from '@components'
interface DrawerProps extends BaseComponentProps {
visible?: boolean
width?: number
containerRef?: RefObject<HTMLElement>
}
export function Drawer (props: DrawerProps) {
const portalRef = useRef<HTMLElement>(document.createElement('div'))
useLayoutEffect(() => {
const current = portalRef.current
document.body.appendChild(current)
return () => { document.body.removeChild(current) }
}, [])
const cardStyle = 'absolute h-full right-0 transition-transform transform translate-x-full duration-100 pointer-events-auto'
const container = (
<div className={classnames(props.className, 'absolute inset-0 pointer-events-none z-9999')}>
<Card className={classnames(cardStyle, { 'translate-x-0': props.visible })} style={{ width: props.width ?? 400 }}>{props.children}</Card>
</div>
)
return createPortal(container, props.containerRef?.current ?? portalRef.current)
}

View File

@ -13,3 +13,4 @@ export * from './Message'
export * from './Checkbox' export * from './Checkbox'
export * from './Tag' export * from './Tag'
export * from './Loading' export * from './Loading'
export * from './Drawer'

View File

@ -0,0 +1,86 @@
import React, { useMemo } from 'react'
import classnames from 'classnames'
import { useI18n } from '@stores'
import { formatTraffic } from '@lib/helper'
import { BaseComponentProps } from '@models'
import { Connection } from '../store'
interface ConnectionsInfoProps extends BaseComponentProps {
connection: Partial<Connection>
}
export function ConnectionInfo (props: ConnectionsInfoProps) {
const { translation } = useI18n()
const t = useMemo(() => translation('Connections').t, [translation])
return (
<div className={classnames(props.className, 'text-sm flex flex-col')}>
<div className="flex my-3">
<span className="w-14 font-bold">{t('info.id')}</span>
<span className="font-mono">{props.connection.id}</span>
</div>
<div className="flex justify-between my-3">
<div className="flex flex-1">
<span className="w-14 font-bold">{t('info.network')}</span>
<span className="font-mono">{props.connection.metadata?.network}</span>
</div>
<div className="flex flex-1">
<span className="w-14 font-bold">{t('info.inbound')}</span>
<span className="font-mono">{props.connection.metadata?.type}</span>
</div>
</div>
<div className="flex my-3">
<span className="w-14 font-bold">{t('info.host')}</span>
<span className="font-mono flex-1 break-all">{
props.connection.metadata?.host
? `${props.connection.metadata.host}:${props.connection.metadata?.destinationPort}`
: t('info.hostEmpty')
}</span>
</div>
<div className="flex my-3">
<span className="w-14 font-bold">{t('info.dstIP')}</span>
<span className="font-mono">{
props.connection.metadata?.destinationIP
? `${props.connection.metadata.destinationIP}:${props.connection.metadata?.destinationPort}`
: t('info.hostEmpty')
}</span>
</div>
<div className="flex my-3">
<span className="w-14 font-bold">{t('info.srcIP')}</span>
<span className="font-mono">{
`${props.connection.metadata?.sourceIP}:${props.connection.metadata?.sourcePort}`
}</span>
</div>
<div className="flex my-3">
<span className="w-14 font-bold">{t('info.rule')}</span>
<span className="font-mono">
{ props.connection.rule && `${props.connection.rule}${props.connection.rulePayload && `(${props.connection.rulePayload})`}` }
</span>
</div>
<div className="flex my-3">
<span className="w-14 font-bold">{t('info.chains')}</span>
<span className="font-mono flex-1 break-all">
{ props.connection.chains?.slice().reverse().join(' / ') }
</span>
</div>
<div className="flex justify-between my-3">
<div className="flex flex-1">
<span className="w-14 font-bold">{t('info.upload')}</span>
<span className="font-mono">{formatTraffic(props.connection.upload ?? 0)}</span>
</div>
<div className="flex flex-1">
<span className="w-14 font-bold">{t('info.download')}</span>
<span className="font-mono">{formatTraffic(props.connection.download ?? 0)}</span>
</div>
</div>
<div className="flex my-3">
<span className="w-14 font-bold">{t('info.status')}</span>
<span className="font-mono">{
!props.connection.completed
? <span className="text-green">{t('info.opening')}</span>
: <span className="text-red">{t('info.closed')}</span>
}</span>
</div>
</div>
)
}

View File

@ -1,16 +1,18 @@
import React, { useMemo, useLayoutEffect, useCallback, useRef, useState } from 'react' import React, { useMemo, useLayoutEffect, useCallback, useRef, useState, useEffect } from 'react'
import { Cell, Column, ColumnInstance, TableInstance, TableOptions, useBlockLayout, useFilters, UseFiltersInstanceProps, UseFiltersOptions, useResizeColumns, UseResizeColumnsColumnProps, UseResizeColumnsOptions, useSortBy, UseSortByColumnOptions, UseSortByColumnProps, UseSortByOptions, useTable } from 'react-table' import { Cell, Column, ColumnInstance, TableInstance, TableOptions, useBlockLayout, useFilters, UseFiltersInstanceProps, UseFiltersOptions, useResizeColumns, UseResizeColumnsColumnProps, UseResizeColumnsOptions, useSortBy, UseSortByColumnOptions, UseSortByColumnProps, UseSortByOptions, useTable } from 'react-table'
import classnames from 'classnames' import classnames from 'classnames'
import { useScroll } from 'react-use' import { useLatest, useScroll } from 'react-use'
import { groupBy } from 'lodash-es' import { groupBy } from 'lodash-es'
import { Header, Card, Checkbox, Modal, Icon } from '@components' import { Header, Checkbox, Modal, Icon, Drawer, Card, Button } from '@components'
import { useClient, useConnectionStreamReader, useI18n } from '@stores' import { useClient, useConnectionStreamReader, useI18n } from '@stores'
import * as API from '@lib/request' import * as API from '@lib/request'
import { useObject, useVisible } from '@lib/hook' import { useObject, useVisible } from '@lib/hook'
import { fromNow } from '@lib/date' import { fromNow } from '@lib/date'
import { formatTraffic } from '@lib/helper'
import { RuleType } from '@models' import { RuleType } from '@models'
import { Devices } from './Devices' import { Devices } from './Devices'
import { useConnections } from './store' import { ConnectionInfo } from './Info'
import { Connection, FormatConnection, useConnections } from './store'
import './style.scss' import './style.scss'
enum Columns { enum Columns {
@ -47,17 +49,6 @@ interface ITableInstance<D extends object = {}> extends
TableInstance<D>, TableInstance<D>,
UseFiltersInstanceProps<D> {} UseFiltersInstanceProps<D> {}
function formatTraffic (num: number) {
const s = ['B', 'KB', 'MB', 'GB', 'TB']
let idx = 0
while (~~(num / 1024) && idx < s.length) {
num /= 1024
idx++
}
return `${idx === 0 ? num : num.toFixed(2)} ${s[idx]}`
}
function formatSpeed (upload: number, download: number) { function formatSpeed (upload: number, download: number) {
switch (true) { switch (true) {
case upload === 0 && download === 0: case upload === 0 && download === 0:
@ -71,29 +62,12 @@ function formatSpeed (upload: number, download: number) {
} }
} }
interface formatConnection {
id: string
host: string
chains: string
rule: string
time: number
upload: number
download: number
type: string
network: string
sourceIP: string
speed: {
upload: number
download: number
}
completed: boolean
}
export default function Connections () { export default function Connections () {
const { translation, lang } = useI18n() const { translation, lang } = useI18n()
const t = useMemo(() => translation('Connections').t, [translation]) const t = useMemo(() => translation('Connections').t, [translation])
const connStreamReader = useConnectionStreamReader() const connStreamReader = useConnectionStreamReader()
const client = useClient() const client = useClient()
const cardRef = useRef<HTMLDivElement>(null)
// total // total
const [traffic, setTraffic] = useObject({ const [traffic, setTraffic] = useObject({
@ -109,7 +83,7 @@ export default function Connections () {
// connections // connections
const { connections, feed, save, toggleSave } = useConnections() const { connections, feed, save, toggleSave } = useConnections()
const data: formatConnection[] = useMemo(() => connections.map( const data: FormatConnection[] = useMemo(() => connections.map(
c => ({ c => ({
id: c.id, id: c.id,
host: `${c.metadata.host || c.metadata.destinationIP}:${c.metadata.destinationPort}`, host: `${c.metadata.host || c.metadata.destinationIP}:${c.metadata.destinationPort}`,
@ -123,6 +97,7 @@ export default function Connections () {
network: c.metadata.network.toUpperCase(), network: c.metadata.network.toUpperCase(),
speed: { upload: c.uploadSpeed, download: c.downloadSpeed }, speed: { upload: c.uploadSpeed, download: c.downloadSpeed },
completed: !!c.completed, completed: !!c.completed,
original: c,
}), }),
), [connections]) ), [connections])
const devices = useMemo(() => { const devices = useMemo(() => {
@ -133,7 +108,7 @@ export default function Connections () {
// table // table
const tableRef = useRef<HTMLDivElement>(null) const tableRef = useRef<HTMLDivElement>(null)
const { x: scrollX } = useScroll(tableRef) const { x: scrollX } = useScroll(tableRef)
const columns: Array<TableColumnOption<formatConnection>> = useMemo(() => [ const columns: Array<TableColumnOption<FormatConnection>> = useMemo(() => [
{ Header: t(`columns.${Columns.Host}`), accessor: Columns.Host, minWidth: 260, width: 260 }, { Header: t(`columns.${Columns.Host}`), accessor: Columns.Host, minWidth: 260, width: 260 },
{ Header: t(`columns.${Columns.Network}`), accessor: Columns.Network, minWidth: 80, width: 80 }, { Header: t(`columns.${Columns.Network}`), accessor: Columns.Network, minWidth: 80, width: 80 },
{ Header: t(`columns.${Columns.Type}`), accessor: Columns.Type, minWidth: 120, width: 120 }, { Header: t(`columns.${Columns.Type}`), accessor: Columns.Type, minWidth: 120, width: 120 },
@ -142,7 +117,7 @@ export default function Connections () {
{ {
id: Columns.Speed, id: Columns.Speed,
Header: t(`columns.${Columns.Speed}`), Header: t(`columns.${Columns.Speed}`),
accessor (originalRow: formatConnection) { accessor (originalRow: FormatConnection) {
return [originalRow.speed.upload, originalRow.speed.download] return [originalRow.speed.upload, originalRow.speed.download]
}, },
sortType (rowA, rowB) { sortType (rowA, rowB) {
@ -160,7 +135,7 @@ export default function Connections () {
{ Header: t(`columns.${Columns.Download}`), accessor: Columns.Download, minWidth: 100, width: 100, sortDescFirst: true }, { Header: t(`columns.${Columns.Download}`), accessor: Columns.Download, minWidth: 100, width: 100, sortDescFirst: true },
{ Header: t(`columns.${Columns.SourceIP}`), accessor: Columns.SourceIP, minWidth: 140, width: 140 }, { Header: t(`columns.${Columns.SourceIP}`), accessor: Columns.SourceIP, minWidth: 140, width: 140 },
{ Header: t(`columns.${Columns.Time}`), accessor: Columns.Time, minWidth: 120, width: 120, sortType (rowA, rowB) { return rowB.original.time - rowA.original.time } }, { Header: t(`columns.${Columns.Time}`), accessor: Columns.Time, minWidth: 120, width: 120, sortType (rowA, rowB) { return rowB.original.time - rowA.original.time } },
] as Array<TableColumnOption<formatConnection>>, [t]) ] as Array<TableColumnOption<FormatConnection>>, [t])
useLayoutEffect(() => { useLayoutEffect(() => {
function handleConnection (snapshots: API.Snapshot[]) { function handleConnection (snapshots: API.Snapshot[]) {
@ -195,14 +170,14 @@ export default function Connections () {
autoResetSortBy: false, autoResetSortBy: false,
autoResetFilters: false, autoResetFilters: false,
initialState: { sortBy: [{ id: Columns.Time, desc: false }] }, initialState: { sortBy: [{ id: Columns.Time, desc: false }] },
} as ITableOptions<formatConnection>, } as ITableOptions<FormatConnection>,
useResizeColumns, useResizeColumns,
useBlockLayout, useBlockLayout,
useFilters, useFilters,
useSortBy, useSortBy,
) as ITableInstance<formatConnection> ) as ITableInstance<FormatConnection>
const headerGroup = useMemo(() => headerGroups[0], [headerGroups]) const headerGroup = useMemo(() => headerGroups[0], [headerGroups])
const renderCell = useCallback(function (cell: Cell<formatConnection>) { const renderCell = useCallback(function (cell: Cell<FormatConnection>) {
switch (cell.column.id) { switch (cell.column.id) {
case Columns.Speed: case Columns.Speed:
return formatSpeed(cell.value[0], cell.value[1]) return formatSpeed(cell.value[0], cell.value[1])
@ -223,6 +198,37 @@ export default function Connections () {
setFilter?.(Columns.SourceIP, label) setFilter?.(Columns.SourceIP, label)
} }
// click item
const [drawerState, setDrawerState] = useObject({
visible: false,
selectedID: '',
connection: {} as Partial<Connection>,
})
function handleConnectionSelected (id: string) {
setDrawerState({
visible: true,
selectedID: id,
})
}
function handleConnectionClosed () {
setDrawerState(d => { d.connection.completed = true })
client.closeConnection(drawerState.selectedID)
}
const latestConntion = useLatest(drawerState.connection)
useEffect(() => {
const conn = data.find(c => c.id === drawerState.selectedID)?.original
if (conn) {
setDrawerState(d => {
d.connection = conn
if (drawerState.selectedID === latestConntion.current.id) {
d.connection.completed = latestConntion.current.completed
}
})
} else if (Object.keys(latestConntion.current).length !== 0 && !latestConntion.current.completed) {
setDrawerState(d => { d.connection.completed = true })
}
}, [data, drawerState.selectedID, latestConntion, setDrawerState])
return ( return (
<div className="page"> <div className="page">
<Header title={t('title')}> <Header title={t('title')}>
@ -233,12 +239,12 @@ export default function Connections () {
<Icon className="connections-filter dangerous" onClick={show} type="close-all" size={20} /> <Icon className="connections-filter dangerous" onClick={show} type="close-all" size={20} />
</Header> </Header>
{ devices.length > 1 && <Devices devices={devices} selected={device} onChange={handleDeviceSelected} /> } { devices.length > 1 && <Devices devices={devices} selected={device} onChange={handleDeviceSelected} /> }
<Card className="connections-card"> <Card ref={cardRef} className="connections-card relative">
<div {...getTableProps()} className="flex flex-col w-full flex-1 overflow-auto" style={{ flexBasis: 0 }} ref={tableRef}> <div {...getTableProps()} className="flex flex-col w-full flex-1 overflow-auto" style={{ flexBasis: 0 }} ref={tableRef}>
<div {...headerGroup.getHeaderGroupProps()} className="connections-header"> <div {...headerGroup.getHeaderGroupProps()} className="connections-header">
{ {
headerGroup.headers.map((column, idx) => { headerGroup.headers.map((column, idx) => {
const realColumn = column as unknown as TableColumn<formatConnection> const realColumn = column as unknown as TableColumn<FormatConnection>
const id = realColumn.id const id = realColumn.id
return ( return (
<div <div
@ -270,7 +276,11 @@ export default function Connections () {
rows.map(row => { rows.map(row => {
prepareRow(row) prepareRow(row)
return ( return (
<div {...row.getRowProps()} className="connections-item" key={row.original.id}> <div
{...row.getRowProps()}
className="connections-item cursor-default select-none"
key={row.original.id}
onClick={() => handleConnectionSelected(row.original.id)}>
{ {
row.cells.map(cell => { row.cells.map(cell => {
const classname = classnames( const classname = classnames(
@ -293,6 +303,16 @@ export default function Connections () {
</div> </div>
</Card> </Card>
<Modal title={t('closeAll.title')} show={visible} onClose={hide} onOk={handleCloseConnections}>{t('closeAll.content')}</Modal> <Modal title={t('closeAll.title')} show={visible} onClose={hide} onOk={handleCloseConnections}>{t('closeAll.content')}</Modal>
<Drawer containerRef={cardRef} visible={drawerState.visible} width={450}>
<div className="flex justify-between items-center h-8">
<span className="pl-3 font-bold">{t('info.title')}</span>
<Icon type="close" size={16} className="cursor-pointer" onClick={() => setDrawerState('visible', false)} />
</div>
<ConnectionInfo className="px-5 mt-3" connection={drawerState.connection} />
<div className="flex justify-end mt-3 pr-3">
<Button type="danger" disiabled={drawerState.connection.completed} onClick={() => handleConnectionClosed()}>{ t('info.closeConnection') }</Button>
</div>
</Drawer>
</div> </div>
) )
} }

View File

@ -3,6 +3,25 @@ import { useState, useMemo, useRef, useCallback } from 'react'
export type Connection = API.Connections & { completed?: boolean, uploadSpeed: number, downloadSpeed: number } export type Connection = API.Connections & { completed?: boolean, uploadSpeed: number, downloadSpeed: number }
export interface FormatConnection {
id: string
host: string
chains: string
rule: string
time: number
upload: number
download: number
type: string
network: string
sourceIP: string
speed: {
upload: number
download: number
}
completed: boolean
original: Connection
}
class Store { class Store {
protected connections = new Map<string, Connection>() protected connections = new Map<string, Connection>()
protected saveDisconnection = false protected saveDisconnection = false

View File

@ -63,7 +63,7 @@
.connections-header { .connections-header {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 9999; z-index: 999;
white-space: nowrap; white-space: nowrap;
&:hover .connections-resizer { &:hover .connections-resizer {
@ -90,7 +90,7 @@
&.fixed { &.fixed {
position: sticky; position: sticky;
left: 0; left: 0;
z-index: 999; z-index: 998;
background-color: $color-white; background-color: $color-white;
box-shadow: inset -9px 0 8px -14px $color-black; box-shadow: inset -9px 0 8px -14px $color-black;
} }

View File

@ -87,7 +87,7 @@ export default function Settings () {
{ label: t('values.global'), value: 'Global' }, { label: t('values.global'), value: 'Global' },
{ label: t('values.rules'), value: 'Rule' }, { label: t('values.rules'), value: 'Rule' },
{ label: t('values.direct'), value: 'Direct' }, { label: t('values.direct'), value: 'Direct' },
] ] as Array<{ label: string, value: string }>
if (premium) { if (premium) {
options.push({ label: t('values.script'), value: 'Script' }) options.push({ label: t('values.script'), value: 'Script' })
} }

View File

@ -16,7 +16,7 @@
line-height: 17px; line-height: 17px;
&.modify-btn { &.modify-btn {
color: $color-primary-dark; color: $color-primary;
cursor: pointer; cursor: pointer;
} }
} }

View File

@ -75,6 +75,25 @@ const EN = {
download: 'Download', download: 'Download',
sourceIP: 'Source IP', sourceIP: 'Source IP',
}, },
info: {
title: 'Connection',
id: 'ID',
host: 'Host',
hostEmpty: 'Empty',
dstIP: 'IP',
dstIPEmpty: 'Empty',
srcIP: 'Source',
upload: 'Upload',
download: 'Download',
network: 'Network',
inbound: 'Inbound',
rule: 'Rule',
chains: 'Chains',
status: 'Status',
opening: 'Open',
closed: 'Closed',
closeConnection: 'Close',
},
}, },
Proxies: { Proxies: {
title: 'Proxies', title: 'Proxies',
@ -105,6 +124,6 @@ const EN = {
ok: 'Ok', ok: 'Ok',
cancel: 'Cancel', cancel: 'Cancel',
}, },
} } as const
export default EN export default EN

View File

@ -75,6 +75,25 @@ const CN = {
download: '下载', download: '下载',
sourceIP: '来源 IP', sourceIP: '来源 IP',
}, },
info: {
title: '连接信息',
id: 'ID',
host: '域名',
hostEmpty: '空',
dstIP: 'IP',
dstIPEmpty: '空',
srcIP: '来源',
upload: '上传',
download: '下载',
network: '网络',
inbound: '入口',
rule: '规则',
chains: '代理',
status: '状态',
opening: '连接中',
closed: '已关闭',
closeConnection: '关闭连接',
},
}, },
Proxies: { Proxies: {
title: '代理', title: '代理',
@ -105,6 +124,6 @@ const CN = {
ok: '确 定', ok: '确 定',
cancel: '取 消', cancel: '取 消',
}, },
} } as const
export default CN export default CN

View File

@ -8,3 +8,14 @@ export function partition<T> (arr: T[], fn: (arg: T) => boolean): [T[], T[]] {
} }
return [left, right] return [left, right]
} }
export function formatTraffic (num: number) {
const s = ['B', 'KB', 'MB', 'GB', 'TB']
let idx = 0
while (~~(num / 1024) && idx < s.length) {
num /= 1024
idx++
}
return `${idx === 0 ? num : num.toFixed(2)} ${s[idx]}`
}

View File

@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo } from 'react'
import { get } from 'lodash-es' import { get } from 'lodash-es'
import useSWR from 'swr' import useSWR from 'swr'
import produce from 'immer' import produce from 'immer'
import { Get } from 'type-fest'
import { Language, locales, Lang, getDefaultLanguage } from '@i18n' import { Language, locales, Lang, getDefaultLanguage } from '@i18n'
import { useWarpImmerSetter, WritableDraft } from '@lib/jotai' import { useWarpImmerSetter, WritableDraft } from '@lib/jotai'
@ -14,10 +15,10 @@ import * as API from '@lib/request'
import * as Models from '@models' import * as Models from '@models'
import { partition } from '@lib/helper' import { partition } from '@lib/helper'
import { isClashX, jsBridge } from '@lib/jsBridge' import { isClashX, jsBridge } from '@lib/jsBridge'
import { useAPIInfo, useClient } from './request'
import { StreamReader } from '@lib/streamer' import { StreamReader } from '@lib/streamer'
import { Log } from '@models/Log' import { Log } from '@models/Log'
import { Snapshot } from '@lib/request' import { Snapshot } from '@lib/request'
import { useAPIInfo, useClient } from './request'
export const identityAtom = atom(true) export const identityAtom = atom(true)
@ -28,9 +29,9 @@ export function useI18n () {
const lang = useMemo(() => defaultLang ?? getDefaultLanguage(), [defaultLang]) const lang = useMemo(() => defaultLang ?? getDefaultLanguage(), [defaultLang])
const translation = useCallback( const translation = useCallback(
function (namespace: keyof typeof Language['en_US']) { function <Namespace extends keyof typeof Language['en_US']>(namespace: Namespace) {
function t (path: string) { function t<Path extends string> (path: Path): Get<typeof Language['en_US'][Namespace], Path> {
return get(Language[lang][namespace], path) as string return get(Language[lang][namespace], path)
} }
return { t } return { t }
}, },

View File

@ -8,7 +8,8 @@ export default defineConfig({
500: '#57befc', 500: '#57befc',
600: '#2c8af8' 600: '#2c8af8'
}, },
red: '#f56c6c' red: '#f56c6c',
green: '#67c23a'
}, },
textShadow: { textShadow: {
primary: '0 0 6px rgb(44 138 248 / 40%)' primary: '0 0 6px rgb(44 138 248 / 40%)'