mirror of
https://github.com/woodchen-ink/clash-and-dashboard.git
synced 2025-07-18 14:01:56 +08:00
Feature: add connection information
This commit is contained in:
parent
df0bfb5e10
commit
610d63cf7d
@ -1,4 +1,4 @@
|
||||
import * as React from 'react'
|
||||
import React, { MouseEventHandler } from 'react'
|
||||
import classnames from 'classnames'
|
||||
import { BaseComponentProps } from '@models'
|
||||
import { noop } from '@lib/helper'
|
||||
@ -6,18 +6,20 @@ import './style.scss'
|
||||
|
||||
interface ButtonProps extends BaseComponentProps {
|
||||
type?: 'primary' | 'normal' | 'danger' | 'success' | 'warning'
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>
|
||||
disiabled?: boolean
|
||||
}
|
||||
|
||||
export function Button (props: ButtonProps) {
|
||||
const { type = 'normal', onClick = noop, children, className, style } = props
|
||||
const classname = classnames('button', `button-${type}`, className)
|
||||
const { type = 'normal', onClick = noop, children, className, style, disiabled } = props
|
||||
const classname = classnames('button', `button-${type}`, className, { 'button-disabled': disiabled })
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classname}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
disabled={disiabled}
|
||||
>{children}</button>
|
||||
)
|
||||
}
|
||||
|
@ -7,6 +7,10 @@
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
@ -84,3 +88,14 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,17 @@
|
||||
import * as React from 'react'
|
||||
import React, { forwardRef } from 'react'
|
||||
import { BaseComponentProps } from '@models/BaseProps'
|
||||
import classnames from 'classnames'
|
||||
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
|
||||
return (
|
||||
<div className={classnames('card', className)} style={style}>
|
||||
<div className={classnames('card', className)} style={style} ref={ref}>
|
||||
{ children }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
31
src/components/Drawer/index.tsx
Normal file
31
src/components/Drawer/index.tsx
Normal 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)
|
||||
}
|
@ -13,3 +13,4 @@ export * from './Message'
|
||||
export * from './Checkbox'
|
||||
export * from './Tag'
|
||||
export * from './Loading'
|
||||
export * from './Drawer'
|
||||
|
86
src/containers/Connections/Info/index.tsx
Normal file
86
src/containers/Connections/Info/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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 classnames from 'classnames'
|
||||
import { useScroll } from 'react-use'
|
||||
import { useLatest, useScroll } from 'react-use'
|
||||
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 * as API from '@lib/request'
|
||||
import { useObject, useVisible } from '@lib/hook'
|
||||
import { fromNow } from '@lib/date'
|
||||
import { formatTraffic } from '@lib/helper'
|
||||
import { RuleType } from '@models'
|
||||
import { Devices } from './Devices'
|
||||
import { useConnections } from './store'
|
||||
import { ConnectionInfo } from './Info'
|
||||
import { Connection, FormatConnection, useConnections } from './store'
|
||||
import './style.scss'
|
||||
|
||||
enum Columns {
|
||||
@ -47,17 +49,6 @@ interface ITableInstance<D extends object = {}> extends
|
||||
TableInstance<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) {
|
||||
switch (true) {
|
||||
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 () {
|
||||
const { translation, lang } = useI18n()
|
||||
const t = useMemo(() => translation('Connections').t, [translation])
|
||||
const connStreamReader = useConnectionStreamReader()
|
||||
const client = useClient()
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// total
|
||||
const [traffic, setTraffic] = useObject({
|
||||
@ -109,7 +83,7 @@ export default function Connections () {
|
||||
|
||||
// connections
|
||||
const { connections, feed, save, toggleSave } = useConnections()
|
||||
const data: formatConnection[] = useMemo(() => connections.map(
|
||||
const data: FormatConnection[] = useMemo(() => connections.map(
|
||||
c => ({
|
||||
id: c.id,
|
||||
host: `${c.metadata.host || c.metadata.destinationIP}:${c.metadata.destinationPort}`,
|
||||
@ -123,6 +97,7 @@ export default function Connections () {
|
||||
network: c.metadata.network.toUpperCase(),
|
||||
speed: { upload: c.uploadSpeed, download: c.downloadSpeed },
|
||||
completed: !!c.completed,
|
||||
original: c,
|
||||
}),
|
||||
), [connections])
|
||||
const devices = useMemo(() => {
|
||||
@ -133,7 +108,7 @@ export default function Connections () {
|
||||
// table
|
||||
const tableRef = useRef<HTMLDivElement>(null)
|
||||
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.Network}`), accessor: Columns.Network, minWidth: 80, width: 80 },
|
||||
{ Header: t(`columns.${Columns.Type}`), accessor: Columns.Type, minWidth: 120, width: 120 },
|
||||
@ -142,7 +117,7 @@ export default function Connections () {
|
||||
{
|
||||
id: Columns.Speed,
|
||||
Header: t(`columns.${Columns.Speed}`),
|
||||
accessor (originalRow: formatConnection) {
|
||||
accessor (originalRow: FormatConnection) {
|
||||
return [originalRow.speed.upload, originalRow.speed.download]
|
||||
},
|
||||
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.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 } },
|
||||
] as Array<TableColumnOption<formatConnection>>, [t])
|
||||
] as Array<TableColumnOption<FormatConnection>>, [t])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
function handleConnection (snapshots: API.Snapshot[]) {
|
||||
@ -195,14 +170,14 @@ export default function Connections () {
|
||||
autoResetSortBy: false,
|
||||
autoResetFilters: false,
|
||||
initialState: { sortBy: [{ id: Columns.Time, desc: false }] },
|
||||
} as ITableOptions<formatConnection>,
|
||||
} as ITableOptions<FormatConnection>,
|
||||
useResizeColumns,
|
||||
useBlockLayout,
|
||||
useFilters,
|
||||
useSortBy,
|
||||
) as ITableInstance<formatConnection>
|
||||
) as ITableInstance<FormatConnection>
|
||||
const headerGroup = useMemo(() => headerGroups[0], [headerGroups])
|
||||
const renderCell = useCallback(function (cell: Cell<formatConnection>) {
|
||||
const renderCell = useCallback(function (cell: Cell<FormatConnection>) {
|
||||
switch (cell.column.id) {
|
||||
case Columns.Speed:
|
||||
return formatSpeed(cell.value[0], cell.value[1])
|
||||
@ -223,6 +198,37 @@ export default function Connections () {
|
||||
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 (
|
||||
<div className="page">
|
||||
<Header title={t('title')}>
|
||||
@ -233,12 +239,12 @@ export default function Connections () {
|
||||
<Icon className="connections-filter dangerous" onClick={show} type="close-all" size={20} />
|
||||
</Header>
|
||||
{ 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 {...headerGroup.getHeaderGroupProps()} className="connections-header">
|
||||
{
|
||||
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
|
||||
return (
|
||||
<div
|
||||
@ -270,7 +276,11 @@ export default function Connections () {
|
||||
rows.map(row => {
|
||||
prepareRow(row)
|
||||
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 => {
|
||||
const classname = classnames(
|
||||
@ -293,6 +303,16 @@ export default function Connections () {
|
||||
</div>
|
||||
</Card>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
@ -3,6 +3,25 @@ import { useState, useMemo, useRef, useCallback } from 'react'
|
||||
|
||||
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 {
|
||||
protected connections = new Map<string, Connection>()
|
||||
protected saveDisconnection = false
|
||||
|
@ -63,7 +63,7 @@
|
||||
.connections-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 9999;
|
||||
z-index: 999;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover .connections-resizer {
|
||||
@ -90,7 +90,7 @@
|
||||
&.fixed {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
z-index: 998;
|
||||
background-color: $color-white;
|
||||
box-shadow: inset -9px 0 8px -14px $color-black;
|
||||
}
|
||||
|
@ -87,7 +87,7 @@ export default function Settings () {
|
||||
{ label: t('values.global'), value: 'Global' },
|
||||
{ label: t('values.rules'), value: 'Rule' },
|
||||
{ label: t('values.direct'), value: 'Direct' },
|
||||
]
|
||||
] as Array<{ label: string, value: string }>
|
||||
if (premium) {
|
||||
options.push({ label: t('values.script'), value: 'Script' })
|
||||
}
|
||||
|
@ -16,7 +16,7 @@
|
||||
line-height: 17px;
|
||||
|
||||
&.modify-btn {
|
||||
color: $color-primary-dark;
|
||||
color: $color-primary;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
@ -75,6 +75,25 @@ const EN = {
|
||||
download: 'Download',
|
||||
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: {
|
||||
title: 'Proxies',
|
||||
@ -105,6 +124,6 @@ const EN = {
|
||||
ok: 'Ok',
|
||||
cancel: 'Cancel',
|
||||
},
|
||||
}
|
||||
} as const
|
||||
|
||||
export default EN
|
||||
|
@ -75,6 +75,25 @@ const CN = {
|
||||
download: '下载',
|
||||
sourceIP: '来源 IP',
|
||||
},
|
||||
info: {
|
||||
title: '连接信息',
|
||||
id: 'ID',
|
||||
host: '域名',
|
||||
hostEmpty: '空',
|
||||
dstIP: 'IP',
|
||||
dstIPEmpty: '空',
|
||||
srcIP: '来源',
|
||||
upload: '上传',
|
||||
download: '下载',
|
||||
network: '网络',
|
||||
inbound: '入口',
|
||||
rule: '规则',
|
||||
chains: '代理',
|
||||
status: '状态',
|
||||
opening: '连接中',
|
||||
closed: '已关闭',
|
||||
closeConnection: '关闭连接',
|
||||
},
|
||||
},
|
||||
Proxies: {
|
||||
title: '代理',
|
||||
@ -105,6 +124,6 @@ const CN = {
|
||||
ok: '确 定',
|
||||
cancel: '取 消',
|
||||
},
|
||||
}
|
||||
} as const
|
||||
|
||||
export default CN
|
||||
|
@ -8,3 +8,14 @@ export function partition<T> (arr: T[], fn: (arg: T) => boolean): [T[], T[]] {
|
||||
}
|
||||
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]}`
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { get } from 'lodash-es'
|
||||
import useSWR from 'swr'
|
||||
import produce from 'immer'
|
||||
import { Get } from 'type-fest'
|
||||
|
||||
import { Language, locales, Lang, getDefaultLanguage } from '@i18n'
|
||||
import { useWarpImmerSetter, WritableDraft } from '@lib/jotai'
|
||||
@ -14,10 +15,10 @@ import * as API from '@lib/request'
|
||||
import * as Models from '@models'
|
||||
import { partition } from '@lib/helper'
|
||||
import { isClashX, jsBridge } from '@lib/jsBridge'
|
||||
import { useAPIInfo, useClient } from './request'
|
||||
import { StreamReader } from '@lib/streamer'
|
||||
import { Log } from '@models/Log'
|
||||
import { Snapshot } from '@lib/request'
|
||||
import { useAPIInfo, useClient } from './request'
|
||||
|
||||
export const identityAtom = atom(true)
|
||||
|
||||
@ -28,9 +29,9 @@ export function useI18n () {
|
||||
const lang = useMemo(() => defaultLang ?? getDefaultLanguage(), [defaultLang])
|
||||
|
||||
const translation = useCallback(
|
||||
function (namespace: keyof typeof Language['en_US']) {
|
||||
function t (path: string) {
|
||||
return get(Language[lang][namespace], path) as string
|
||||
function <Namespace extends keyof typeof Language['en_US']>(namespace: Namespace) {
|
||||
function t<Path extends string> (path: Path): Get<typeof Language['en_US'][Namespace], Path> {
|
||||
return get(Language[lang][namespace], path)
|
||||
}
|
||||
return { t }
|
||||
},
|
||||
|
@ -8,7 +8,8 @@ export default defineConfig({
|
||||
500: '#57befc',
|
||||
600: '#2c8af8'
|
||||
},
|
||||
red: '#f56c6c'
|
||||
red: '#f56c6c',
|
||||
green: '#67c23a'
|
||||
},
|
||||
textShadow: {
|
||||
primary: '0 0 6px rgb(44 138 248 / 40%)'
|
||||
|
Loading…
x
Reference in New Issue
Block a user