From 3d668769f604e9fa90460972d9603b6bdcecbb77 Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 6 Oct 2026 14:15:34 +0800 Subject: [PATCH] fix(web): use page scrolling for all tables --- .../accounts/AccountManagementList.tsx | 5 +- .../accounts/MonitoringAccountList.tsx | 8 +- web/src/pages/accounts/import.tsx | 8 +- web/src/pages/gateways/index.tsx | 8 +- web/src/pages/network-exits/index.tsx | 8 +- web/src/utils/table.ts | 26 +----- .../account-management-unified-list.test.cjs | 41 ++++----- web/tests/table-fixed-header.test.cjs | 90 ++++++++++++++++--- 8 files changed, 117 insertions(+), 77 deletions(-) diff --git a/web/src/components/accounts/AccountManagementList.tsx b/web/src/components/accounts/AccountManagementList.tsx index 4b180a6..cd8d33b 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, useOverflowGrid } from '@/utils/table'; +import { fixedLeft, fixedRight, tablePagination } from '@/utils/table'; import AccountLoginModal from '@/components/accounts/AccountLoginModal'; import AccountCreateModal from '@/components/accounts/AccountCreateModal'; @@ -70,7 +70,6 @@ export default function AccountManagementList() { const [actionBusy, setActionBusy] = useState(''); const [loginTarget, setLoginTarget] = useState(null); const { message: messageApi } = App.useApp(); - const { vertical } = useOverflowGrid(rows.length); const load = useCallback(async () => { setPending(true); @@ -336,7 +335,7 @@ export default function AccountManagementList() { loading={pending} sticky pagination={tablePagination()} - scroll={{ x: 'max-content', y: vertical ? 'calc(100vh - 300px)' : undefined }} + scroll={{ x: 'max-content' }} locale={{ emptyText: '暂无账号或待登录环境。' }} /> { setPending(true); @@ -230,7 +230,7 @@ export default function MonitoringAccountList() { sticky pagination={tablePagination()} locale={{ emptyText: '暂无监控账号。' }} - scroll={tableScroll(horizontal ? 1100 : undefined, vertical ? 'calc(100vh - 300px)' : undefined)} + scroll={{ x: horizontal ? 1100 : undefined }} /> (null); const { message: messageApi } = App.useApp(); - const { vertical, horizontal } = useOverflowGrid(jobs.length); + const horizontal = !Grid.useBreakpoint().xl; const loadShareJobs = useCallback(async () => { setPending(true); @@ -188,7 +188,7 @@ export default function Page() { {error ? ( 重试} /> ) : null} - +
diff --git a/web/src/pages/gateways/index.tsx b/web/src/pages/gateways/index.tsx index 6b608bd..385ed98 100644 --- a/web/src/pages/gateways/index.tsx +++ b/web/src/pages/gateways/index.tsx @@ -1,11 +1,11 @@ // 网关管理:语义对齐 web.archived GatewaysPage.jsx(注册/编辑 Modal、令牌显隐复制、删除)。 import { useCallback, useEffect, useRef, useState } from 'react'; -import { Alert, App, Button, Card, Flex, Form, Input, Modal, Popconfirm, Space, Table, Tag, Tooltip, Typography } from 'antd'; +import { Alert, App, Button, Card, Flex, Form, Grid, Input, Modal, Popconfirm, Space, Table, Tag, Tooltip, Typography } from 'antd'; import { CopyOutlined, EyeInvisibleOutlined, EyeOutlined, PlusOutlined } from '@ant-design/icons'; 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, tableScroll, useOverflowGrid } from '@/utils/table'; +import { fixedLeft, fixedRight, tablePagination } 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}$/; @@ -95,7 +95,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 horizontal = !Grid.useBreakpoint().xl; const healthReloadTimer = useRef>(undefined); const load = useCallback(async () => { @@ -234,7 +234,7 @@ export default function Page() { {error ? ( 重试} /> ) : null} -
+
= { unchecked: '未检测', @@ -113,7 +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 horizontal = !Grid.useBreakpoint().xl; const load = useCallback(async () => { setPending(true); @@ -306,7 +306,7 @@ export default function Page() { loading={pending} sticky pagination={tablePagination()} - scroll={tableScroll(horizontal ? 1100 : undefined, vertical ? 'calc(100vh - 300px)' : undefined)} + scroll={{ x: horizontal ? 1100 : undefined }} locale={{ emptyText: '暂无网络出口。创建并检测健康后,才能绑定运行环境。' }} /> = 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 }; } diff --git a/web/tests/account-management-unified-list.test.cjs b/web/tests/account-management-unified-list.test.cjs index 5cedceb..af16434 100644 --- a/web/tests/account-management-unified-list.test.cjs +++ b/web/tests/account-management-unified-list.test.cjs @@ -7,7 +7,7 @@ const ts = require('typescript'); const requireWeb = createRequire(resolve(__dirname, '../package.json')); const file = resolve(__dirname, '../src/components/accounts/AccountManagementList.tsx'); -function setup({ accounts = [{ id: 'account-1', name: '真实昵称', business_status: 'normal', tags: ['主账号'] }], environments = [{ alias: 'env-1', gateway: 'gw-a' }], loadError, actionError, grid = { vertical: true, horizontal: true } } = {}) { +function setup({ accounts = [{ id: 'account-1', name: '真实昵称', business_status: 'normal', tags: ['主账号'] }], environments = [{ alias: 'env-1', gateway: 'gw-a' }], loadError, actionError } = {}) { const states = [], calls = [], messages = [], paths = []; let cursor = 0, started = false; const marker = displayName => Object.assign(() => null, { displayName }); @@ -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: () => ({}), tableScroll: (x, y) => ({ x, y }), useOverflowGrid: () => grid }; + if (id === '@/utils/table') return { fixedLeft: column => ({ ...column, fixed: 'left' }), fixedRight: column => ({ ...column, fixed: 'right' }), tablePagination: () => ({ pageSize: 20, hideOnSinglePage: true }) }; if (id.startsWith('@/components/accounts/')) return { default: marker(id.split('/').at(-1)) }; return requireWeb(id); }; @@ -67,24 +67,25 @@ test('待登录环境与已登录账号共用唯一列表、计数及分页', as assert.notEqual(rows[0].id, rows[1].id); }); -for (const horizontal of [false, true]) { - for (const vertical of [false, true]) { - test(`账号与操作列不挤压,其他列横向滚动(横向溢出 ${horizontal},纵向溢出 ${vertical})`, async () => { - const s = await ready({ grid: { horizontal, vertical } }); - const { columns, scroll, dataSource } = s.find('Table').props; - assert.equal(columns[0].title, '账号'); - assert.equal(columns[0].fixed, 'left'); - assert.equal(columns[0].width, 240); - assert.equal(columns.at(-1).title, '操作'); - assert.equal(columns.at(-1).fixed, 'right'); - assert.equal(columns.at(-1).width, 300); - assert.ok(columns.slice(1, -1).every(column => !column.fixed)); - assert.equal(scroll.x, 'max-content', '宽屏也应按内容宽度滚动,而不是挤压两侧列'); - assert.equal(scroll.y, vertical ? 'calc(100vh - 300px)' : undefined); - const row = dataSource.find(row => !row.pending_environment); - assert.notEqual(s.find('Space', s.cell('操作', row)).props.wrap, true, '操作按钮不得换行折叠'); - }); - } +for (const rowCount of [1, 20, 100]) { + test(`账号与操作列不挤压,${rowCount} 条数据使用页面纵向滚动`, async () => { + 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(pagination, { pageSize: 20, hideOnSinglePage: true }); + assert.equal(columns[0].title, '账号'); + assert.equal(columns[0].fixed, 'left'); + assert.equal(columns[0].width, 240); + assert.equal(columns.at(-1).title, '操作'); + assert.equal(columns.at(-1).fixed, 'right'); + assert.equal(columns.at(-1).width, 300); + assert.ok(columns.slice(1, -1).every(column => !column.fixed)); + assert.equal(scroll.x, 'max-content', '宽屏也应按内容宽度滚动,而不是挤压两侧列'); + assert.equal(Object.hasOwn(scroll, 'y'), false, '不应启用表格内部纵向滚动'); + const row = dataSource.find(row => !row.pending_environment); + assert.notEqual(s.find('Space', s.cell('操作', row)).props.wrap, true, '操作按钮不得换行折叠'); + }); } test('按共用环境数据库ID倒序排列,新建环境与账号在前,登录绑定不改变位置', async () => { diff --git a/web/tests/table-fixed-header.test.cjs b/web/tests/table-fixed-header.test.cjs index 41500d9..9c839e5 100644 --- a/web/tests/table-fixed-header.test.cjs +++ b/web/tests/table-fixed-header.test.cjs @@ -1,18 +1,19 @@ const assert = require('node:assert/strict'); const { readFileSync } = require('node:fs'); const { test } = require('node:test'); +const vm = require('node:vm'); const ts = require('typescript'); -// 表格统一形态:固定表头(scroll.y)+ sticky 兜底 + 横向滚动 + 操作列固定右侧 + 分页。 +// 页面纵向滚动 + sticky 表头吸顶 + 横向滚动 + 固定列 + 分页。 const files = [ - 'src/components/accounts/MonitoringAccountList.tsx', - 'src/components/accounts/AccountManagementList.tsx', - 'src/pages/accounts/import.tsx', - 'src/pages/network-exits/index.tsx', - 'src/pages/gateways/index.tsx', + ['src/components/accounts/MonitoringAccountList.tsx', 1100], + ['src/components/accounts/AccountManagementList.tsx', 'max-content'], + ['src/pages/accounts/import.tsx', 1000], + ['src/pages/network-exits/index.tsx', 1100], + ['src/pages/gateways/index.tsx', 960], ]; -for (const file of files) { +for (const [file, width] of files) { const source = ts.createSourceFile( file, readFileSync(`${__dirname}/../${file}`, 'utf8'), @@ -21,13 +22,74 @@ for (const file of files) { ts.ScriptKind.TSX, ); const code = source.getFullText(); + const tables = []; + function visit(node) { + if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && node.tagName.getText(source) === 'Table') tables.push(node); + ts.forEachChild(node, visit); + } + visit(source); - test(`${file} 表格使用统一固定表头与分页形态`, () => { - assert.match(code, /sticky/, '应开启表头吸顶兜底'); - assert.match(code, /pagination=\{tablePagination\(\)\}/, '应恢复分页(每页 20,单页隐藏)'); - assert.doesNotMatch(code, /pagination=\{false\}/, '不应再保留 pagination={false}'); - assert.match(code, /\.\.\.fixedLeft { + assert.equal(tables.length, 1); + const props = tables[0].attributes.properties; + assert.ok(props.some(prop => prop.name?.getText(source) === 'sticky')); + assert.match(code, /pagination=\{tablePagination\(\)\}/); + assert.doesNotMatch(code, /pagination=\{false\}/); + assert.match(code, /\.\.\.fixedLeft { + const scroll = tables[0].attributes.properties.find(prop => prop.name?.getText(source) === 'scroll'); + assert.ok(scroll?.initializer && ts.isJsxExpression(scroll.initializer)); + const expression = scroll.initializer.expression.getText(source); + for (const horizontal of [false, true]) { + for (const height of [480, 1080]) { + for (const rowCount of [0, 1, 20, 100]) { + const result = vm.runInNewContext(`(${expression})`, { + horizontal, + vertical: rowCount * 48 + 120 >= height - 300 || horizontal, + tableScroll: (x, y) => ({ x, y }), + }); + assert.equal(Object.hasOwn(result, 'y'), false, `不应配置 scroll.y(行数 ${rowCount},高度 ${height})`); + assert.equal(result.x, width === 'max-content' ? width : horizontal ? width : undefined); + } + } + } + assert.doesNotMatch(code, /window\.innerHeight|useOverflowGrid|tableScroll/, '不再保留按高度和行数启用表内滚动的旧路径'); + if (width !== 'max-content') assert.match(code, /!Grid\.useBreakpoint\(\)\.xl/, '横向断点保持 xl'); }); } + +const tableFile = `${__dirname}/../src/utils/table.ts`; +const tableCode = ts.transpileModule(readFileSync(tableFile, 'utf8'), { + fileName: tableFile, + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, +}).outputText; +const tableModule = { exports: {} }; +vm.runInNewContext(tableCode, { exports: tableModule.exports }, { filename: tableFile }); +const helpers = tableModule.exports; + +test('分页仍为每页 20 条,单页隐藏分页条', () => { + assert.equal(helpers.TABLE_PAGE_SIZE, 20); + const pagination = helpers.tablePagination(); + assert.equal(pagination.pageSize, 20); + assert.equal(pagination.hideOnSinglePage, true); +}); + +test('固定列保留原配置且不修改输入', () => { + const column = { title: '账号', width: 240 }; + for (const [helper, side] of [[helpers.fixedLeft, 'left'], [helpers.fixedRight, 'right']]) { + const fixed = helper(column); + assert.equal(fixed.fixed, side); + assert.equal(fixed.title, column.title); + assert.equal(fixed.width, column.width); + assert.notEqual(fixed, column); + assert.equal(Object.hasOwn(column, 'fixed'), false); + } +}); + +test('移除纵向滚动判断与无必要的滚动包装函数', () => { + assert.equal(helpers.useOverflowGrid, undefined); + assert.equal(helpers.tableScroll, undefined); +});