From 0a988fb1b3c9d5bb67406eae9fe18d879b336092 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 28 Sep 2026 15:28:31 +0800 Subject: [PATCH] fix(web): conditional table scroll and fixed first column --- .../accounts/AccountManagementList.tsx | 6 ++-- .../accounts/MonitoringAccountList.tsx | 6 ++-- web/src/pages/accounts/import.tsx | 6 ++-- web/src/pages/browsers/index.tsx | 6 ++-- web/src/pages/gateways/index.tsx | 7 ++-- web/src/pages/network-exits/index.tsx | 6 ++-- web/src/pages/tasks/index.tsx | 6 ++-- web/src/utils/table.ts | 33 ++++++++++++++----- web/tests/table-fixed-header.test.cjs | 3 +- 9 files changed, 55 insertions(+), 24 deletions(-) diff --git a/web/src/components/accounts/AccountManagementList.tsx b/web/src/components/accounts/AccountManagementList.tsx index 6112cc6..69accf4 100644 --- a/web/src/components/accounts/AccountManagementList.tsx +++ b/web/src/components/accounts/AccountManagementList.tsx @@ -8,7 +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 { fixedRight, tablePagination, tableScroll } from '@/utils/table'; +import { fixedLeft, fixedRight, tablePagination, tableScroll, useOverflowGrid } from '@/utils/table'; // 仅用 antd 默认组件:Table/Tag/Modal/Popconfirm/Select(tags)。 interface Row { @@ -31,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); @@ -92,6 +93,7 @@ export default function AccountManagementList() { { title: '账号', dataIndex: 'name', + ...fixedLeft({}), render: (_, account) => (
history.push(`/accounts/${account.id}`)}>{account.name} @@ -173,7 +175,7 @@ export default function AccountManagementList() { loading={pending} sticky pagination={tablePagination()} - scroll={tableScroll(960)} + scroll={tableScroll(horizontal ? 960 : undefined, vertical ? 'calc(100vh - 300px)' : undefined)} locale={{ emptyText: '暂无自有账号。' }} /> { setPending(true); @@ -130,6 +131,7 @@ export default function MonitoringAccountList() { { title: '账号', dataIndex: 'nickname', + ...fixedLeft({}), render: (_, account) => ( {(account.nickname || '账').slice(0, 1).toUpperCase()} @@ -223,7 +225,7 @@ export default function MonitoringAccountList() { sticky pagination={tablePagination()} locale={{ emptyText: '暂无监控账号。' }} - scroll={tableScroll(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); @@ -107,6 +108,7 @@ export default function Page() { { title: '账号名称', dataIndex: 'author_name', + ...fixedLeft({}), render: (value?: string, job?: Job) => { if (!value) { return ( @@ -184,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 8b523e2..6585bf4 100644 --- a/web/src/pages/browsers/index.tsx +++ b/web/src/pages/browsers/index.tsx @@ -6,7 +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 { fixedRight, tablePagination, tableScroll } from '@/utils/table'; +import { fixedLeft, fixedRight, tablePagination, tableScroll, useOverflowGrid } from '@/utils/table'; const stateText: Record = { created: '已创建', @@ -37,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); @@ -75,6 +76,7 @@ export default function Page() { { title: '环境', dataIndex: 'name', + ...fixedLeft({}), render: (_, runtime) => (
history.push(`/browsers/${encodeURIComponent(runtime.alias)}`)}> {runtime.name} @@ -173,7 +175,7 @@ export default function Page() { loading={pending} sticky pagination={tablePagination()} - scroll={tableScroll(1000)} + 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 93bf7f9..7411619 100644 --- a/web/src/pages/gateways/index.tsx +++ b/web/src/pages/gateways/index.tsx @@ -5,7 +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 { fixedRight, tablePagination, tableScroll } from '@/utils/table'; +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}$/; @@ -80,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); @@ -143,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: '令牌', @@ -202,7 +203,7 @@ export default function Page() { {error ? ( 重试} /> ) : null} -
+
= { unchecked: '未检测', @@ -113,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); @@ -216,6 +217,7 @@ export default function Page() { { title: '出口', dataIndex: 'id', + ...fixedLeft({}), render: (_, exit) => (
history.push(`/network-exits/${encodeURIComponent(exit.id)}`)}>{exit.id} @@ -304,7 +306,7 @@ export default function Page() { loading={pending} sticky pagination={tablePagination()} - scroll={tableScroll(1100)} + scroll={tableScroll(horizontal ? 1100 : undefined, vertical ? 'calc(100vh - 300px)' : undefined)} locale={{ emptyText: '暂无网络出口。创建并检测健康后,才能绑定运行环境。' }} /> = { queued: '已排队', @@ -56,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); @@ -97,6 +98,7 @@ export default function Page() { { title: '任务', dataIndex: 'id', + ...fixedLeft({}), render: (value: string) => ( history.push(`/tasks/${value}`)}>{value} @@ -175,7 +177,7 @@ export default function Page() { loading={pending} sticky pagination={tablePagination()} - scroll={tableScroll(960)} + 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 index 1f0fdda..aa75ea0 100644 --- a/web/src/utils/table.ts +++ b/web/src/utils/table.ts @@ -1,22 +1,39 @@ -// 列表页表格的统一形态:固定表头(表内滚动)+ 横向滚动防挤压 + 分页。 -// - scroll.y 用 calc(100vh - offset) 让表格高度贴合一屏,表头表尾常驻,数据在表内滚动; -// offset 是页头(菜单行+PageContainer 标题/页头槽+卡片边框)与表格头行之外的高度估算值, -// 估不准只会留下少量页面滚动,配合 sticky 兜底表头仍可见。 -// - sticky:当 offset 估算偏差或行高变化导致页面仍有滚动时,表头吸顶兜底。 -// - scroll.x + 操作列 fixed: 'right':窗口窄时横向滚动,列内容不再被压缩折叠。 +// 列表页表格的统一形态:固定表头(表内滚动)+ 固定首列/操作列 + 横向滚动 + 分页。 +// 仅用 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; -export function tableScroll(x: number, offset = 300) { - return { x, y: `calc(100vh - ${offset}px)` }; +// 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 }; } diff --git a/web/tests/table-fixed-header.test.cjs b/web/tests/table-fixed-header.test.cjs index 2b55fcc..aaabf74 100644 --- a/web/tests/table-fixed-header.test.cjs +++ b/web/tests/table-fixed-header.test.cjs @@ -25,10 +25,11 @@ for (const file of files) { const code = source.getFullText(); test(`${file} 表格使用统一固定表头与分页形态`, () => { - assert.match(code, /tableScroll\(\d+\)/, '应设置 scroll.x/y(tableScroll)'); assert.match(code, /sticky/, '应开启表头吸顶兜底'); assert.match(code, /pagination=\{tablePagination\(\)\}/, '应恢复分页(每页 20,单页隐藏)'); assert.doesNotMatch(code, /pagination=\{false\}/, '不应再保留 pagination={false}'); + assert.match(code, /\.\.\.fixedLeft