diff --git a/web/src/components/accounts/AccountManagementList.tsx b/web/src/components/accounts/AccountManagementList.tsx index cd8d33b..6b3b9f5 100644 --- a/web/src/components/accounts/AccountManagementList.tsx +++ b/web/src/components/accounts/AccountManagementList.tsx @@ -10,7 +10,7 @@ import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { remove, creatorUpdate, creatorGet, creatorAction } from '@/services/api'; import { accountReadiness, conflictMessage, dateTime } from '@/utils/helpers'; -import { fixedLeft, fixedRight, tablePagination } from '@/utils/table'; +import { fixedLeft, fixedRight, tablePagination, useTableSticky } from '@/utils/table'; import AccountLoginModal from '@/components/accounts/AccountLoginModal'; import AccountCreateModal from '@/components/accounts/AccountCreateModal'; @@ -70,6 +70,7 @@ export default function AccountManagementList() { const [actionBusy, setActionBusy] = useState(''); const [loginTarget, setLoginTarget] = useState(null); const { message: messageApi } = App.useApp(); + const sticky = useTableSticky(); const load = useCallback(async () => { setPending(true); @@ -333,7 +334,7 @@ export default function AccountManagementList() { columns={columns} dataSource={rows} loading={pending} - sticky + sticky={sticky} pagination={tablePagination()} scroll={{ x: 'max-content' }} locale={{ emptyText: '暂无账号或待登录环境。' }} diff --git a/web/src/components/accounts/MonitoringAccountList.tsx b/web/src/components/accounts/MonitoringAccountList.tsx index 6c1cc56..5021474 100644 --- a/web/src/components/accounts/MonitoringAccountList.tsx +++ b/web/src/components/accounts/MonitoringAccountList.tsx @@ -6,7 +6,7 @@ import type { ColumnsType } from 'antd/es/table'; import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons'; import { getList, remove, creatorAction, creatorUpdate } from '@/services/api'; import { conflictMessage, dateTime } from '@/utils/helpers'; -import { fixedLeft, fixedRight, tablePagination } from '@/utils/table'; +import { fixedLeft, fixedRight, tablePagination, useTableSticky } from '@/utils/table'; interface CompetitorRow { id: string; @@ -46,6 +46,7 @@ export default function MonitoringAccountList() { const [actionBusy, setActionBusy] = useState(''); const { message: messageApi } = App.useApp(); const horizontal = !Grid.useBreakpoint().xl; + const sticky = useTableSticky(); const load = useCallback(async () => { setPending(true); @@ -227,7 +228,7 @@ export default function MonitoringAccountList() { columns={columns} dataSource={rows} loading={pending} - sticky + sticky={sticky} pagination={tablePagination()} locale={{ emptyText: '暂无监控账号。' }} scroll={{ x: horizontal ? 1100 : undefined }} diff --git a/web/src/pages/accounts/import.tsx b/web/src/pages/accounts/import.tsx index 83456f7..906eb63 100644 --- a/web/src/pages/accounts/import.tsx +++ b/web/src/pages/accounts/import.tsx @@ -5,7 +5,7 @@ import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { create, getList, creatorAction } from '@/services/api'; import { conflictMessage, dateTime, extractShareURL, platformForShareURL, platformLabel, shareJobStatus } from '@/utils/helpers'; -import { fixedLeft, fixedRight, tablePagination } from '@/utils/table'; +import { fixedLeft, fixedRight, tablePagination, useTableSticky } from '@/utils/table'; interface Job { id: string; @@ -35,6 +35,7 @@ export default function Page() { const [retryID, setRetryID] = useState(null); const { message: messageApi } = App.useApp(); const horizontal = !Grid.useBreakpoint().xl; + const sticky = useTableSticky(); const loadShareJobs = useCallback(async () => { setPending(true); @@ -188,7 +189,7 @@ export default function Page() { {error ? ( 重试} /> ) : null} - +
diff --git a/web/src/pages/gateways/index.tsx b/web/src/pages/gateways/index.tsx index 385ed98..096a64d 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, dateTime } from '@/utils/helpers'; -import { fixedLeft, fixedRight, tablePagination } from '@/utils/table'; +import { fixedLeft, fixedRight, tablePagination, useTableSticky } 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}$/; @@ -96,6 +96,7 @@ export default function Page() { const [created, setCreated] = useState(null); const { message: messageApi } = App.useApp(); const horizontal = !Grid.useBreakpoint().xl; + const sticky = useTableSticky(); const healthReloadTimer = useRef>(undefined); const load = useCallback(async () => { @@ -234,7 +235,7 @@ export default function Page() { {error ? ( 重试} /> ) : null} -
+
= { unchecked: '未检测', @@ -114,6 +114,7 @@ export default function Page() { const [busy, setBusy] = useState(''); const { message: messageApi } = App.useApp(); const horizontal = !Grid.useBreakpoint().xl; + const sticky = useTableSticky(); const load = useCallback(async () => { setPending(true); @@ -304,7 +305,7 @@ export default function Page() { columns={columns} dataSource={exits} loading={pending} - sticky + sticky={sticky} pagination={tablePagination()} scroll={{ x: horizontal ? 1100 : undefined }} locale={{ emptyText: '暂无网络出口。创建并检测健康后,才能绑定运行环境。' }} diff --git a/web/src/utils/table.ts b/web/src/utils/table.ts index 1f401f1..1a8be3c 100644 --- a/web/src/utils/table.ts +++ b/web/src/utils/table.ts @@ -1,9 +1,19 @@ // 列表表格使用页面纵向滚动与 sticky 表头吸顶,不设置表内纵向滚动。 // 保留横向滚动、固定首列/操作列与分页(每页 20 条,单页隐藏分页条)。 +import { ProProvider, RouteContext } from '@ant-design/pro-components'; import type { ColumnsType, ColumnType } from 'antd/es/table'; +import { useContext } from 'react'; export const TABLE_PAGE_SIZE = 20; +export function useTableSticky() { + const { hasHeader, fixedHeader, layout } = useContext(RouteContext); + const { token } = useContext(ProProvider); + // ProLayout 的 mix 布局默认固定顶部栏;高度与其原生 token/default 保持一致。 + const headerHeight = token.layout?.header?.heightLayoutHeader || 56; + return { offsetHeader: hasHeader && (fixedHeader || layout === 'mix') ? headerHeight : 0 }; +} + export function tablePagination() { return { pageSize: TABLE_PAGE_SIZE, hideOnSinglePage: true }; } diff --git a/web/tests/account-management-unified-list.test.cjs b/web/tests/account-management-unified-list.test.cjs index af16434..5310a76 100644 --- a/web/tests/account-management-unified-list.test.cjs +++ b/web/tests/account-management-unified-list.test.cjs @@ -34,7 +34,7 @@ function setup({ accounts = [{ id: 'account-1', name: '真实昵称', business_s async remove(resource, id) { calls.push({ resource, id }); if (options.actionError) throw options.actionError; }, }; if (id === '@/utils/helpers') return { accountReadiness: () => ({ ready: true, label: '可运行' }), dateTime: value => value, conflictMessage: (error, fallback) => error.message || fallback }; - if (id === '@/utils/table') return { fixedLeft: column => ({ ...column, fixed: 'left' }), fixedRight: column => ({ ...column, fixed: 'right' }), tablePagination: () => ({ pageSize: 20, hideOnSinglePage: true }) }; + if (id === '@/utils/table') return { fixedLeft: column => ({ ...column, fixed: 'left' }), fixedRight: column => ({ ...column, fixed: 'right' }), tablePagination: () => ({ pageSize: 20, hideOnSinglePage: true }), useTableSticky: () => ({ offsetHeader: 56 }) }; if (id.startsWith('@/components/accounts/')) return { default: marker(id.split('/').at(-1)) }; return requireWeb(id); }; @@ -72,7 +72,7 @@ for (const rowCount of [1, 20, 100]) { const s = await ready({ accounts: Array.from({ length: rowCount }, (_, i) => ({ id: `account-${i}`, name: `账号 ${i}` })), environments: [] }); const { columns, scroll, dataSource, sticky, pagination } = s.find('Table').props; assert.equal(dataSource.length, rowCount); - assert.equal(sticky, true); + assert.deepEqual(sticky, { offsetHeader: 56 }); assert.deepEqual(pagination, { pageSize: 20, hideOnSinglePage: true }); assert.equal(columns[0].title, '账号'); assert.equal(columns[0].fixed, 'left'); diff --git a/web/tests/table-fixed-header.test.cjs b/web/tests/table-fixed-header.test.cjs index 9c839e5..ce55e1f 100644 --- a/web/tests/table-fixed-header.test.cjs +++ b/web/tests/table-fixed-header.test.cjs @@ -32,7 +32,10 @@ for (const [file, width] of files) { test(`${file} 保留表头吸顶、分页和固定列`, () => { assert.equal(tables.length, 1); const props = tables[0].attributes.properties; - assert.ok(props.some(prop => prop.name?.getText(source) === 'sticky')); + const sticky = props.find(prop => prop.name?.getText(source) === 'sticky'); + assert.ok(sticky?.initializer && ts.isJsxExpression(sticky.initializer), '表头吸顶必须预留顶部栏高度'); + assert.equal(sticky.initializer.expression.getText(source), 'sticky'); + assert.match(code, /const sticky = useTableSticky\(\);/); assert.match(code, /pagination=\{tablePagination\(\)\}/); assert.doesNotMatch(code, /pagination=\{false\}/); assert.match(code, /\.\.\.fixedLeft { + if (id === '@ant-design/pro-components') return { RouteContext: routeContext, ProProvider: proProvider }; + if (id === 'react') return { useContext: context => context === routeContext ? layout : { token } }; + throw new Error(`Unexpected import: ${id}`); +} }, { filename: tableFile }); const helpers = tableModule.exports; +test('mix 布局默认固定顶部栏,表头须避让;自定义高度随布局更新', () => { + for (const height of [56, 72, 96]) { + for (const isMobile of [false, true]) { + layout = { hasHeader: true, layout: 'mix', fixedHeader: false, isMobile }; + token = { layout: { header: { heightLayoutHeader: height } } }; + assert.equal(helpers.useTableSticky().offsetHeader, height); + } + } +}); + +test('遵循 ProLayout 默认顶部栏高度', () => { + layout = { hasHeader: true, layout: 'mix' }; + token = {}; + assert.equal(helpers.useTableSticky().offsetHeader, 56); +}); + +test('其他布局只在存在固定顶部栏时预留高度', () => { + token = { layout: { header: { heightLayoutHeader: 72 } } }; + for (const kind of ['top', 'side']) { + for (const hasHeader of [false, true]) { + for (const fixedHeader of [false, true]) { + layout = { layout: kind, hasHeader, fixedHeader }; + assert.equal(helpers.useTableSticky().offsetHeader, hasHeader && fixedHeader ? 72 : 0); + } + } + } + layout = { layout: 'mix', hasHeader: false, fixedHeader: true }; + assert.equal(helpers.useTableSticky().offsetHeader, 0); +}); + test('分页仍为每页 20 条,单页隐藏分页条', () => { assert.equal(helpers.TABLE_PAGE_SIZE, 20); const pagination = helpers.tablePagination();