summaryrefslogtreecommitdiff
path: root/src/components/ConnectionTable.tsx
diff options
context:
space:
mode:
authorLarvan2 <[email protected]>2026-01-01 15:52:18 +0800
committerLarvan2 <[email protected]>2026-01-01 15:52:18 +0800
commita547b3c8dc48791c4f767da0314d907eba543cc1 (patch)
tree70a0d500147dcb972574e9a9641da956e033a963 /src/components/ConnectionTable.tsx
parent6768024fc9460f7f5a459de32de4cf771c75e19c (diff)
chore: adjust style
Diffstat (limited to 'src/components/ConnectionTable.tsx')
-rw-r--r--src/components/ConnectionTable.tsx392
1 files changed, 277 insertions, 115 deletions
diff --git a/src/components/ConnectionTable.tsx b/src/components/ConnectionTable.tsx
index 630167a..9dcb7ae 100644
--- a/src/components/ConnectionTable.tsx
+++ b/src/components/ConnectionTable.tsx
@@ -3,11 +3,11 @@ import './ConnectionTable.scss';
import cx from 'clsx';
import { formatDistance, Locale } from 'date-fns';
import { enUS, zhCN, zhTW } from 'date-fns/locale';
-import React, { useEffect, useMemo, useState } from 'react';
-import { ArrowDown, ArrowUp, ChevronDown, Sliders } from 'react-feather';
-import { XCircle } from 'react-feather';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { ArrowDown, ArrowUp, ChevronDown, Sliders, XCircle } from 'react-feather';
import { useTranslation } from 'react-i18next';
import { useSortBy, useTable } from 'react-table';
+import { FixedSizeList as List } from 'react-window';
import { FormattedConn } from '~/store/connections';
import { State } from '~/store/types';
@@ -23,19 +23,97 @@ import { connect } from './StateProvider';
const sortById = { id: 'id', desc: true };
-function Table({ data, columns, hiddenColumns, apiConfig }) {
+const COLUMN_WIDTHS = {
+ ctrl: 50,
+ start: 100,
+ type: 120,
+ host: 300,
+ rule: 180,
+ 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 InnerElement = React.forwardRef<HTMLDivElement, React.HTMLProps<HTMLDivElement>>(
+ ({ style, ...rest }, ref) => (
+ <div
+ ref={ref}
+ style={{
+ ...style,
+ width: TOTAL_WIDTH,
+ }}
+ {...rest}
+ />
+ )
+);
+
+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<FormattedConn | null>(null);
- // 从本地存储加载排序状态
- const savedSortBy = JSON.parse(localStorage.getItem('tableSortBy')) || [sortById];
+ const [isMobile, setIsMobile] = useState(false);
- const tableState = {
- sortBy: savedSortBy,
- hiddenColumns,
- };
+ const headerRef = React.useRef<HTMLDivElement>(null);
+ const outerRef = React.useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ const outer = outerRef.current;
+ if (!outer) return;
+ const handleScroll = () => {
+ if (headerRef.current) {
+ headerRef.current.scrollLeft = outer.scrollLeft;
+ }
+ };
+ outer.addEventListener('scroll', handleScroll);
+ return () => outer.removeEventListener('scroll', handleScroll);
+ }, []);
+
+ 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);
+ }, []);
+
+ // 从本地存储加载排序状态
+ const tableState = useMemo(() => {
+ const savedSortBy = JSON.parse(localStorage.getItem('tableSortBy')) || [sortById];
+ return {
+ sortBy: savedSortBy,
+ hiddenColumns,
+ };
+ }, [hiddenColumns]);
const table = useTable(
{
@@ -47,7 +125,7 @@ function Table({ data, columns, hiddenColumns, apiConfig }) {
useSortBy
);
- const { getTableProps, setHiddenColumns, headerGroups, rows, prepareRow, toggleSortBy } = table;
+ const { setHiddenColumns, headerGroups, rows, prepareRow, toggleSortBy } = table;
const state = table.state;
const sortOptions = useMemo(() => {
@@ -75,130 +153,214 @@ function Table({ data, columns, hiddenColumns, apiConfig }) {
locale = enUS;
}
- const disconnectOperation = () => {
+ const disconnectOperation = useCallback(() => {
connAPI.closeConnById(apiConfig, operationId);
setShowModalDisconnect(false);
- };
+ }, [apiConfig, operationId]);
- const handlerDisconnect = (id, e) => {
+ const handlerDisconnect = useCallback((id, e) => {
e.stopPropagation();
setOperationId(id);
setShowModalDisconnect(true);
- };
+ }, []);
- const renderCell = (cell, locale) => {
- switch (cell.column.id) {
- case 'ctrl':
- return (
- <XCircle
- style={{ cursor: 'pointer' }}
- onClick={(e) => handlerDisconnect(cell.row.original.id, e)}
- ></XCircle>
- );
- case 'start':
- return formatDistance(cell.value, 0, { locale: locale });
- case 'download':
- case 'upload':
- return prettyBytes(cell.value);
- case 'downloadSpeedCurr':
- case 'uploadSpeedCurr':
- return prettyBytes(cell.value) + '/s';
- default:
- return cell.value;
- }
- };
+ const renderCell = useCallback(
+ (cell, locale) => {
+ switch (cell.column.id) {
+ case 'ctrl':
+ return (
+ <XCircle
+ style={{ cursor: 'pointer' }}
+ onClick={(e) => handlerDisconnect(cell.row.original.id, e)}
+ ></XCircle>
+ );
+ case 'start':
+ return formatDistance(cell.value, 0, { locale: locale });
+ case 'download':
+ case 'upload':
+ return prettyBytes(cell.value);
+ case 'downloadSpeedCurr':
+ case 'uploadSpeedCurr':
+ return prettyBytes(cell.value) + '/s';
+ default:
+ return cell.value;
+ }
+ },
+ [handlerDisconnect]
+ );
// 当排序状态改变时,将新状态保存到本地存储
useEffect(() => {
localStorage.setItem('tableSortBy', JSON.stringify(state.sortBy));
}, [state.sortBy]);
+ const MobileRow = useCallback(
+ ({ index, style }) => {
+ const row = rows[index];
+ const conn = row.original as FormattedConn;
+ return (
+ <div style={style}>
+ <ConnectionCard
+ key={conn.id}
+ conn={conn}
+ onDisconnect={handlerDisconnect}
+ onClick={() => setSelectedConn(conn)}
+ />
+ </div>
+ );
+ },
+ [rows, handlerDisconnect]
+ );
+
+ const DesktopRow = useCallback(
+ ({ index, style }) => {
+ const row = rows[index];
+ prepareRow(row);
+ return (
+ <div
+ {...(row as any).getRowProps({
+ style: {
+ ...style,
+ display: 'flex',
+ width: TOTAL_WIDTH,
+ },
+ })}
+ className={s.tr}
+ onClick={() => setSelectedConn((row as any).original)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setSelectedConn((row as any).original);
+ }
+ }}
+ >
+ {row.cells.map((cell) => {
+ const columnStyle = getColumnStyle(cell.column.id);
+ return (
+ <div
+ {...cell.getCellProps()}
+ className={cx(s.td, index % 2 === 0 ? s.odd : false, cell.column.id)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ ...columnStyle,
+ }}
+ >
+ <span className={s.cellText}>{renderCell(cell, locale)}</span>
+ </div>
+ );
+ })}
+ </div>
+ );
+ },
+ [prepareRow, rows, renderCell, locale]
+ );
+
return (
- <div className={s.tableWrapper}>
- <div className={s.cardsView}>
- <div className={s.mobileSortToolbar}>
- <div className={s.sortSelectWrapper}>
- <div className={s.selectedValue}>
- <Sliders size={14} />
- <span>
- {t('Sort')}: {sortOptions.find((opt) => opt.value === currentSort.id)?.label}
- </span>
+ <div className={s.tableWrapper} style={{ height, overflow: 'hidden' }}>
+ {isMobile ? (
+ <div className={s.cardsView}>
+ <div className={s.mobileSortToolbar}>
+ <div className={s.sortSelectWrapper}>
+ <div className={s.selectedValue}>
+ <Sliders size={14} />
+ <span>
+ {t('Sort')}: {sortOptions.find((opt) => opt.value === currentSort.id)?.label}
+ </span>
+ </div>
+ <select
+ value={currentSort.id}
+ onChange={(e) => toggleSortBy(e.target.value, currentSort.desc)}
+ >
+ {sortOptions.map((opt) => (
+ <option key={opt.value} value={opt.value}>
+ {opt.label}
+ </option>
+ ))}
+ </select>
+ <ChevronDown size={14} className={s.selectArrow} />
</div>
- <select
- value={currentSort.id}
- onChange={(e) => toggleSortBy(e.target.value, currentSort.desc)}
+ <button
+ className={s.sortDirBtn}
+ onClick={() => toggleSortBy(currentSort.id, !currentSort.desc)}
>
- {sortOptions.map((opt) => (
- <option key={opt.value} value={opt.value}>
- {opt.label}
- </option>
+ {currentSort.desc ? <ArrowDown size={18} /> : <ArrowUp size={18} />}
+ </button>
+ </div>
+ <List height={height - 50} itemCount={rows.length} itemSize={120} width="100%">
+ {MobileRow}
+ </List>
+ </div>
+ ) : (
+ <div
+ className={cx(s.table, 'connections-table')}
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ width: '100%',
+ }}
+ >
+ <div
+ className={s.theadWrapper}
+ ref={headerRef}
+ style={{ overflow: 'hidden', width: '100%' }}
+ >
+ <div className={s.thead} style={{ width: TOTAL_WIDTH }}>
+ {headerGroups.map((headerGroup, trindex) => (
+ <div
+ {...headerGroup.getHeaderGroupProps()}
+ className={s.tr}
+ key={trindex}
+ style={{ display: 'flex' }}
+ >
+ {headerGroup.headers.map((column) => {
+ const columnStyle = getColumnStyle(column.id);
+ return (
+ <div
+ {...column.getHeaderProps(column.getSortByToggleProps())}
+ className={s.th}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ ...columnStyle,
+ }}
+ >
+ <span className={s.headerText}>{t(column.render('Header'))}</span>
+ {column.id !== 'ctrl' ? (
+ <span className={s.sortIconContainer}>
+ {column.isSorted ? (
+ <ChevronDown
+ size={14}
+ className={column.isSortedDesc ? '' : s.rotate180}
+ />
+ ) : null}
+ </span>
+ ) : null}
+ </div>
+ );
+ })}
+ </div>
))}
- </select>
- <ChevronDown size={14} className={s.selectArrow} />
+ </div>
</div>
- <button
- className={s.sortDirBtn}
- onClick={() => toggleSortBy(currentSort.id, !currentSort.desc)}
+ <List
+ height={height - 50}
+ itemCount={rows.length}
+ itemSize={44}
+ width="100%"
+ outerRef={outerRef}
+ innerElementType={InnerElement}
>
- {currentSort.desc ? <ArrowDown size={18} /> : <ArrowUp size={18} />}
- </button>
+ {DesktopRow}
+ </List>
</div>
- {rows.map((row) => {
- const conn = row.original as FormattedConn;
- return (
- <ConnectionCard
- key={conn.id}
- conn={conn}
- onDisconnect={handlerDisconnect}
- onClick={() => setSelectedConn(conn)}
- />
- );
- })}
- </div>
- <table {...getTableProps()} className={cx(s.table, 'connections-table')}>
- <thead>
- {headerGroups.map((headerGroup, trindex) => {
- return (
- <tr {...headerGroup.getHeaderGroupProps()} className={s.tr} key={trindex}>
- {headerGroup.headers.map((column) => (
- <th {...column.getHeaderProps(column.getSortByToggleProps())} className={s.th}>
- <span>{t(column.render('Header'))}</span>
- {column.id !== 'ctrl' ? (
- <span className={s.sortIconContainer}>
- {column.isSorted ? (
- <ChevronDown
- size={16}
- className={column.isSortedDesc ? '' : s.rotate180}
- />
- ) : null}
- </span>
- ) : null}
- </th>
- ))}
- </tr>
- );
- })}
- </thead>
- <tbody>
- {rows.map((row, i) => {
- prepareRow(row);
- return (
- <tr className={s.tr} key={i} onClick={() => setSelectedConn((row as any).original)}>
- {row.cells.map((cell) => {
- return (
- <td
- {...cell.getCellProps()}
- className={cx(s.td, i % 2 === 0 ? s.odd : false, cell.column.id)}
- >
- {renderCell(cell, locale)}
- </td>
- );
- })}
- </tr>
- );
- })}
- </tbody>
- </table>
+ )}
<MOdalCloseConnection
confirm={'disconnect'}
isOpen={showModalDisconnect}