diff options
| author | Larvan2 <[email protected]> | 2026-01-01 15:52:18 +0800 |
|---|---|---|
| committer | Larvan2 <[email protected]> | 2026-01-01 15:52:18 +0800 |
| commit | a547b3c8dc48791c4f767da0314d907eba543cc1 (patch) | |
| tree | 70a0d500147dcb972574e9a9641da956e033a963 /src/components/ConnectionTable.tsx | |
| parent | 6768024fc9460f7f5a459de32de4cf771c75e19c (diff) | |
chore: adjust style
Diffstat (limited to 'src/components/ConnectionTable.tsx')
| -rw-r--r-- | src/components/ConnectionTable.tsx | 392 |
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} |
