diff --git a/web/src/components/accounts/AccountManagementList.tsx b/web/src/components/accounts/AccountManagementList.tsx index ffda90a..69accf4 100644 --- a/web/src/components/accounts/AccountManagementList.tsx +++ b/web/src/components/accounts/AccountManagementList.tsx @@ -8,6 +8,7 @@ import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { remove, creatorUpdate, creatorGet } from '@/services/api'; import { conflictMessage, dateTime } from '@/utils/helpers'; +import { fixedLeft, fixedRight, tablePagination, tableScroll, useOverflowGrid } from '@/utils/table'; // 仅用 antd 默认组件:Table/Tag/Modal/Popconfirm/Select(tags)。 interface Row { @@ -30,6 +31,7 @@ export default function AccountManagementList() { const [tagBusy, setTagBusy] = useState(false); const [actionBusy, setActionBusy] = useState(''); const { message: messageApi } = App.useApp(); + const { vertical, horizontal } = useOverflowGrid(rows.length); const load = useCallback(async () => { setPending(true); @@ -91,6 +93,7 @@ export default function AccountManagementList() { { title: '账号', dataIndex: 'name', + ...fixedLeft({}), render: (_, account) => (
history.push(`/accounts/${account.id}`)}>{account.name} @@ -113,7 +116,7 @@ export default function AccountManagementList() { { title: '标签', dataIndex: 'tags', - render: (tags?: string[]) => (tags?.length ? tags.map((tag) => {tag}) : 未设置) }, + render: (tags?: string[]) => (tags?.length ? {tags.map((tag) => {tag})} : 未设置) }, { title: '状态', dataIndex: 'status', @@ -129,12 +132,13 @@ export default function AccountManagementList() { title: '操作', key: 'actions', align: 'right', + ...fixedRight({}), render: (_, account) => ( - - deleteAccount(account)} disabled={actionBusy === account.id} > - @@ -169,7 +173,9 @@ export default function AccountManagementList() { columns={columns} dataSource={rows} loading={pending} - pagination={false} + sticky + pagination={tablePagination()} + scroll={tableScroll(horizontal ? 960 : undefined, vertical ? 'calc(100vh - 300px)' : undefined)} locale={{ emptyText: '暂无自有账号。' }} /> { setPending(true); @@ -129,9 +131,10 @@ export default function MonitoringAccountList() { { title: '账号', dataIndex: 'nickname', + ...fixedLeft({}), render: (_, account) => ( - - {(account.nickname || '账').slice(0, 1).toUpperCase()} + + {(account.nickname || '账').slice(0, 1).toUpperCase()} history.push(`/accounts/monitoring/${account.id}`)}> {account.nickname || '未命名账号'} @@ -174,17 +177,18 @@ export default function MonitoringAccountList() { { title: '标签', dataIndex: 'tags', - render: (tags?: string[]) => tags?.length ? tags.map((tag) => {tag}) : 未设置, + render: (tags?: string[]) => tags?.length ? {tags.map((tag) => {tag})} : 未设置, }, { title: '操作', key: 'actions', align: 'right', + ...fixedRight({}), render: (_, account) => ( - - - + + deleteAccount(account)} disabled={actionBusy === account.id} > - + ), @@ -218,9 +222,10 @@ export default function MonitoringAccountList() { columns={columns} dataSource={rows} loading={pending} - pagination={false} + sticky + pagination={tablePagination()} locale={{ emptyText: '暂无监控账号。' }} - scroll={{ x: 1100 }} + scroll={tableScroll(horizontal ? 1100 : undefined, vertical ? 'calc(100vh - 300px)' : undefined)} /> (null); const { message: messageApi } = App.useApp(); + const { vertical, horizontal } = useOverflowGrid(jobs.length); const loadShareJobs = useCallback(async () => { setPending(true); @@ -106,6 +108,7 @@ export default function Page() { { title: '账号名称', dataIndex: 'author_name', + ...fixedLeft({}), render: (value?: string, job?: Job) => { if (!value) { return ( @@ -158,9 +161,10 @@ export default function Page() { title: '操作', key: 'actions', align: 'right', + ...fixedRight({}), render: (_, job) => job.status === 'failed' ? ( - ) : ( @@ -182,7 +186,7 @@ export default function Page() { {error ? ( 重试} /> ) : null} - +
diff --git a/web/src/pages/browsers/index.tsx b/web/src/pages/browsers/index.tsx index da12a0f..6585bf4 100644 --- a/web/src/pages/browsers/index.tsx +++ b/web/src/pages/browsers/index.tsx @@ -6,6 +6,7 @@ import { Alert, App, Button, Card, Flex, Popconfirm, Space, Table, Tag, Typograp import { CopyOutlined, PlusOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { browserAction, getList } from '@/services/api'; +import { fixedLeft, fixedRight, tablePagination, tableScroll, useOverflowGrid } from '@/utils/table'; const stateText: Record = { created: '已创建', @@ -36,6 +37,7 @@ export default function Page() { const [error, setError] = useState(null); const [busy, setBusy] = useState(''); const { message: messageApi } = App.useApp(); + const { vertical, horizontal } = useOverflowGrid(runtimes.length); const load = useCallback(async () => { setPending(true); @@ -74,6 +76,7 @@ export default function Page() { { title: '环境', dataIndex: 'name', + ...fixedLeft({}), render: (_, runtime) => (
history.push(`/browsers/${encodeURIComponent(runtime.alias)}`)}> {runtime.name} @@ -124,6 +127,7 @@ export default function Page() { title: '操作', key: 'actions', align: 'right', + ...fixedRight({}), render: (_, runtime) => { const running = runtime.state === 'running'; const stopping = ['running', 'degraded', 'starting', 'failed', 'cleanup_pending'].includes(runtime.state); @@ -132,6 +136,7 @@ export default function Page() { return ( - handleRecycle(runtime)}> - @@ -168,7 +173,9 @@ export default function Page() { columns={columns} dataSource={runtimes} loading={pending} - pagination={false} + sticky + pagination={tablePagination()} + scroll={tableScroll(horizontal ? 1000 : undefined, vertical ? 'calc(100vh - 300px)' : undefined)} locale={{ emptyText: '暂无运行环境,创建第一个隔离 Profile。' }} /> diff --git a/web/src/pages/gateways/index.tsx b/web/src/pages/gateways/index.tsx index 76297be..7411619 100644 --- a/web/src/pages/gateways/index.tsx +++ b/web/src/pages/gateways/index.tsx @@ -5,6 +5,7 @@ import { CopyOutlined, EyeInvisibleOutlined, EyeOutlined, PlusOutlined } from '@ import type { ColumnsType } from 'antd/es/table'; import { create, getList, remove, update } from '@/services/api'; import { conflictMessage } from '@/utils/helpers'; +import { fixedLeft, fixedRight, tablePagination, tableScroll, useOverflowGrid } from '@/utils/table'; const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; const tokenPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$/; @@ -79,6 +80,7 @@ export default function Page() { const [busy, setBusy] = useState(''); const [created, setCreated] = useState(null); const { message: messageApi } = App.useApp(); + const { vertical, horizontal } = useOverflowGrid(gateways.length); const load = useCallback(async () => { setPending(true); @@ -142,7 +144,7 @@ export default function Page() { } const columns: ColumnsType = [ - { title: '名称', dataIndex: 'name', render: (value: string) => {value} }, + { title: '名称', dataIndex: 'name', ...fixedLeft({}), render: (value: string) => {value} }, { title: 'Endpoint', dataIndex: 'endpoint', render: (value: string) => {value} }, { title: '令牌', @@ -152,13 +154,14 @@ export default function Page() { title: '操作', key: 'actions', align: 'right', + ...fixedRight({}), render: (_, gateway) => ( - removeGateway(gateway)}> - @@ -200,7 +203,7 @@ export default function Page() { {error ? ( 重试} /> ) : null} -
+
= { unchecked: '未检测', @@ -112,6 +113,7 @@ export default function Page() { const [editing, setEditing] = useState(null); const [busy, setBusy] = useState(''); const { message: messageApi } = App.useApp(); + const { vertical, horizontal } = useOverflowGrid(exits.length); const load = useCallback(async () => { setPending(true); @@ -215,6 +217,7 @@ export default function Page() { { title: '出口', dataIndex: 'id', + ...fixedLeft({}), render: (_, exit) => (
history.push(`/network-exits/${encodeURIComponent(exit.id)}`)}>{exit.id} @@ -243,27 +246,28 @@ export default function Page() { title: '操作', key: 'actions', align: 'right', + ...fixedRight({}), render: (_, exit) => ( - {exit.health_status === 'disabled' ? ( - ) : ( runAction(exit, 'disable')}> - )} - deleteExit(exit)}> - @@ -289,7 +293,7 @@ export default function Page() { style={{ marginBottom: 16 }} title={`环境不可用${bindingsError.status ? `(${bindingsError.status})` : ''}:无法读取账号绑定,状态暂时未知。`} action={ - } @@ -300,7 +304,9 @@ export default function Page() { columns={columns} dataSource={exits} loading={pending} - pagination={false} + sticky + pagination={tablePagination()} + scroll={tableScroll(horizontal ? 1100 : undefined, vertical ? 'calc(100vh - 300px)' : undefined)} locale={{ emptyText: '暂无网络出口。创建并检测健康后,才能绑定运行环境。' }} /> = { queued: '已排队', @@ -55,6 +56,7 @@ export default function Page() { const [accountsPending, setAccountsPending] = useState(false); const [pending, setPending] = useState(true); const [error, setError] = useState(null); + const { vertical, horizontal } = useOverflowGrid(rows.length); const load = useCallback(async () => { setPending(true); @@ -96,6 +98,7 @@ export default function Page() { { title: '任务', dataIndex: 'id', + ...fixedLeft({}), render: (value: string) => ( history.push(`/tasks/${value}`)}>{value} @@ -129,8 +132,9 @@ export default function Page() { title: '操作', key: 'actions', align: 'right', + ...fixedRight({}), render: (_, task) => ( - ), @@ -171,7 +175,9 @@ export default function Page() { columns={columns} dataSource={rows} loading={pending} - pagination={false} + sticky + pagination={tablePagination()} + scroll={tableScroll(horizontal ? 960 : undefined, vertical ? 'calc(100vh - 300px)' : undefined)} locale={{ emptyText: accountFilter || stateFilter ? '没有符合筛选条件的任务' : '尚无任务' }} />
diff --git a/web/src/utils/table.ts b/web/src/utils/table.ts new file mode 100644 index 0000000..aa75ea0 --- /dev/null +++ b/web/src/utils/table.ts @@ -0,0 +1,41 @@ +// 列表页表格的统一形态:固定表头(表内滚动)+ 固定首列/操作列 + 横向滚动 + 分页。 +// 仅用 antd/rc-table 原生能力,不自定义滚动组件: +// - scroll.y(表内滚动,固定表头)+ sticky(页面余量滚动时表头吸顶兜底); +// antd 源码在 fixHeader 时固定 overflowY: 'scroll'(滚动条常驻,官方未提供按需开关), +// 因此仅在「数据行超过一屏」时才开启 scroll.y,用 overflowGrid 判断: +// 纵向 ≤ md(阈值 768,Grid.useBreakpoint 官方响应式 hook)→ 小屏数据必超一屏 → scroll.y + 首列固定; +// 横向 ≥ xl(阈值 1280,布局含侧栏后仍够宽)→ 无挤压,不加 scroll.x/fixed 列/scroll.y; +// 其余中等宽度 → scroll.x + fixed 首列/操作列,不设 scroll.y(无固定表头时滚动条自然按需出现)。 +// - scroll.x + 固定首列(fixed: 'left')与操作列(fixed: 'right')防挤压折叠。 +// - pageSize 20 + hideOnSinglePage:数据多时翻页,单页时隐藏分页条保持原视觉。 +import { Grid } from 'antd'; +import type { ColumnsType, ColumnType } from 'antd/es/table'; + +export const TABLE_PAGE_SIZE = 20; + +// antd Table 内容超出视口高度时,浏览器才需要纵向滚动; +// 这里用「数据行数 × 行高」与视口比较,决定是否需要表内滚动(固定表头)。 +export function useOverflowGrid(rowCount: number) { + const screens = Grid.useBreakpoint(); + const vertical = rowCount * 48 + 120 >= window.innerHeight - 300 || !screens.md; + const horizontal = !screens.xl; + return { vertical, horizontal }; +} + +export function tableScroll(x: number | undefined, y: string | undefined) { + return { x, y }; +} + +export function tablePagination() { + return { pageSize: TABLE_PAGE_SIZE, hideOnSinglePage: true }; +} + +export function fixedLeft(column: ColumnType): ColumnType { + return { ...column, fixed: 'left' as const }; +} + +export function fixedRight(column: ColumnType): ColumnType { + return { ...column, fixed: 'right' as const }; +} + +export type { ColumnsType }; diff --git a/web/tests/table-fixed-header.test.cjs b/web/tests/table-fixed-header.test.cjs new file mode 100644 index 0000000..aaabf74 --- /dev/null +++ b/web/tests/table-fixed-header.test.cjs @@ -0,0 +1,35 @@ +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const { test } = require('node:test'); +const ts = require('typescript'); + +// 表格统一形态:固定表头(scroll.y)+ sticky 兜底 + 横向滚动 + 操作列固定右侧 + 分页。 +const files = [ + 'src/components/accounts/MonitoringAccountList.tsx', + 'src/components/accounts/AccountManagementList.tsx', + 'src/pages/accounts/import.tsx', + 'src/pages/tasks/index.tsx', + 'src/pages/browsers/index.tsx', + 'src/pages/network-exits/index.tsx', + 'src/pages/gateways/index.tsx', +]; + +for (const file of files) { + const source = ts.createSourceFile( + file, + readFileSync(`${__dirname}/../${file}`, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + const code = source.getFullText(); + + test(`${file} 表格使用统一固定表头与分页形态`, () => { + assert.match(code, /sticky/, '应开启表头吸顶兜底'); + assert.match(code, /pagination=\{tablePagination\(\)\}/, '应恢复分页(每页 20,单页隐藏)'); + assert.doesNotMatch(code, /pagination=\{false\}/, '不应再保留 pagination={false}'); + assert.match(code, /\.\.\.fixedLeft