fix: preserve monitoring list pagination when returning from details
This commit is contained in:
@@ -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 />;
|
||||
|
||||
@@ -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} 应留在表格行操作中`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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</);
|
||||
|
||||
Reference in New Issue
Block a user