diff --git a/src/renderer/src/components/connections/connection-table.tsx b/src/renderer/src/components/connections/connection-table.tsx new file mode 100644 index 0000000..c76746b --- /dev/null +++ b/src/renderer/src/components/connections/connection-table.tsx @@ -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> + setIsDetailModalOpen: React.Dispatch> + close: (id: string) => void + visibleColumns: Set + onColumnWidthChange?: (widths: Record) => void + onSortChange?: (column: string | null, direction: 'asc' | 'desc') => void + initialColumnWidths?: Record + 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[] = [ + { + 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) => ( + {conn.metadata.type}({conn.metadata.network.toUpperCase()}) + ) + }, + { + 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 = ({ + connections, + setSelected, + setIsDetailModalOpen, + close, + visibleColumns, + onColumnWidthChange, + onSortChange, + initialColumnWidths, + initialSortColumn, + initialSortDirection +}) => { + const { t } = useTranslation() + const tableRef = useRef(null) + const [resizingColumn, setResizingColumn] = useState(null) + const [sortColumn, setSortColumn] = useState(initialSortColumn || null) + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(initialSortDirection || 'asc') + + // 状态列渲染函数 + const renderStatus = useCallback((conn: IMihomoConnectionDetail) => ( + + {conn.isActive ? t('connections.active') : t('connections.closed')} + + ), [t]) + + // 连接类型渲染函数 + const renderType = useCallback((conn: IMihomoConnectionDetail) => ( + {conn.metadata.type}({conn.metadata.network.toUpperCase()}) + ), []) + + // 翻译标签映射 + const getLabelForColumn = useCallback((key: string): string => { + const translationMap: Record = { + 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>(() => { + const widths: Record = {} + 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 ( +
+ {/* 表格容器 */} +
+ + + + {visibleColumnsFiltered.map(col => ( + + ))} + + + + {sortedConnections.map((connection, index) => ( + { + 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 ( + + ) + })} + + + ))} + +
+
+ +
handleMouseDown(e, col.key)} + > +
+
+
+
+
+ {content} + e.stopPropagation()}> + +
+ {sortedConnections.length === 0 && ( +
+ {t('connections.table.noData')} +
+ )} +
+
+ ) +} + +export default ConnectionTable diff --git a/src/renderer/src/locales/en-US.json b/src/renderer/src/locales/en-US.json index 2454baa..c6c19de 100644 --- a/src/renderer/src/locales/en-US.json +++ b/src/renderer/src/locales/en-US.json @@ -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", diff --git a/src/renderer/src/locales/zh-CN.json b/src/renderer/src/locales/zh-CN.json index 97918de..05aa121 100644 --- a/src/renderer/src/locales/zh-CN.json +++ b/src/renderer/src/locales/zh-CN.json @@ -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 数据库", diff --git a/src/renderer/src/pages/connections.tsx b/src/renderer/src/pages/connections.tsx index e673ca0..fb79c68 100644 --- a/src/renderer/src/pages/connections.tsx +++ b/src/renderer/src/pages/connections.tsx @@ -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() const [allConnections, setAllConnections] = useState(cachedConnections) const [activeConnections, setActiveConnections] = useState([]) @@ -30,54 +45,64 @@ const Connections: React.FC = () => { const [selected, setSelected] = useState() const [tab, setTab] = useState('active') const [isPaused, setIsPaused] = useState(false) + const [viewMode, setViewMode] = useState<'list' | 'table'>(connectionViewMode) + const [visibleColumns, setVisibleColumns] = useState>(new Set(connectionTableColumns)) + + const handleColumnWidthChange = useCallback(async (widths: Record) => { + 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) - } + + const filtered = filter === '' + ? connections + : connections.filter((connection) => { + const raw = JSON.stringify(connection) + return includesIgnoreCase(raw, filter) + }) + + 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 }) } - 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, 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}`} > + + {viewMode === 'table' && ( + + + + + { + const newColumns = Array.from(keys) as string[] + setVisibleColumns(new Set(newColumns)) + await patchAppConfig({ connectionTableColumns: newColumns }) + }} + > + {t('connections.detail.status')} + {t('connections.detail.establishTime')} + {t('connections.detail.connectionType')} + {t('connections.detail.host')} + {t('connections.detail.sniffHost')} + {t('connections.detail.processName')} + {t('connections.detail.processPath')} + {t('connections.detail.rule')} + {t('connections.detail.proxyChain')} + {t('connections.detail.sourceIP')} + {t('connections.detail.sourcePort')} + {t('connections.detail.destinationPort')} + {t('connections.detail.inboundIP')} + {t('connections.detail.inboundPort')} + {t('connections.uploadSpeed')} + {t('connections.downloadSpeed')} + {t('connections.uploadAmount')} + {t('connections.downloadAmount')} + {t('connections.detail.dscp')} + {t('connections.detail.remoteDestination')} + {t('connections.detail.dnsMode')} + + + )} + + {viewMode === 'list' && ( + <> + + + + )}
- ( - - )} - /> + {viewMode === 'list' ? ( + ( + + )} + /> + ) : ( + + )}
) diff --git a/src/shared/types.d.ts b/src/shared/types.d.ts index 621604c..04f4628 100644 --- a/src/shared/types.d.ts +++ b/src/shared/types.d.ts @@ -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 + connectionTableSortColumn?: string + connectionTableSortDirection?: 'asc' | 'desc' spinFloatingIcon?: boolean disableTray?: boolean showFloatingWindow?: boolean