fix(web): conditional table scroll and fixed first column

This commit is contained in:
2026-09-28 15:28:31 +08:00
parent 7ed542df4f
commit 0a988fb1b3
9 changed files with 55 additions and 24 deletions
@@ -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<Row>({}),
render: (_, account) => (
<div>
<Typography.Link onClick={() => history.push(`/accounts/${account.id}`)}>{account.name}</Typography.Link>
@@ -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: '暂无自有账号。' }}
/>
<Modal
@@ -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 { fixedRight, tablePagination, tableScroll } from '@/utils/table';
import { fixedLeft, fixedRight, tablePagination, tableScroll, useOverflowGrid } from '@/utils/table';
interface CompetitorRow {
id: string;
@@ -45,6 +45,7 @@ export default function MonitoringAccountList() {
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);
@@ -130,6 +131,7 @@ export default function MonitoringAccountList() {
{
title: '账号',
dataIndex: 'nickname',
...fixedLeft<CompetitorRow>({}),
render: (_, account) => (
<Flex align="center" gap={12}>
<Avatar size={40} shape="square" src={account.avatar_url || undefined}>{(account.nickname || '账').slice(0, 1).toUpperCase()}</Avatar>
@@ -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)}
/>
<Modal
open={Boolean(tagTarget)}
+4 -2
View File
@@ -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 { fixedRight, tablePagination, tableScroll } from '@/utils/table';
import { fixedLeft, fixedRight, tablePagination, tableScroll, useOverflowGrid } from '@/utils/table';
interface Job {
id: string;
@@ -34,6 +34,7 @@ export default function Page() {
const [busy, setBusy] = useState(false);
const [retryID, setRetryID] = useState<string | null>(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<Job>({}),
render: (value?: string, job?: Job) => {
if (!value) {
return (
@@ -184,7 +186,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} sticky pagination={tablePagination()} scroll={tableScroll(1000)} locale={{ emptyText: '暂无分享链接解析任务。' }} />
<Table rowKey="id" columns={columns} dataSource={jobs} loading={pending} sticky pagination={tablePagination()} scroll={tableScroll(horizontal ? 1000 : undefined, vertical ? 'calc(100vh - 300px)' : undefined)} 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="可直接粘贴平台分享文案,系统会自动提取其中的链接并识别作者。">
+4 -2
View File
@@ -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<string, string> = {
created: '已创建',
@@ -37,6 +37,7 @@ export default function Page() {
const [error, setError] = useState<any>(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<any>({}),
render: (_, runtime) => (
<div style={{ cursor: 'pointer' }} onClick={() => history.push(`/browsers/${encodeURIComponent(runtime.alias)}`)}>
<Typography.Text strong>{runtime.name}</Typography.Text>
@@ -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。' }}
/>
</Card>
+4 -3
View File
@@ -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<any>(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<any> = [
{ title: '名称', dataIndex: 'name', render: (value: string) => <Typography.Text strong>{value}</Typography.Text> },
{ title: '名称', dataIndex: 'name', ...fixedLeft<any>({}), render: (value: string) => <Typography.Text strong>{value}</Typography.Text> },
{ title: 'Endpoint', dataIndex: 'endpoint', render: (value: string) => <Typography.Text code style={{ fontSize: 12 }} ellipsis>{value}</Typography.Text> },
{
title: '令牌',
@@ -202,7 +203,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} sticky pagination={tablePagination()} scroll={tableScroll(960)} locale={{ emptyText: '暂无网关;注册后环境即可调度到该网关。' }} />
<Table rowKey="name" columns={columns} dataSource={gateways} loading={pending} sticky pagination={tablePagination()} scroll={tableScroll(horizontal ? 960 : undefined, vertical ? 'calc(100vh - 300px)' : undefined)} locale={{ emptyText: '暂无网关;注册后环境即可调度到该网关。' }} />
<GatewayFormModal
open={createOpen}
initial={null}
+4 -2
View File
@@ -6,7 +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';
import { fixedLeft, fixedRight, tablePagination, tableScroll, useOverflowGrid } from '@/utils/table';
const healthText: Record<string, string> = {
unchecked: '未检测',
@@ -113,6 +113,7 @@ export default function Page() {
const [editing, setEditing] = useState<any | null>(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<any>({}),
render: (_, exit) => (
<div>
<Typography.Link onClick={() => history.push(`/network-exits/${encodeURIComponent(exit.id)}`)}>{exit.id}</Typography.Link>
@@ -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: '暂无网络出口。创建并检测健康后,才能绑定运行环境。' }}
/>
<ExitFormModal
+4 -2
View File
@@ -5,7 +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';
import { fixedLeft, fixedRight, tablePagination, tableScroll, useOverflowGrid } from '@/utils/table';
const stateText: Record<string, string> = {
queued: '已排队',
@@ -56,6 +56,7 @@ export default function Page() {
const [accountsPending, setAccountsPending] = useState(false);
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(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<any>({}),
render: (value: string) => (
<Space size={4}>
<Typography.Link onClick={() => history.push(`/tasks/${value}`)}>{value}</Typography.Link>
@@ -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 ? '没有符合筛选条件的任务' : '尚无任务' }} />
</Card>
</div>
+25 -8
View File
@@ -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<RecordType>(column: ColumnType<RecordType>): ColumnType<RecordType> {
return { ...column, fixed: 'left' as const };
}
export function fixedRight<RecordType>(column: ColumnType<RecordType>): ColumnType<RecordType> {
return { ...column, fixed: 'right' as const };
}
+2 -1
View File
@@ -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</, '首列应固定在左侧');
assert.match(code, /\.\.\.fixedRight</, '操作列应固定在右侧');
assert.match(code, /useOverflowGrid\(/, '应按数据量按需开启表内滚动/横向滚动');
});
}