import './ConnectionTable.scss'; import { ColumnDef, getCoreRowModel, getSortedRowModel, SortingState, useReactTable, VisibilityState, } from '@tanstack/react-table'; import cx from 'clsx'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { List as VirtualList, RowComponentProps } from 'react-window'; import * as connAPI from '~/api/connections'; import { ArrowDown, ArrowUp, ChevronDown, Sliders, XCircle } from '~/components/shared/FeatherIcons'; import prettyBytes from '~/misc/pretty-bytes'; import { formatElapsed, getDateFnsLocale } from '~/modules/connections/utils'; import { FormattedConn } from '~/store/connections'; import ConnectionCard from './ConnectionCard'; import s from './ConnectionTable.module.scss'; import MOdalCloseConnection from './ModalCloseAllConnections'; import ModalConnectionDetails from './ModalConnectionDetails'; const sortById = { id: 'id', desc: true }; const COLUMN_WIDTHS = { ctrl: 50, start: 100, type: 120, host: 300, rule: 200, chains: 250, download: 100, upload: 100, downloadSpeedCurr: 100, uploadSpeedCurr: 100, source: 170, destinationIP: 170, process: 130, sniffHost: 150, }; const TOTAL_WIDTH = Object.values(COLUMN_WIDTHS).reduce((a, b) => a + b, 0); const getColumnStyle = (columnId: string) => { const width = COLUMN_WIDTHS[columnId] || 100; const style: React.CSSProperties = { width, minWidth: width, flex: `0 0 ${width}px`, flexShrink: 0, }; if (['download', 'upload', 'downloadSpeedCurr', 'uploadSpeedCurr', 'start'].includes(columnId)) { style.justifyContent = 'flex-end'; } if (columnId === 'ctrl') { style.justifyContent = 'center'; } return style; }; function Table({ data, columns, hiddenColumns, apiConfig, height }) { const { t, i18n } = useTranslation(); const [operationId, setOperationId] = useState(''); const [showModalDisconnect, setShowModalDisconnect] = useState(false); const [selectedConn, setSelectedConn] = useState(null); const [isMobile, setIsMobile] = useState(false); const headerRef = React.useRef(null); useEffect(() => { const mql = window.matchMedia('(max-width: 768px)'); setIsMobile(mql.matches); const listener = (e) => setIsMobile(e.matches); mql.addEventListener('change', listener); return () => mql.removeEventListener('change', listener); }, []); // react-table v8 列定义:从项目内部的 ConnectionColumn 形态映射而来 const columnDefs = useMemo[]>( () => columns.map((c) => ({ id: c.accessor, accessorFn: (row: FormattedConn) => (row as any)[c.accessor], header: c.Header ?? c.accessor, enableSorting: c.accessor !== 'ctrl', sortDescFirst: c.sortDescFirst ?? false, })), [columns] ); // 从本地存储加载排序状态(v7 sortBy 与 v8 SortingState 形态一致,可直接复用) const [sorting, setSorting] = useState(() => { return JSON.parse(localStorage.getItem('tableSortBy')) || [sortById]; }); // hiddenColumns 为需隐藏的列 id 列表,转换为 v8 的可见性映射 const columnVisibility = useMemo( () => Object.fromEntries(hiddenColumns.map((id: string) => [id, false])), [hiddenColumns] ); const table = useReactTable({ data, columns: columnDefs, state: { sorting, columnVisibility }, onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), autoResetAll: false, }); const rows = table.getRowModel().rows; const sortOptions = useMemo(() => { return columns .filter((c) => c.accessor !== 'id' && c.accessor !== 'ctrl') .map((c) => ({ label: t(c.Header), value: c.accessor, })); }, [columns, t]); const currentSort = sorting[0] || sortById; const locale = getDateFnsLocale(i18n.language); const disconnectOperation = useCallback(() => { connAPI.closeConnById(apiConfig, operationId); setShowModalDisconnect(false); }, [apiConfig, operationId]); const handlerDisconnect = useCallback((id, e) => { e.stopPropagation(); setOperationId(id); setShowModalDisconnect(true); }, []); const renderCell = useCallback( (cell, locale) => { switch (cell.column.id) { case 'ctrl': return ( handlerDisconnect(cell.row.original.id, e)} > ); case 'start': return formatElapsed(cell.getValue(), locale); case 'download': case 'upload': return prettyBytes(cell.getValue()); case 'downloadSpeedCurr': case 'uploadSpeedCurr': return prettyBytes(cell.getValue()) + '/s'; default: return cell.getValue(); } }, [handlerDisconnect] ); // 当排序状态改变时,将新状态保存到本地存储 useEffect(() => { localStorage.setItem('tableSortBy', JSON.stringify(sorting)); }, [sorting]); const MobileRow = useCallback( ({ index, style }: RowComponentProps) => { const row = rows[index]; const conn = row.original as FormattedConn; return (
setSelectedConn(conn)} />
); }, [rows, handlerDisconnect] ); const DesktopRow = useCallback( ({ index, style }: RowComponentProps) => { const row = rows[index]; return (
setSelectedConn(row.original as FormattedConn)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedConn(row.original as FormattedConn); } }} > {row.getVisibleCells().map((cell) => { const columnStyle = getColumnStyle(cell.column.id); return (
{renderCell(cell, locale)}
); })}
); }, [rows, renderCell, locale] ); const handleDesktopListScroll = useCallback((e: React.UIEvent) => { if (headerRef.current) { headerRef.current.scrollLeft = e.currentTarget.scrollLeft; } }, []); return (
{isMobile ? (
{t('Sort')}: {sortOptions.find((opt) => opt.value === currentSort.id)?.label}
) : (
{table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => { const columnStyle = getColumnStyle(header.column.id); const sortDir = header.column.getIsSorted(); const canSort = header.column.getCanSort(); const sortHandler = header.column.getToggleSortingHandler(); return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); sortHandler?.(e); } } : undefined } role={canSort ? 'button' : undefined} tabIndex={canSort ? 0 : undefined} style={{ display: 'flex', alignItems: 'center', cursor: canSort ? 'pointer' : 'default', ...columnStyle, }} > {t(header.column.columnDef.header as string)} {header.column.id !== 'ctrl' ? ( {sortDir ? ( ) : null} ) : null}
); })}
))}
)} setShowModalDisconnect(false)} primaryButtonOnTap={disconnectOperation} > setSelectedConn(null)} connection={selectedConn} />
); } export default Table;