mirror of
https://gh.catmak.name/https://github.com/mihomo-party-org/mihomo-party
synced 2025-12-27 05:00:30 +08:00
feat: add table view to connection page
This commit is contained in:
parent
a5a583fdc5
commit
51b8c879ea
465
src/renderer/src/components/connections/connection-table.tsx
Normal file
465
src/renderer/src/components/connections/connection-table.tsx
Normal 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
|
||||||
@ -540,8 +540,13 @@
|
|||||||
"connections.detail.specialProxy": "Special Proxy",
|
"connections.detail.specialProxy": "Special Proxy",
|
||||||
"connections.detail.specialRules": "Special Rules",
|
"connections.detail.specialRules": "Special Rules",
|
||||||
"connections.detail.close": "Close",
|
"connections.detail.close": "Close",
|
||||||
|
"connections.detail.status": "Status",
|
||||||
"connections.pause": "Pause",
|
"connections.pause": "Pause",
|
||||||
"connections.resume": "Resume",
|
"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.geoip": "GeoIP Database",
|
||||||
"resources.geoData.geosite": "GeoSite Database",
|
"resources.geoData.geosite": "GeoSite Database",
|
||||||
"resources.geoData.mmdb": "MMDB Database",
|
"resources.geoData.mmdb": "MMDB Database",
|
||||||
|
|||||||
@ -540,8 +540,13 @@
|
|||||||
"connections.detail.specialProxy": "特殊代理",
|
"connections.detail.specialProxy": "特殊代理",
|
||||||
"connections.detail.specialRules": "特殊规则",
|
"connections.detail.specialRules": "特殊规则",
|
||||||
"connections.detail.close": "关闭",
|
"connections.detail.close": "关闭",
|
||||||
|
"connections.detail.status": "状态",
|
||||||
"connections.pause": "暂停",
|
"connections.pause": "暂停",
|
||||||
"connections.resume": "恢复",
|
"connections.resume": "恢复",
|
||||||
|
"connections.table.switchToTable": "切换到表格视图",
|
||||||
|
"connections.table.switchToList": "切换到列表视图",
|
||||||
|
"connections.table.columns": "显示列",
|
||||||
|
"connections.table.noData": "暂无数据",
|
||||||
"resources.geoData.geoip": "GeoIP 数据库",
|
"resources.geoData.geoip": "GeoIP 数据库",
|
||||||
"resources.geoData.geosite": "GeoSite 数据库",
|
"resources.geoData.geosite": "GeoSite 数据库",
|
||||||
"resources.geoData.mmdb": "MMDB 数据库",
|
"resources.geoData.mmdb": "MMDB 数据库",
|
||||||
|
|||||||
@ -1,15 +1,19 @@
|
|||||||
import BasePage from '@renderer/components/base/base-page'
|
import BasePage from '@renderer/components/base/base-page'
|
||||||
import { mihomoCloseAllConnections, mihomoCloseConnection } from '@renderer/utils/ipc'
|
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 { Badge, Button, Divider, Input, Select, SelectItem, Tab, Tabs } from '@heroui/react'
|
||||||
import { calcTraffic } from '@renderer/utils/calc'
|
import { calcTraffic } from '@renderer/utils/calc'
|
||||||
import ConnectionItem from '@renderer/components/connections/connection-item'
|
import ConnectionItem from '@renderer/components/connections/connection-item'
|
||||||
|
import ConnectionTable from '@renderer/components/connections/connection-table'
|
||||||
import { Virtuoso } from 'react-virtuoso'
|
import { Virtuoso } from 'react-virtuoso'
|
||||||
import dayjs from '@renderer/utils/dayjs'
|
import dayjs from '@renderer/utils/dayjs'
|
||||||
import ConnectionDetailModal from '@renderer/components/connections/connection-detail-modal'
|
import ConnectionDetailModal from '@renderer/components/connections/connection-detail-modal'
|
||||||
import { CgClose, CgTrash } from 'react-icons/cg'
|
import { CgClose, CgTrash } from 'react-icons/cg'
|
||||||
import { useAppConfig } from '@renderer/hooks/use-app-config'
|
import { useAppConfig } from '@renderer/hooks/use-app-config'
|
||||||
import { HiSortAscending, HiSortDescending } from 'react-icons/hi'
|
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 { includesIgnoreCase } from '@renderer/utils/includes'
|
||||||
import { differenceWith, unionWith } from 'lodash'
|
import { differenceWith, unionWith } from 'lodash'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@ -21,7 +25,18 @@ const Connections: React.FC = () => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
const { appConfig, patchAppConfig } = useAppConfig()
|
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 [connectionsInfo, setConnectionsInfo] = useState<IMihomoConnectionsInfo>()
|
||||||
const [allConnections, setAllConnections] = useState<IMihomoConnectionDetail[]>(cachedConnections)
|
const [allConnections, setAllConnections] = useState<IMihomoConnectionDetail[]>(cachedConnections)
|
||||||
const [activeConnections, setActiveConnections] = useState<IMihomoConnectionDetail[]>([])
|
const [activeConnections, setActiveConnections] = useState<IMihomoConnectionDetail[]>([])
|
||||||
@ -30,54 +45,64 @@ const Connections: React.FC = () => {
|
|||||||
const [selected, setSelected] = useState<IMihomoConnectionDetail>()
|
const [selected, setSelected] = useState<IMihomoConnectionDetail>()
|
||||||
const [tab, setTab] = useState('active')
|
const [tab, setTab] = useState('active')
|
||||||
const [isPaused, setIsPaused] = useState(false)
|
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 filteredConnections = useMemo(() => {
|
||||||
const connections = tab === 'active' ? activeConnections : closedConnections
|
const connections = tab === 'active' ? activeConnections : closedConnections
|
||||||
if (connectionOrderBy) {
|
|
||||||
connections.sort((a, b) => {
|
const filtered = filter === ''
|
||||||
if (connectionDirection === 'asc') {
|
? connections
|
||||||
switch (connectionOrderBy) {
|
: connections.filter((connection) => {
|
||||||
case 'time':
|
const raw = JSON.stringify(connection)
|
||||||
return dayjs(b.start).unix() - dayjs(a.start).unix()
|
return includesIgnoreCase(raw, filter)
|
||||||
case 'upload':
|
})
|
||||||
return a.upload - b.upload
|
|
||||||
case 'download':
|
if (viewMode === 'list' && connectionOrderBy) {
|
||||||
return a.download - b.download
|
return [...filtered].sort((a, b) => {
|
||||||
case 'uploadSpeed':
|
let comparison = 0
|
||||||
return (a.uploadSpeed || 0) - (b.uploadSpeed || 0)
|
switch (connectionOrderBy) {
|
||||||
case 'downloadSpeed':
|
case 'time':
|
||||||
return (a.downloadSpeed || 0) - (b.downloadSpeed || 0)
|
comparison = dayjs(a.start).unix() - dayjs(b.start).unix()
|
||||||
}
|
break
|
||||||
} else {
|
case 'upload':
|
||||||
switch (connectionOrderBy) {
|
comparison = a.upload - b.upload
|
||||||
case 'time':
|
break
|
||||||
return dayjs(a.start).unix() - dayjs(b.start).unix()
|
case 'download':
|
||||||
case 'upload':
|
comparison = a.download - b.download
|
||||||
return b.upload - a.upload
|
break
|
||||||
case 'download':
|
case 'uploadSpeed':
|
||||||
return b.download - a.download
|
comparison = (a.uploadSpeed || 0) - (b.uploadSpeed || 0)
|
||||||
case 'uploadSpeed':
|
break
|
||||||
return (b.uploadSpeed || 0) - (a.uploadSpeed || 0)
|
case 'downloadSpeed':
|
||||||
case 'downloadSpeed':
|
comparison = (a.downloadSpeed || 0) - (b.downloadSpeed || 0)
|
||||||
return (b.downloadSpeed || 0) - (a.downloadSpeed || 0)
|
break
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return connectionDirection === 'asc' ? comparison : -comparison
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (filter === '') return connections
|
|
||||||
return connections?.filter((connection) => {
|
|
||||||
const raw = JSON.stringify(connection)
|
|
||||||
return includesIgnoreCase(raw, filter)
|
|
||||||
})
|
|
||||||
}, [activeConnections, closedConnections, filter, connectionDirection, connectionOrderBy])
|
|
||||||
|
|
||||||
const closeAllConnections = (): void => {
|
return filtered
|
||||||
|
}, [activeConnections, closedConnections, tab, filter, connectionDirection, connectionOrderBy, viewMode])
|
||||||
|
|
||||||
|
const closeAllConnections = useCallback((): void => {
|
||||||
tab === 'active' ? mihomoCloseAllConnections() : trashAllClosedConnection()
|
tab === 'active' ? mihomoCloseAllConnections() : trashAllClosedConnection()
|
||||||
}
|
}, [tab, closedConnections])
|
||||||
|
|
||||||
const closeConnection = (id: string): void => {
|
const closeConnection = useCallback((id: string): void => {
|
||||||
tab === 'active' ? mihomoCloseConnection(id) : trashClosedConnection(id)
|
tab === 'active' ? mihomoCloseConnection(id) : trashClosedConnection(id)
|
||||||
}
|
}, [tab])
|
||||||
|
|
||||||
const trashAllClosedConnection = (): void => {
|
const trashAllClosedConnection = (): void => {
|
||||||
const trashIds = closedConnections.map((conn) => conn.id)
|
const trashIds = closedConnections.map((conn) => conn.id)
|
||||||
@ -159,6 +184,20 @@ const Connections: React.FC = () => {
|
|||||||
showOutline={false}
|
showOutline={false}
|
||||||
content={`${filteredConnections.length}`}
|
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
|
<Button
|
||||||
className="app-nodrag ml-1"
|
className="app-nodrag ml-1"
|
||||||
title={isPaused ? t('connections.resume') : t('connections.pause')}
|
title={isPaused ? t('connections.resume') : t('connections.pause')}
|
||||||
@ -246,64 +285,130 @@ const Connections: React.FC = () => {
|
|||||||
onValueChange={setFilter}
|
onValueChange={setFilter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select
|
{viewMode === 'table' && (
|
||||||
classNames={{ trigger: 'data-[hover=true]:bg-default-200' }}
|
<Dropdown>
|
||||||
size="sm"
|
<DropdownTrigger>
|
||||||
className="w-[180px] min-w-[131px]"
|
<Button
|
||||||
aria-label={t('connections.orderBy')}
|
size="sm"
|
||||||
selectedKeys={new Set([connectionOrderBy])}
|
variant="flat"
|
||||||
disallowEmptySelection={true}
|
startContent={<HiOutlineAdjustmentsHorizontal className="text-2xl" />}
|
||||||
onSelectionChange={async (v) => {
|
>
|
||||||
await patchAppConfig({
|
{t('connections.table.columns')}
|
||||||
connectionOrderBy: v.currentKey as
|
</Button>
|
||||||
| 'time'
|
</DropdownTrigger>
|
||||||
| 'upload'
|
<DropdownMenu
|
||||||
| 'download'
|
aria-label="Column visibility"
|
||||||
| 'uploadSpeed'
|
closeOnSelect={false}
|
||||||
| 'downloadSpeed'
|
selectionMode="multiple"
|
||||||
})
|
selectedKeys={visibleColumns}
|
||||||
}}
|
onSelectionChange={async (keys) => {
|
||||||
>
|
const newColumns = Array.from(keys) as string[]
|
||||||
<SelectItem key="time">{t('connections.time')}</SelectItem>
|
setVisibleColumns(new Set(newColumns))
|
||||||
<SelectItem key="upload">{t('connections.uploadAmount')}</SelectItem>
|
await patchAppConfig({ connectionTableColumns: newColumns })
|
||||||
<SelectItem key="download">{t('connections.downloadAmount')}</SelectItem>
|
}}
|
||||||
<SelectItem key="uploadSpeed">{t('connections.uploadSpeed')}</SelectItem>
|
>
|
||||||
<SelectItem key="downloadSpeed">{t('connections.downloadSpeed')}</SelectItem>
|
<DropdownItem key="status">{t('connections.detail.status')}</DropdownItem>
|
||||||
</Select>
|
<DropdownItem key="establishTime">{t('connections.detail.establishTime')}</DropdownItem>
|
||||||
<Button
|
<DropdownItem key="type">{t('connections.detail.connectionType')}</DropdownItem>
|
||||||
size="sm"
|
<DropdownItem key="host">{t('connections.detail.host')}</DropdownItem>
|
||||||
isIconOnly
|
<DropdownItem key="sniffHost">{t('connections.detail.sniffHost')}</DropdownItem>
|
||||||
className="bg-content2"
|
<DropdownItem key="process">{t('connections.detail.processName')}</DropdownItem>
|
||||||
onPress={async () => {
|
<DropdownItem key="processPath">{t('connections.detail.processPath')}</DropdownItem>
|
||||||
patchAppConfig({
|
<DropdownItem key="rule">{t('connections.detail.rule')}</DropdownItem>
|
||||||
connectionDirection: connectionDirection === 'asc' ? 'desc' : 'asc'
|
<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>
|
||||||
{connectionDirection === 'asc' ? (
|
<DropdownItem key="inboundIP">{t('connections.detail.inboundIP')}</DropdownItem>
|
||||||
<HiSortAscending className="text-lg" />
|
<DropdownItem key="inboundPort">{t('connections.detail.inboundPort')}</DropdownItem>
|
||||||
) : (
|
<DropdownItem key="uploadSpeed">{t('connections.uploadSpeed')}</DropdownItem>
|
||||||
<HiSortDescending className="text-lg" />
|
<DropdownItem key="downloadSpeed">{t('connections.downloadSpeed')}</DropdownItem>
|
||||||
)}
|
<DropdownItem key="upload">{t('connections.uploadAmount')}</DropdownItem>
|
||||||
</Button>
|
<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"
|
||||||
|
className="w-[180px] min-w-[131px]"
|
||||||
|
aria-label={t('connections.orderBy')}
|
||||||
|
selectedKeys={new Set([connectionOrderBy])}
|
||||||
|
disallowEmptySelection={true}
|
||||||
|
onSelectionChange={async (v) => {
|
||||||
|
await patchAppConfig({
|
||||||
|
connectionOrderBy: v.currentKey as
|
||||||
|
| 'time'
|
||||||
|
| 'upload'
|
||||||
|
| 'download'
|
||||||
|
| 'uploadSpeed'
|
||||||
|
| 'downloadSpeed'
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectItem key="time">{t('connections.time')}</SelectItem>
|
||||||
|
<SelectItem key="upload">{t('connections.uploadAmount')}</SelectItem>
|
||||||
|
<SelectItem key="download">{t('connections.downloadAmount')}</SelectItem>
|
||||||
|
<SelectItem key="uploadSpeed">{t('connections.uploadSpeed')}</SelectItem>
|
||||||
|
<SelectItem key="downloadSpeed">{t('connections.downloadSpeed')}</SelectItem>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
isIconOnly
|
||||||
|
className="bg-content2"
|
||||||
|
onPress={async () => {
|
||||||
|
patchAppConfig({
|
||||||
|
connectionDirection: connectionDirection === 'asc' ? 'desc' : 'asc'
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{connectionDirection === 'asc' ? (
|
||||||
|
<HiSortAscending className="text-lg" />
|
||||||
|
) : (
|
||||||
|
<HiSortDescending className="text-lg" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Divider />
|
<Divider />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[calc(100vh-100px)] mt-px">
|
<div className="h-[calc(100vh-100px)] mt-px">
|
||||||
<Virtuoso
|
{viewMode === 'list' ? (
|
||||||
data={filteredConnections}
|
<Virtuoso
|
||||||
itemContent={(i, connection) => (
|
data={filteredConnections}
|
||||||
<ConnectionItem
|
itemContent={(i, connection) => (
|
||||||
setSelected={setSelected}
|
<ConnectionItem
|
||||||
setIsDetailModalOpen={setIsDetailModalOpen}
|
setSelected={setSelected}
|
||||||
selected={selected}
|
setIsDetailModalOpen={setIsDetailModalOpen}
|
||||||
close={closeConnection}
|
selected={selected}
|
||||||
index={i}
|
close={closeConnection}
|
||||||
key={connection.id}
|
index={i}
|
||||||
info={connection}
|
key={connection.id}
|
||||||
/>
|
info={connection}
|
||||||
)}
|
/>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ConnectionTable
|
||||||
|
connections={filteredConnections}
|
||||||
|
setSelected={setSelected}
|
||||||
|
setIsDetailModalOpen={setIsDetailModalOpen}
|
||||||
|
close={closeConnection}
|
||||||
|
visibleColumns={visibleColumns}
|
||||||
|
initialColumnWidths={connectionTableColumnWidths}
|
||||||
|
initialSortColumn={connectionTableSortColumn}
|
||||||
|
initialSortDirection={connectionTableSortDirection}
|
||||||
|
onColumnWidthChange={handleColumnWidthChange}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</BasePage>
|
</BasePage>
|
||||||
)
|
)
|
||||||
|
|||||||
5
src/shared/types.d.ts
vendored
5
src/shared/types.d.ts
vendored
@ -234,6 +234,11 @@ interface IAppConfig {
|
|||||||
proxyCols: 'auto' | '1' | '2' | '3' | '4'
|
proxyCols: 'auto' | '1' | '2' | '3' | '4'
|
||||||
connectionDirection: 'asc' | 'desc'
|
connectionDirection: 'asc' | 'desc'
|
||||||
connectionOrderBy: 'time' | 'upload' | 'download' | 'uploadSpeed' | 'downloadSpeed'
|
connectionOrderBy: 'time' | 'upload' | 'download' | 'uploadSpeed' | 'downloadSpeed'
|
||||||
|
connectionViewMode?: 'list' | 'table'
|
||||||
|
connectionTableColumns?: string[]
|
||||||
|
connectionTableColumnWidths?: Record<string, number>
|
||||||
|
connectionTableSortColumn?: string
|
||||||
|
connectionTableSortDirection?: 'asc' | 'desc'
|
||||||
spinFloatingIcon?: boolean
|
spinFloatingIcon?: boolean
|
||||||
disableTray?: boolean
|
disableTray?: boolean
|
||||||
showFloatingWindow?: boolean
|
showFloatingWindow?: boolean
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user