fix: preserve monitoring list pagination when returning from details

This commit is contained in:
2026-10-07 14:00:35 +08:00
parent b4705f3256
commit 718e4479cf
5 changed files with 189 additions and 9 deletions
@@ -1,12 +1,12 @@
// 监控账号列表:表格呈现账号画像、作品数据与同步状态。
import { useCallback, useEffect, useState } from 'react';
import { history } from '@umijs/max';
import { history, useSearchParams } from '@umijs/max';
import { Alert, App, Avatar, Button, Card, Flex, Form, Grid, Modal, Popconfirm, Select, Space, Table, Tag, Tooltip, Typography } from 'antd';
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, useTableSticky } from '@/utils/table';
import { fixedLeft, fixedRight, TABLE_PAGE_SIZE, tablePagination, useTableSticky } from '@/utils/table';
interface CompetitorRow {
id: string;
@@ -37,6 +37,12 @@ function formatCount(value?: number | null): string {
}
export default function MonitoringAccountList() {
const [searchParams, setSearchParams] = useSearchParams();
const pageValue = Number(searchParams.get('page'));
const pageSizeValue = Number(searchParams.get('pageSize'));
const current = Number.isSafeInteger(pageValue) && pageValue > 0 ? pageValue : 1;
const pageSize = Number.isSafeInteger(pageSizeValue) && pageSizeValue > 0 ? pageSizeValue : TABLE_PAGE_SIZE;
const listQuery = searchParams.toString();
const [rows, setRows] = useState<CompetitorRow[]>([]);
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(null);
@@ -136,7 +142,7 @@ export default function MonitoringAccountList() {
render: (_, account) => (
<Flex align="center" gap={12}>
<Avatar size={40} shape="square" src={account.avatar_url || undefined}>{(account.nickname || '账').slice(0, 1).toUpperCase()}</Avatar>
<Typography.Link onClick={() => history.push(`/accounts/monitoring/${account.id}`)}>
<Typography.Link onClick={() => history.push(`/accounts/monitoring/${account.id}${listQuery ? `?${listQuery}` : ''}`)}>
{account.nickname || '未命名账号'}
</Typography.Link>
</Flex>
@@ -229,7 +235,17 @@ export default function MonitoringAccountList() {
dataSource={rows}
loading={pending}
sticky={sticky}
pagination={tablePagination()}
pagination={{
...tablePagination(),
current,
pageSize,
onChange: (page, size) => {
const params = new URLSearchParams(searchParams);
params.set('page', String(size === pageSize ? page : 1));
params.set('pageSize', String(size));
setSearchParams(params, { replace: true });
},
}}
locale={{ emptyText: '暂无监控账号。' }}
scroll={{ x: horizontal ? 1100 : undefined }}
/>
@@ -2,7 +2,7 @@
// 页头 actions 为返回/同步/暂停恢复;主体:账号画像 Card + 作品数据(共享 WorksGallery:作品网格 + Drawer 增长曲线/指标记录/评论)。
// 作品列表走 GET /creator/works?source_id=<competitor_id>&source_type=competitor,定时采集由后端调度器维护。
import { useCallback, useEffect, useState } from 'react';
import { history, useParams } from '@umijs/max';
import { history, useLocation, useParams } from '@umijs/max';
import { Alert, App, Avatar, Button, Card, Descriptions, Flex, Popover, Space, Tag, Typography } from 'antd';
import { creatorAction, getOne } from '@/services/api';
import { conflictMessage, dateTime } from '@/utils/helpers';
@@ -44,6 +44,7 @@ function formatCount(value?: number | null): string {
export default function Page() {
const { id = '' } = useParams<{ id: string }>();
const { search } = useLocation();
const [competitor, setCompetitor] = useState<CompetitorDetail | null>(null);
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(null);
@@ -73,7 +74,7 @@ export default function Page() {
// 页头 actions:返回/同步/暂停恢复,对齐账号详情页形态。
usePageActions(
<Space>
<Button onClick={() => history.push('/accounts/monitoring')}>返回监控账号</Button>
<Button onClick={() => history.push(`/accounts/monitoring${search}`)}>返回监控账号</Button>
<Button
loading={actionBusy}
disabled={actionBusy || syncRunning || !competitor}
@@ -117,7 +118,7 @@ export default function Page() {
{competitor ? (competitor.enabled ? '暂停监控' : '恢复监控') : '暂停监控'}
</Button>
</Space>,
[competitor?.id, competitor?.enabled, competitor?.sync_status],
[competitor?.id, competitor?.enabled, competitor?.sync_status, search],
);
if (pending) return <Card loading />;
+1 -1
View File
@@ -78,7 +78,7 @@ test('监控账号列表使用 Table,一行一个账号,保留详情入口
assert.match(code, /<Table\b/);
assert.match(code, /rowKey="id"/);
assert.match(code, /loading=\{pending\}/);
assert.match(code, /history\.push\(`\/accounts\/monitoring\/\$\{account\.id\}`\)/);
assert.match(code, /history\.push\(`\/accounts\/monitoring\/\$\{account\.id\}/);
for (const action of ['openTags(account)', 'syncCompetitor(account)', 'toggleCompetitor(account)', 'deleteAccount(account)']) {
assert.ok(code.includes(action), `${action} 应留在表格行操作中`);
}
+154
View File
@@ -0,0 +1,154 @@
const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const path = require('node:path');
const Module = require('node:module');
const test = require('node:test');
const ts = require('typescript');
const listFile = path.join(__dirname, '../src/components/accounts/MonitoringAccountList.tsx');
const detailFile = path.join(__dirname, '../src/pages/accounts/monitoring/$id/index.tsx');
function harness(initialURL = '/accounts/monitoring') {
const entries = [initialURL];
let position = 0;
let pageActions = [];
const location = () => new URL(entries[position], 'http://localhost');
const history = {
push(url) {
entries.splice(position + 1);
entries.push(url);
position += 1;
},
replace(url) { entries[position] = url; },
back() { position -= 1; },
};
const element = (type, props, ...children) => ({ type, props: props || {}, children });
const react = {
createElement: element,
Fragment: 'Fragment',
useState: (value) => [typeof value === 'function' ? value() : value, () => {}],
useEffect: () => {},
useCallback: (callback) => callback,
};
const components = new Proxy({
App: { useApp: () => ({ message: { success() {}, error() {} } }) },
Typography: { Text: 'Text', Link: 'Typography.Link' },
Form: Object.assign('Form', { useForm: () => [{}], Item: 'Form.Item' }),
Grid: { useBreakpoint: () => ({ xl: true }) },
}, { get: (target, key) => target[key] ?? String(key) });
const jsx = (type, props) => element(type, props, props?.children);
const mocks = {
react,
'react/jsx-runtime': { jsx, jsxs: jsx, Fragment: 'Fragment' },
antd: components,
'@ant-design/icons': new Proxy({}, { get: (_, key) => String(key) }),
'@ant-design/pro-components': { PageContainer: 'PageContainer' },
'@umijs/max': {
history,
Link: 'Link',
useParams: () => ({ id: '42' }),
useLocation: location,
useSearchParams: () => [new URLSearchParams(location().search), (params, options) => {
const url = `${location().pathname}?${params}`;
if (options?.replace) history.replace(url);
else history.push(url);
}],
},
'@/services/api': {},
'@/components/PageActions': { usePageActions: (actions) => { pageActions = actions; } },
'@/utils/helpers': { conflictMessage: () => '', dateTime: () => '' },
'@/utils/table': {
TABLE_PAGE_SIZE: 20,
tablePagination: () => ({ pageSize: 20, hideOnSinglePage: true }),
fixedLeft: () => ({}), fixedRight: () => ({}), useTableSticky: () => undefined,
},
'@/components/creator/WorksGallery': { default: 'WorksGallery' },
};
function load(file) {
const compiled = new Module(file, module);
compiled.filename = file;
compiled.paths = module.paths;
compiled.require = (name) => {
assert.ok(Object.hasOwn(mocks, name), `Unmocked dependency: ${name}`);
return mocks[name];
};
compiled._compile(ts.transpileModule(readFileSync(file, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2020 },
}).outputText, file);
return compiled.exports.default;
}
function find(node, type) {
if (!node || typeof node !== 'object') return undefined;
if (node.type === type) return node;
return node.children?.flat(Infinity).map((child) => find(child, type)).find(Boolean);
}
const List = load(listFile);
const Detail = load(detailFile);
return {
table: () => find(List(), 'Table').props,
enterDetail() {
const column = find(List(), 'Table').props.columns.find((column) => column.dataIndex === 'nickname');
find(column.render('', { id: '42' }), 'Typography.Link').props.onClick();
},
returnFromDetail() {
Detail();
const button = find(pageActions, 'Button');
button.props.onClick();
},
history,
url: () => entries[position],
entryCount: () => entries.length,
};
}
test('monitoring list starts at page one with the existing page size', () => {
const h = harness();
assert.equal(h.table().pagination.current, 1);
assert.equal(h.table().pagination.pageSize, 20);
});
test('detail return preserves the selected page and page size', () => {
const h = harness();
h.table().pagination.onChange(2, 20);
h.enterDetail();
h.returnFromDetail();
assert.equal(h.table().pagination.current, 2);
assert.equal(h.table().pagination.pageSize, 20);
assert.equal(new URL(h.url(), 'http://localhost').pathname, '/accounts/monitoring');
});
test('browser back restores pagination without stepping through previous page changes', () => {
const h = harness();
h.table().pagination.onChange(2, 20);
h.table().pagination.onChange(3, 20);
assert.equal(h.entryCount(), 1);
h.enterDetail();
h.history.back();
assert.equal(h.table().pagination.current, 3);
});
test('changing page size resets to page one and keeps that size after returning', () => {
const h = harness('/accounts/monitoring?page=3&pageSize=20');
h.table().pagination.onChange(3, 50);
assert.equal(h.table().pagination.current, 1);
h.table().pagination.onChange(2, 50);
h.enterDetail();
h.returnFromDetail();
assert.equal(h.table().pagination.current, 2);
assert.equal(h.table().pagination.pageSize, 50);
});
test('reload and directly opened detail retain the pagination in their URL', () => {
const h = harness('/accounts/monitoring/42?page=4&pageSize=50');
h.returnFromDetail();
assert.equal(h.table().pagination.current, 4);
assert.equal(h.table().pagination.pageSize, 50);
});
for (const value of ['invalid', '-1', '0', '1.5', 'Infinity', '9007199254740992']) {
test(`invalid pagination value ${value} uses valid defaults`, () => {
const h = harness(`/accounts/monitoring?page=${value}&pageSize=${value}`);
assert.equal(h.table().pagination.current, 1);
assert.equal(h.table().pagination.pageSize, 20);
});
}
+10 -1
View File
@@ -36,7 +36,16 @@ for (const [file, width] of files) {
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\(\)\}/);
const pagination = props.find(prop => prop.name?.getText(source) === 'pagination');
assert.ok(pagination?.initializer && ts.isJsxExpression(pagination.initializer));
const config = pagination.initializer.expression;
assert.ok(
(ts.isCallExpression(config) && config.expression.getText(source) === 'tablePagination')
|| (ts.isObjectLiteralExpression(config) && config.properties.some(prop =>
ts.isSpreadAssignment(prop) && prop.expression.getText(source) === 'tablePagination()',
)),
'分页必须使用或扩展统一分页配置',
);
assert.doesNotMatch(code, /pagination=\{false\}/);
assert.match(code, /\.\.\.fixedLeft</);
assert.match(code, /\.\.\.fixedRight</);