fix(web): unify list tables with fixed header, fixed actions and pagination

This commit is contained in:
2026-09-28 15:20:55 +08:00
parent 630a4feab1
commit 7ed542df4f
9 changed files with 90 additions and 9 deletions
@@ -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 { fixedRight, tablePagination, tableScroll } from '@/utils/table';
// 仅用 antd 默认组件:Table/Tag/Modal/Popconfirm/Select(tags)。
interface Row {
@@ -113,7 +114,7 @@ export default function AccountManagementList() {
{
title: '标签',
dataIndex: 'tags',
render: (tags?: string[]) => (tags?.length ? tags.map((tag) => <Tag key={tag}>{tag}</Tag>) : <Typography.Text type="secondary">未设置</Typography.Text>) },
render: (tags?: string[]) => (tags?.length ? <Flex wrap="wrap" gap={4}>{tags.map((tag) => <Tag key={tag}>{tag}</Tag>)}</Flex> : <Typography.Text type="secondary">未设置</Typography.Text>) },
{
title: '状态',
dataIndex: 'status',
@@ -129,6 +130,7 @@ export default function AccountManagementList() {
title: '操作',
key: 'actions',
align: 'right',
...fixedRight<Row>({}),
render: (_, account) => (
<Space wrap>
<Button type="text" size="small" onClick={() => openTags(account)}>
@@ -169,7 +171,9 @@ export default function AccountManagementList() {
columns={columns}
dataSource={rows}
loading={pending}
pagination={false}
sticky
pagination={tablePagination()}
scroll={tableScroll(960)}
locale={{ emptyText: '暂无自有账号。' }}
/>
<Modal
@@ -6,6 +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 { fixedRight, tablePagination, tableScroll } from '@/utils/table';
interface CompetitorRow {
id: string;
@@ -180,6 +181,7 @@ export default function MonitoringAccountList() {
title: '操作',
key: 'actions',
align: 'right',
...fixedRight<CompetitorRow>({}),
render: (_, account) => (
<Space wrap>
<Button type="text" size="small" onClick={() => openTags(account)}>编辑标签</Button>
@@ -218,9 +220,10 @@ export default function MonitoringAccountList() {
columns={columns}
dataSource={rows}
loading={pending}
pagination={false}
sticky
pagination={tablePagination()}
locale={{ emptyText: '暂无监控账号。' }}
scroll={{ x: 1100 }}
scroll={tableScroll(1100)}
/>
<Modal
open={Boolean(tagTarget)}
+3 -1
View File
@@ -5,6 +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 { fixedRight, tablePagination, tableScroll } from '@/utils/table';
interface Job {
id: string;
@@ -158,6 +159,7 @@ export default function Page() {
title: '操作',
key: 'actions',
align: 'right',
...fixedRight<Job>({}),
render: (_, job) =>
job.status === 'failed' ? (
<Button type="text" size="small" disabled={retryID === job.id} onClick={() => retryShareJob(job.id)}>
@@ -182,7 +184,7 @@ export default function Page() {
{error ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={loadShareJobs}>重试</Button>} />
) : null}
<Table rowKey="id" columns={columns} dataSource={jobs} loading={pending} pagination={false} locale={{ emptyText: '暂无分享链接解析任务。' }} />
<Table rowKey="id" columns={columns} dataSource={jobs} loading={pending} sticky pagination={tablePagination()} scroll={tableScroll(1000)} locale={{ emptyText: '暂无分享链接解析任务。' }} />
<Modal open={createOpen} title="创建账号导入任务" onCancel={closeCreate} onOk={createJob} confirmLoading={busy} okText={busy ? '加入中…' : '加入解析队列'} width={560}>
<Form form={form} layout="vertical">
<Form.Item name="shareText" label="分享内容或链接" required extra="可直接粘贴平台分享文案,系统会自动提取其中的链接并识别作者。">
+5 -1
View File
@@ -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 { fixedRight, tablePagination, tableScroll } from '@/utils/table';
const stateText: Record<string, string> = {
created: '已创建',
@@ -124,6 +125,7 @@ export default function Page() {
title: '操作',
key: 'actions',
align: 'right',
...fixedRight<any>({}),
render: (_, runtime) => {
const running = runtime.state === 'running';
const stopping = ['running', 'degraded', 'starting', 'failed', 'cleanup_pending'].includes(runtime.state);
@@ -169,7 +171,9 @@ export default function Page() {
columns={columns}
dataSource={runtimes}
loading={pending}
pagination={false}
sticky
pagination={tablePagination()}
scroll={tableScroll(1000)}
locale={{ emptyText: '暂无运行环境,创建第一个隔离 Profile。' }}
/>
</Card>
+3 -1
View File
@@ -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 { fixedRight, tablePagination, tableScroll } 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}$/;
@@ -152,6 +153,7 @@ export default function Page() {
title: '操作',
key: 'actions',
align: 'right',
...fixedRight<any>({}),
render: (_, gateway) => (
<Space>
<Button type="text" size="small" disabled={busy === gateway.name || busy === 'edit'} onClick={() => setEditing(gateway)}>
@@ -200,7 +202,7 @@ export default function Page() {
{error ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
) : null}
<Table rowKey="name" columns={columns} dataSource={gateways} loading={pending} pagination={false} locale={{ emptyText: '暂无网关;注册后环境即可调度到该网关。' }} />
<Table rowKey="name" columns={columns} dataSource={gateways} loading={pending} sticky pagination={tablePagination()} scroll={tableScroll(960)} locale={{ emptyText: '暂无网关;注册后环境即可调度到该网关。' }} />
<GatewayFormModal
open={createOpen}
initial={null}
+5 -1
View File
@@ -6,6 +6,7 @@ import { PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { create, getOne, getList, networkExitAction, remove, update } from '@/services/api';
import { conflictMessage, dateTime } from '@/utils/helpers';
import { fixedRight, tablePagination, tableScroll } from '@/utils/table';
const healthText: Record<string, string> = {
unchecked: '未检测',
@@ -243,6 +244,7 @@ export default function Page() {
title: '操作',
key: 'actions',
align: 'right',
...fixedRight<any>({}),
render: (_, exit) => (
<Space wrap style={{ justifyContent: 'flex-end' }}>
<Button type="text" size="small" disabled={busy === exit.id} onClick={() => runAction(exit, 'check')}>
@@ -300,7 +302,9 @@ export default function Page() {
columns={columns}
dataSource={exits}
loading={pending}
pagination={false}
sticky
pagination={tablePagination()}
scroll={tableScroll(1100)}
locale={{ emptyText: '暂无网络出口。创建并检测健康后,才能绑定运行环境。' }}
/>
<ExitFormModal
+5 -1
View File
@@ -5,6 +5,7 @@ import { Alert, Button, Card, Flex, Select, Space, Table, Tag, Typography } from
import type { ColumnsType } from 'antd/es/table';
import { getList } from '@/services/api';
import { dateTime } from '@/utils/helpers';
import { fixedRight, tablePagination, tableScroll } from '@/utils/table';
const stateText: Record<string, string> = {
queued: '已排队',
@@ -129,6 +130,7 @@ export default function Page() {
title: '操作',
key: 'actions',
align: 'right',
...fixedRight<any>({}),
render: (_, task) => (
<Button type="text" size="small" onClick={() => history.push(`/tasks/${task.id}`)}>
查看
@@ -171,7 +173,9 @@ export default function Page() {
columns={columns}
dataSource={rows}
loading={pending}
pagination={false}
sticky
pagination={tablePagination()}
scroll={tableScroll(960)}
locale={{ emptyText: accountFilter || stateFilter ? '没有符合筛选条件的任务' : '尚无任务' }} />
</Card>
</div>
+24
View File
@@ -0,0 +1,24 @@
// 列表页表格的统一形态:固定表头(表内滚动)+ 横向滚动防挤压 + 分页。
// - scroll.y 用 calc(100vh - offset) 让表格高度贴合一屏,表头表尾常驻,数据在表内滚动;
// offset 是页头(菜单行+PageContainer 标题/页头槽+卡片边框)与表格头行之外的高度估算值,
// 估不准只会留下少量页面滚动,配合 sticky 兜底表头仍可见。
// - sticky:当 offset 估算偏差或行高变化导致页面仍有滚动时,表头吸顶兜底。
// - scroll.x + 操作列 fixed: 'right':窗口窄时横向滚动,列内容不再被压缩折叠。
// - pageSize 20 + hideOnSinglePage:数据多时翻页,单页时隐藏分页条保持原视觉。
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)` };
}
export function tablePagination() {
return { pageSize: TABLE_PAGE_SIZE, hideOnSinglePage: true };
}
export function fixedRight<RecordType>(column: ColumnType<RecordType>): ColumnType<RecordType> {
return { ...column, fixed: 'right' as const };
}
export type { ColumnsType };
+34
View File
@@ -0,0 +1,34 @@
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, /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, /\.\.\.fixedRight</, '操作列应固定在右侧');
});
}