feat: add table view to connection page

This commit is contained in:
ezequielnick 2025-10-17 21:45:43 +08:00
parent a5a583fdc5
commit 51b8c879ea
5 changed files with 679 additions and 94 deletions

View File

@ -0,0 +1,465 @@
import React, { useMemo, useRef, useState, useCallback } from 'react'
import { Button, Chip } from '@heroui/react'
import { calcTraffic } from '@renderer/utils/calc'
import dayjs from '@renderer/utils/dayjs'
import { useTranslation } from 'react-i18next'
import { CgClose, CgTrash } from 'react-icons/cg'
interface Props {
connections: IMihomoConnectionDetail[]
setSelected: React.Dispatch<React.SetStateAction<IMihomoConnectionDetail | undefined>>
setIsDetailModalOpen: React.Dispatch<React.SetStateAction<boolean>>
close: (id: string) => void
visibleColumns: Set<string>
onColumnWidthChange?: (widths: Record<string, number>) => void
onSortChange?: (column: string | null, direction: 'asc' | 'desc') => void
initialColumnWidths?: Record<string, number>
initialSortColumn?: string
initialSortDirection?: 'asc' | 'desc'
}
interface ColumnConfig {
key: string
label: string
width: number
minWidth: number
visible: boolean
getValue: (connection: IMihomoConnectionDetail) => string | number
render?: (connection: IMihomoConnectionDetail) => React.ReactNode
sortValue?: (connection: IMihomoConnectionDetail) => string | number
}
const DEFAULT_COLUMNS: Omit<ColumnConfig, 'label'>[] = [
{
key: 'status',
width: 80,
minWidth: 60,
visible: true,
getValue: (conn) => conn.isActive ? 'active' : 'closed',
sortValue: (conn) => conn.isActive ? 1 : 0
},
{
key: 'establishTime',
width: 105,
minWidth: 80,
visible: true,
getValue: (conn) => dayjs(conn.start).fromNow(),
sortValue: (conn) => dayjs(conn.start).unix()
},
{
key: 'type',
width: 120,
minWidth: 100,
visible: true,
getValue: (conn) => `${conn.metadata.type}(${conn.metadata.network})`,
render: (conn) => (
<span className="text-xs">{conn.metadata.type}({conn.metadata.network.toUpperCase()})</span>
)
},
{
key: 'host',
width: 200,
minWidth: 150,
visible: true,
getValue: (conn) => conn.metadata.host || '-'
},
{
key: 'sniffHost',
width: 200,
minWidth: 150,
visible: false,
getValue: (conn) => conn.metadata.sniffHost || '-'
},
{
key: 'process',
width: 150,
minWidth: 120,
visible: true,
getValue: (conn) =>
conn.metadata.process
? `${conn.metadata.process}${conn.metadata.uid ? `(${conn.metadata.uid})` : ''}`
: '-'
},
{
key: 'processPath',
width: 250,
minWidth: 200,
visible: false,
getValue: (conn) => conn.metadata.processPath || '-'
},
{
key: 'rule',
width: 150,
minWidth: 120,
visible: true,
getValue: (conn) => `${conn.rule}${conn.rulePayload ? `(${conn.rulePayload})` : ''}`
},
{
key: 'proxyChain',
width: 150,
minWidth: 120,
visible: true,
getValue: (conn) => [...conn.chains].reverse().join('>>')
},
{
key: 'sourceIP',
width: 140,
minWidth: 120,
visible: false,
getValue: (conn) => conn.metadata.sourceIP || '-'
},
{
key: 'sourcePort',
width: 100,
minWidth: 80,
visible: false,
getValue: (conn) => conn.metadata.sourcePort || '-'
},
{
key: 'destinationPort',
width: 100,
minWidth: 80,
visible: true,
getValue: (conn) => conn.metadata.destinationPort || '-'
},
{
key: 'inboundIP',
width: 140,
minWidth: 120,
visible: false,
getValue: (conn) => conn.metadata.inboundIP || '-'
},
{
key: 'inboundPort',
width: 100,
minWidth: 80,
visible: false,
getValue: (conn) => conn.metadata.inboundPort || '-'
},
{
key: 'uploadSpeed',
width: 110,
minWidth: 90,
visible: true,
getValue: (conn) => `${calcTraffic(conn.uploadSpeed || 0)}/s`,
sortValue: (conn) => conn.uploadSpeed || 0
},
{
key: 'downloadSpeed',
width: 110,
minWidth: 90,
visible: true,
getValue: (conn) => `${calcTraffic(conn.downloadSpeed || 0)}/s`,
sortValue: (conn) => conn.downloadSpeed || 0
},
{
key: 'upload',
width: 100,
minWidth: 80,
visible: true,
getValue: (conn) => calcTraffic(conn.upload),
sortValue: (conn) => conn.upload
},
{
key: 'download',
width: 100,
minWidth: 80,
visible: true,
getValue: (conn) => calcTraffic(conn.download),
sortValue: (conn) => conn.download
},
{
key: 'dscp',
width: 80,
minWidth: 60,
visible: false,
getValue: (conn) => conn.metadata.dscp.toString()
},
{
key: 'remoteDestination',
width: 200,
minWidth: 150,
visible: false,
getValue: (conn) => conn.metadata.remoteDestination || '-'
},
{
key: 'dnsMode',
width: 120,
minWidth: 100,
visible: false,
getValue: (conn) => conn.metadata.dnsMode || '-'
}
]
const ConnectionTable: React.FC<Props> = ({
connections,
setSelected,
setIsDetailModalOpen,
close,
visibleColumns,
onColumnWidthChange,
onSortChange,
initialColumnWidths,
initialSortColumn,
initialSortDirection
}) => {
const { t } = useTranslation()
const tableRef = useRef<HTMLDivElement>(null)
const [resizingColumn, setResizingColumn] = useState<string | null>(null)
const [sortColumn, setSortColumn] = useState<string | null>(initialSortColumn || null)
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(initialSortDirection || 'asc')
// 状态列渲染函数
const renderStatus = useCallback((conn: IMihomoConnectionDetail) => (
<Chip
color={conn.isActive ? 'primary' : 'danger'}
size="sm"
radius="sm"
variant="dot"
>
{conn.isActive ? t('connections.active') : t('connections.closed')}
</Chip>
), [t])
// 连接类型渲染函数
const renderType = useCallback((conn: IMihomoConnectionDetail) => (
<span className="text-xs">{conn.metadata.type}({conn.metadata.network.toUpperCase()})</span>
), [])
// 翻译标签映射
const getLabelForColumn = useCallback((key: string): string => {
const translationMap: Record<string, string> = {
status: t('connections.detail.status'),
establishTime: t('connections.detail.establishTime'),
type: t('connections.detail.connectionType'),
host: t('connections.detail.host'),
sniffHost: t('connections.detail.sniffHost'),
process: t('connections.detail.processName'),
processPath: t('connections.detail.processPath'),
rule: t('connections.detail.rule'),
proxyChain: t('connections.detail.proxyChain'),
sourceIP: t('connections.detail.sourceIP'),
sourcePort: t('connections.detail.sourcePort'),
destinationPort: t('connections.detail.destinationPort'),
inboundIP: t('connections.detail.inboundIP'),
inboundPort: t('connections.detail.inboundPort'),
uploadSpeed: t('connections.uploadSpeed'),
downloadSpeed: t('connections.downloadSpeed'),
upload: t('connections.uploadAmount'),
download: t('connections.downloadAmount'),
dscp: t('connections.detail.dscp'),
remoteDestination: t('connections.detail.remoteDestination'),
dnsMode: t('connections.detail.dnsMode')
}
return translationMap[key] || key
}, [t])
// 初始化列配置(保留宽度状态)
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
const widths: Record<string, number> = {}
DEFAULT_COLUMNS.forEach(col => {
widths[col.key] = initialColumnWidths?.[col.key] || col.width
})
return widths
})
// 更新列标签和可见性
const columnsWithLabels = useMemo(() =>
DEFAULT_COLUMNS.map(col => ({
...col,
label: getLabelForColumn(col.key),
visible: visibleColumns.has(col.key),
width: columnWidths[col.key] || col.width
}))
, [getLabelForColumn, visibleColumns, columnWidths])
// 处理列宽度调整
const handleMouseDown = useCallback((e: React.MouseEvent, columnKey: string) => {
e.preventDefault()
setResizingColumn(columnKey)
const startX = e.clientX
const column = DEFAULT_COLUMNS.find(c => c.key === columnKey)
if (!column) return
let currentWidth = column.width
setColumnWidths(prev => {
currentWidth = prev[columnKey] || column.width
return prev
})
const handleMouseMove = (e: MouseEvent) => {
const diff = e.clientX - startX
const newWidth = Math.max(column.minWidth, currentWidth + diff)
setColumnWidths(prev => ({
...prev,
[columnKey]: newWidth
}))
}
const handleMouseUp = () => {
setResizingColumn(null)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
// 保存列宽度
if (onColumnWidthChange) {
setColumnWidths(currentWidths => {
onColumnWidthChange(currentWidths)
return currentWidths
})
}
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [onColumnWidthChange])
// 处理排序
const handleSort = useCallback((columnKey: string) => {
let newDirection: 'asc' | 'desc' = 'asc'
let newColumn = columnKey
if (sortColumn === columnKey) {
newDirection = sortDirection === 'asc' ? 'desc' : 'asc'
setSortDirection(newDirection)
} else {
setSortColumn(columnKey)
setSortDirection('asc')
}
// 保存排序状态
if (onSortChange) {
onSortChange(newColumn, newDirection)
}
}, [sortColumn, sortDirection, onSortChange])
// 排序连接
const sortedConnections = useMemo(() => {
if (!sortColumn) return connections
const column = columnsWithLabels.find(c => c.key === sortColumn)
if (!column) return connections
return [...connections].sort((a, b) => {
const getSortValue = column.sortValue || column.getValue
const aValue = getSortValue(a)
const bValue = getSortValue(b)
let comparison = 0
if (typeof aValue === 'string' && typeof bValue === 'string') {
comparison = aValue.localeCompare(bValue)
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
comparison = aValue - bValue
}
return sortDirection === 'asc' ? comparison : -comparison
})
}, [connections, sortColumn, sortDirection, columnsWithLabels])
const visibleColumnsFiltered = columnsWithLabels.filter(col => col.visible)
return (
<div className="h-full flex flex-col">
{/* 表格容器 */}
<div ref={tableRef} className="flex-1 overflow-auto">
<table className="w-full border-collapse">
<thead className="sticky top-0 z-10 bg-content2">
<tr>
{visibleColumnsFiltered.map(col => (
<th
key={col.key}
className="relative border-b border-divider text-left text-xs font-semibold text-foreground-600 px-3 h-10"
style={{ width: col.width, minWidth: col.minWidth }}
>
<div className="flex items-center justify-between gap-1">
<button
className="flex-1 text-left hover:text-foreground"
onClick={() => handleSort(col.key)}
>
{col.label}
{sortColumn === col.key && (
<span className="ml-1">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</button>
<div
className="absolute right-0 top-0 h-full w-2 cursor-col-resize flex items-center justify-center group"
onMouseDown={(e) => handleMouseDown(e, col.key)}
>
<div
className="w-px h-full bg-divider group-hover:bg-primary transition-colors"
style={{
backgroundColor: resizingColumn === col.key ? 'var(--primary)' : undefined
}}
/>
</div>
</div>
</th>
))}
<th className="sticky right-0 border-b border-divider w-12 bg-content2" />
</tr>
</thead>
<tbody>
{sortedConnections.map((connection, index) => (
<tr
key={connection.id}
className="border-b border-divider hover:bg-content2 cursor-pointer transition-colors h-12"
onClick={() => {
setSelected(connection)
setIsDetailModalOpen(true)
}}
>
{visibleColumnsFiltered.map(col => {
let content: React.ReactNode
// 根据列类型选择渲染方式
if (col.key === 'status') {
content = renderStatus(connection)
} else if (col.key === 'type') {
content = renderType(connection)
} else if (col.render) {
content = col.render(connection)
} else {
content = col.getValue(connection)
}
return (
<td
key={col.key}
className="px-3 text-sm text-foreground truncate"
style={{ maxWidth: col.width }}
title={typeof col.getValue(connection) === 'string' ? col.getValue(connection) as string : ''}
>
{content}
</td>
)
})}
<td className="sticky right-0 bg-inherit" onClick={(e) => e.stopPropagation()}>
<Button
color={connection.isActive ? 'warning' : 'danger'}
variant="light"
isIconOnly
size="sm"
onPress={() => {
close(connection.id)
}}
>
{connection.isActive ? <CgClose className="text-lg" /> : <CgTrash className="text-lg" />}
</Button>
</td>
</tr>
))}
</tbody>
</table>
{sortedConnections.length === 0 && (
<div className="flex items-center justify-center h-32 text-foreground-400">
{t('connections.table.noData')}
</div>
)}
</div>
</div>
)
}
export default ConnectionTable

View File

@ -540,8 +540,13 @@
"connections.detail.specialProxy": "Special Proxy",
"connections.detail.specialRules": "Special Rules",
"connections.detail.close": "Close",
"connections.detail.status": "Status",
"connections.pause": "Pause",
"connections.resume": "Resume",
"connections.table.switchToTable": "Switch to Table View",
"connections.table.switchToList": "Switch to List View",
"connections.table.columns": "Columns",
"connections.table.noData": "No Data",
"resources.geoData.geoip": "GeoIP Database",
"resources.geoData.geosite": "GeoSite Database",
"resources.geoData.mmdb": "MMDB Database",

View File

@ -540,8 +540,13 @@
"connections.detail.specialProxy": "特殊代理",
"connections.detail.specialRules": "特殊规则",
"connections.detail.close": "关闭",
"connections.detail.status": "状态",
"connections.pause": "暂停",
"connections.resume": "恢复",
"connections.table.switchToTable": "切换到表格视图",
"connections.table.switchToList": "切换到列表视图",
"connections.table.columns": "显示列",
"connections.table.noData": "暂无数据",
"resources.geoData.geoip": "GeoIP 数据库",
"resources.geoData.geosite": "GeoSite 数据库",
"resources.geoData.mmdb": "MMDB 数据库",

View File

@ -1,15 +1,19 @@
import BasePage from '@renderer/components/base/base-page'
import { mihomoCloseAllConnections, mihomoCloseConnection } from '@renderer/utils/ipc'
import { Key, useEffect, useMemo, useState } from 'react'
import { Key, useCallback, useEffect, useMemo, useState } from 'react'
import { Badge, Button, Divider, Input, Select, SelectItem, Tab, Tabs } from '@heroui/react'
import { calcTraffic } from '@renderer/utils/calc'
import ConnectionItem from '@renderer/components/connections/connection-item'
import ConnectionTable from '@renderer/components/connections/connection-table'
import { Virtuoso } from 'react-virtuoso'
import dayjs from '@renderer/utils/dayjs'
import ConnectionDetailModal from '@renderer/components/connections/connection-detail-modal'
import { CgClose, CgTrash } from 'react-icons/cg'
import { useAppConfig } from '@renderer/hooks/use-app-config'
import { HiSortAscending, HiSortDescending } from 'react-icons/hi'
import { MdViewList, MdTableChart } from 'react-icons/md'
import { HiOutlineAdjustmentsHorizontal } from 'react-icons/hi2'
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from '@heroui/react'
import { includesIgnoreCase } from '@renderer/utils/includes'
import { differenceWith, unionWith } from 'lodash'
import { useTranslation } from 'react-i18next'
@ -21,7 +25,18 @@ const Connections: React.FC = () => {
const { t } = useTranslation()
const [filter, setFilter] = useState('')
const { appConfig, patchAppConfig } = useAppConfig()
const { connectionDirection = 'asc', connectionOrderBy = 'time' } = appConfig || {}
const {
connectionDirection = 'asc',
connectionOrderBy = 'time',
connectionViewMode = 'list',
connectionTableColumns = [
'status', 'establishTime', 'type', 'host', 'process', 'rule',
'proxyChain', 'remoteDestination', 'uploadSpeed', 'downloadSpeed', 'upload', 'download'
],
connectionTableColumnWidths,
connectionTableSortColumn,
connectionTableSortDirection
} = appConfig || {}
const [connectionsInfo, setConnectionsInfo] = useState<IMihomoConnectionsInfo>()
const [allConnections, setAllConnections] = useState<IMihomoConnectionDetail[]>(cachedConnections)
const [activeConnections, setActiveConnections] = useState<IMihomoConnectionDetail[]>([])
@ -30,54 +45,64 @@ const Connections: React.FC = () => {
const [selected, setSelected] = useState<IMihomoConnectionDetail>()
const [tab, setTab] = useState('active')
const [isPaused, setIsPaused] = useState(false)
const [viewMode, setViewMode] = useState<'list' | 'table'>(connectionViewMode)
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(new Set(connectionTableColumns))
const handleColumnWidthChange = useCallback(async (widths: Record<string, number>) => {
await patchAppConfig({ connectionTableColumnWidths: widths })
}, [patchAppConfig])
const handleSortChange = useCallback(async (column: string | null, direction: 'asc' | 'desc') => {
await patchAppConfig({
connectionTableSortColumn: column || undefined,
connectionTableSortDirection: direction
})
}, [patchAppConfig])
const filteredConnections = useMemo(() => {
const connections = tab === 'active' ? activeConnections : closedConnections
if (connectionOrderBy) {
connections.sort((a, b) => {
if (connectionDirection === 'asc') {
switch (connectionOrderBy) {
case 'time':
return dayjs(b.start).unix() - dayjs(a.start).unix()
case 'upload':
return a.upload - b.upload
case 'download':
return a.download - b.download
case 'uploadSpeed':
return (a.uploadSpeed || 0) - (b.uploadSpeed || 0)
case 'downloadSpeed':
return (a.downloadSpeed || 0) - (b.downloadSpeed || 0)
}
} else {
switch (connectionOrderBy) {
case 'time':
return dayjs(a.start).unix() - dayjs(b.start).unix()
case 'upload':
return b.upload - a.upload
case 'download':
return b.download - a.download
case 'uploadSpeed':
return (b.uploadSpeed || 0) - (a.uploadSpeed || 0)
case 'downloadSpeed':
return (b.downloadSpeed || 0) - (a.downloadSpeed || 0)
}
}
})
}
if (filter === '') return connections
return connections?.filter((connection) => {
const filtered = filter === ''
? connections
: connections.filter((connection) => {
const raw = JSON.stringify(connection)
return includesIgnoreCase(raw, filter)
})
}, [activeConnections, closedConnections, filter, connectionDirection, connectionOrderBy])
const closeAllConnections = (): void => {
if (viewMode === 'list' && connectionOrderBy) {
return [...filtered].sort((a, b) => {
let comparison = 0
switch (connectionOrderBy) {
case 'time':
comparison = dayjs(a.start).unix() - dayjs(b.start).unix()
break
case 'upload':
comparison = a.upload - b.upload
break
case 'download':
comparison = a.download - b.download
break
case 'uploadSpeed':
comparison = (a.uploadSpeed || 0) - (b.uploadSpeed || 0)
break
case 'downloadSpeed':
comparison = (a.downloadSpeed || 0) - (b.downloadSpeed || 0)
break
}
return connectionDirection === 'asc' ? comparison : -comparison
})
}
return filtered
}, [activeConnections, closedConnections, tab, filter, connectionDirection, connectionOrderBy, viewMode])
const closeAllConnections = useCallback((): void => {
tab === 'active' ? mihomoCloseAllConnections() : trashAllClosedConnection()
}
}, [tab, closedConnections])
const closeConnection = (id: string): void => {
const closeConnection = useCallback((id: string): void => {
tab === 'active' ? mihomoCloseConnection(id) : trashClosedConnection(id)
}
}, [tab])
const trashAllClosedConnection = (): void => {
const trashIds = closedConnections.map((conn) => conn.id)
@ -159,6 +184,20 @@ const Connections: React.FC = () => {
showOutline={false}
content={`${filteredConnections.length}`}
>
<Button
className="app-nodrag ml-1"
title={viewMode === 'list' ? t('connections.table.switchToTable') : t('connections.table.switchToList')}
isIconOnly
size="sm"
variant="light"
onPress={async () => {
const newMode = viewMode === 'list' ? 'table' : 'list'
setViewMode(newMode)
await patchAppConfig({ connectionViewMode: newMode })
}}
>
{viewMode === 'list' ? <MdTableChart className="text-lg" /> : <MdViewList className="text-lg" />}
</Button>
<Button
className="app-nodrag ml-1"
title={isPaused ? t('connections.resume') : t('connections.pause')}
@ -246,6 +285,55 @@ const Connections: React.FC = () => {
onValueChange={setFilter}
/>
{viewMode === 'table' && (
<Dropdown>
<DropdownTrigger>
<Button
size="sm"
variant="flat"
startContent={<HiOutlineAdjustmentsHorizontal className="text-2xl" />}
>
{t('connections.table.columns')}
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="Column visibility"
closeOnSelect={false}
selectionMode="multiple"
selectedKeys={visibleColumns}
onSelectionChange={async (keys) => {
const newColumns = Array.from(keys) as string[]
setVisibleColumns(new Set(newColumns))
await patchAppConfig({ connectionTableColumns: newColumns })
}}
>
<DropdownItem key="status">{t('connections.detail.status')}</DropdownItem>
<DropdownItem key="establishTime">{t('connections.detail.establishTime')}</DropdownItem>
<DropdownItem key="type">{t('connections.detail.connectionType')}</DropdownItem>
<DropdownItem key="host">{t('connections.detail.host')}</DropdownItem>
<DropdownItem key="sniffHost">{t('connections.detail.sniffHost')}</DropdownItem>
<DropdownItem key="process">{t('connections.detail.processName')}</DropdownItem>
<DropdownItem key="processPath">{t('connections.detail.processPath')}</DropdownItem>
<DropdownItem key="rule">{t('connections.detail.rule')}</DropdownItem>
<DropdownItem key="proxyChain">{t('connections.detail.proxyChain')}</DropdownItem>
<DropdownItem key="sourceIP">{t('connections.detail.sourceIP')}</DropdownItem>
<DropdownItem key="sourcePort">{t('connections.detail.sourcePort')}</DropdownItem>
<DropdownItem key="destinationPort">{t('connections.detail.destinationPort')}</DropdownItem>
<DropdownItem key="inboundIP">{t('connections.detail.inboundIP')}</DropdownItem>
<DropdownItem key="inboundPort">{t('connections.detail.inboundPort')}</DropdownItem>
<DropdownItem key="uploadSpeed">{t('connections.uploadSpeed')}</DropdownItem>
<DropdownItem key="downloadSpeed">{t('connections.downloadSpeed')}</DropdownItem>
<DropdownItem key="upload">{t('connections.uploadAmount')}</DropdownItem>
<DropdownItem key="download">{t('connections.downloadAmount')}</DropdownItem>
<DropdownItem key="dscp">{t('connections.detail.dscp')}</DropdownItem>
<DropdownItem key="remoteDestination">{t('connections.detail.remoteDestination')}</DropdownItem>
<DropdownItem key="dnsMode">{t('connections.detail.dnsMode')}</DropdownItem>
</DropdownMenu>
</Dropdown>
)}
{viewMode === 'list' && (
<>
<Select
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
size="sm"
@ -286,10 +374,13 @@ const Connections: React.FC = () => {
<HiSortDescending className="text-lg" />
)}
</Button>
</>
)}
</div>
<Divider />
</div>
<div className="h-[calc(100vh-100px)] mt-px">
{viewMode === 'list' ? (
<Virtuoso
data={filteredConnections}
itemContent={(i, connection) => (
@ -304,6 +395,20 @@ const Connections: React.FC = () => {
/>
)}
/>
) : (
<ConnectionTable
connections={filteredConnections}
setSelected={setSelected}
setIsDetailModalOpen={setIsDetailModalOpen}
close={closeConnection}
visibleColumns={visibleColumns}
initialColumnWidths={connectionTableColumnWidths}
initialSortColumn={connectionTableSortColumn}
initialSortDirection={connectionTableSortDirection}
onColumnWidthChange={handleColumnWidthChange}
onSortChange={handleSortChange}
/>
)}
</div>
</BasePage>
)

View File

@ -234,6 +234,11 @@ interface IAppConfig {
proxyCols: 'auto' | '1' | '2' | '3' | '4'
connectionDirection: 'asc' | 'desc'
connectionOrderBy: 'time' | 'upload' | 'download' | 'uploadSpeed' | 'downloadSpeed'
connectionViewMode?: 'list' | 'table'
connectionTableColumns?: string[]
connectionTableColumnWidths?: Record<string, number>
connectionTableSortColumn?: string
connectionTableSortDirection?: 'asc' | 'desc'
spinFloatingIcon?: boolean
disableTray?: boolean
showFloatingWindow?: boolean