fix: preserve pagination and filters across list navigation
douyin-release-gate / verify (push) Failing after 18m33s

This commit is contained in:
2026-10-07 14:41:11 +08:00
parent 718e4479cf
commit 51091cf0c5
21 changed files with 565 additions and 46 deletions
+2
View File
@@ -88,6 +88,8 @@
## UI 规范(antd/pro)
- 分页导航:我的账号、监控账号、账号导入、网关、网络出口、作品分析、评论聚合、事件聚合,以及账号详情作品列表,返回时保留页码、每页数量和对应筛选;作品分析、评论聚合同时保留选中的 TAB,各来源独立保存。状态按页面地址和来源隔离,仅保留在当前浏览器标签页;关闭标签页结束本次记忆。筛选或每页数量变化仍回第一页,作品列表固定每页 25 条不新增选择器。监控列表已有的 URL 分页优先于保存位置。私信“加载更多”及作品弹窗评论不参与跨页面记忆。
- 页面标题用 PageContainer 自带 `title`/`subTitle`;页头不另造按钮行。
- 列表页(对齐 ant-design-pro TableList):主操作按钮(带 icon)放表格 Card 内右上角,与卡片左上角统计信息同行(`Flex justify="space-between" align="center"`,间距用 `marginBottom: 16`),按钮只出现在卡片内,不再经 `usePageActions` 注册到页头;非列表页的操作按钮仍经 `usePageActions` 注册到页头 content 槽。
- 弃用 API 禁止回潮:`Alert` 用 `title`/`description`(非 `message`)、纵向堆叠用 `Flex vertical`(非 `Space direction`)、静态 `message.xxx` 改 `App.useApp()`。以 `antd lint` 结果为准。
@@ -10,7 +10,7 @@ import { DownOutlined, 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, useTableSticky } from '@/utils/table';
import { fixedLeft, fixedRight, useTablePagination, useTableSticky } from '@/utils/table';
import { listenerStatusLabel } from '@/utils/account-events';
import { accountCollectionStatusPresentation, type CollectionCheckpoint } from '@/utils/account-collection-status';
import { accountPrimaryAction, performAccountPrimaryAction, type AccountAction, type BrowserState } from './account-actions';
@@ -86,6 +86,7 @@ export default function AccountManagementList() {
const [loginTarget, setLoginTarget] = useState<Row | null>(null);
const { message: messageApi, modal } = App.useApp();
const sticky = useTableSticky();
const pagination = useTablePagination();
const load = useCallback(async (silent = false) => {
const sequence = ++loadSequence.current;
@@ -468,7 +469,7 @@ export default function AccountManagementList() {
dataSource={rows}
loading={pending}
sticky={sticky}
pagination={tablePagination()}
pagination={pagination}
scroll={{ x: 'max-content' }}
locale={{ emptyText: '暂无账号或待登录环境。' }}
/>
@@ -1,6 +1,7 @@
// 监控账号列表:表格呈现账号画像、作品数据与同步状态。
import { useCallback, useEffect, useState } from 'react';
import { history, useSearchParams } from '@umijs/max';
import { usePageState } from '@/hooks/usePageState';
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';
@@ -38,11 +39,17 @@ function formatCount(value?: number | null): string {
export default function MonitoringAccountList() {
const [searchParams, setSearchParams] = useSearchParams();
const [savedPagination, setSavedPagination] = usePageState('pagination', { current: 1, pageSize: TABLE_PAGE_SIZE });
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 current = Number.isSafeInteger(pageValue) && pageValue > 0 ? pageValue : savedPagination.current;
const pageSize = Number.isSafeInteger(pageSizeValue) && pageSizeValue > 0 ? pageSizeValue : savedPagination.pageSize;
const listQuery = searchParams.toString();
useEffect(() => {
if (current !== savedPagination.current || pageSize !== savedPagination.pageSize) {
setSavedPagination({ current, pageSize });
}
}, [current, pageSize, savedPagination.current, savedPagination.pageSize, setSavedPagination]);
const [rows, setRows] = useState<CompetitorRow[]>([]);
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(null);
@@ -241,8 +248,10 @@ export default function MonitoringAccountList() {
pageSize,
onChange: (page, size) => {
const params = new URLSearchParams(searchParams);
params.set('page', String(size === pageSize ? page : 1));
const nextPage = size === pageSize ? page : 1;
params.set('page', String(nextPage));
params.set('pageSize', String(size));
setSavedPagination({ current: nextPage, pageSize: size });
setSearchParams(params, { replace: true });
},
}}
+8 -2
View File
@@ -7,6 +7,7 @@ import { Line } from '@ant-design/charts';
import { Alert, App, Button, Card, Col, Drawer, Empty, Flex, InputNumber, Pagination, Row, Space, Spin, Table, Typography } from 'antd';
import { CommentOutlined, EyeOutlined, StarOutlined, FireOutlined, LikeOutlined, ReloadOutlined, ShareAltOutlined } from '@ant-design/icons';
import { creatorAction, creatorGet, fetchCreatorBinary, getList } from '@/services/api';
import { usePageState } from '@/hooks/usePageState';
import { conflictMessage, dateTime } from '@/utils/helpers';
// 作品封面:从本地缓存接口读取(认证 fetch + blob URL);缓存缺失时展示占位。
@@ -141,16 +142,21 @@ interface WorksGalleryProps {
export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFilters, showGrowthFilter = true }: WorksGalleryProps) {
const [works, setWorks] = useState<any[]>([]);
const [pageState, setPageState] = useState({ filterKey: '', page: 1 });
const [pageState, setPageState] = usePageState(`${sourceType}-works-pagination`, { filterKey: '', page: 1 });
const [worksRefresh, setWorksRefresh] = useState(0);
const [workPageInfo, setWorkPageInfo] = useState({ total: 0 });
const [collectionProgress, setCollectionProgress] = useState<CollectionProgress | null>(null);
const [progressError, setProgressError] = useState<any>(null);
const [worksPending, setWorksPending] = useState(true);
const [worksError, setWorksError] = useState<any>(null);
const [minGrowth, setMinGrowth] = useState<number | null>(null);
const [minGrowth, setMinGrowth] = usePageState<number | null>(`${sourceType}-works-growth`, null);
const filterKey = JSON.stringify([sourceId, sourceType, extraFilters, minGrowth]);
const workPage = pageState.filterKey === filterKey ? pageState.page : 1;
useEffect(() => {
if (pageState.filterKey !== filterKey) {
setPageState(previous => ({ ...previous, filterKey, page: 1 }));
}
}, [filterKey, pageState.filterKey, setPageState]);
const setWorkPage = (page: number) => setPageState({ filterKey, page });
const [detailWork, setDetailWork] = useState<any>(null);
const [workMetrics, setWorkMetrics] = useState<any[]>([]);
+27
View File
@@ -0,0 +1,27 @@
import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
import { useLocation } from '@umijs/max';
/** 当前标签页中的列表状态;值必须能够用 JSON 保存。 */
export function usePageState<T>(name: string, defaultValue: T): [T, Dispatch<SetStateAction<T>>] {
const { pathname } = useLocation();
const key = `creator-hub:page-state:${pathname}:${name}`;
// 初始值与 useState 一样,仅在进入新的页面或 TAB 时读取。
const initialValue = useMemo(() => {
const saved = sessionStorage.getItem(key);
return saved === null ? defaultValue : JSON.parse(saved) as T;
}, [key]);
const [state, setState] = useState({ key, value: initialValue });
const value = state.key === key ? state.value : initialValue;
const update = useCallback((next: SetStateAction<T>) => {
setState(previous => {
const current = previous.key === key ? previous.value : initialValue;
return { key, value: typeof next === 'function' ? (next as (value: T) => T)(current) : next };
});
}, [key, initialValue]);
useEffect(() => {
sessionStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, update];
}
+3 -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 { fixedLeft, fixedRight, tablePagination, useTableSticky } from '@/utils/table';
import { fixedLeft, fixedRight, useTablePagination, useTableSticky } from '@/utils/table';
interface Job {
id: string;
@@ -36,6 +36,7 @@ export default function Page() {
const { message: messageApi } = App.useApp();
const horizontal = !Grid.useBreakpoint().xl;
const sticky = useTableSticky();
const pagination = useTablePagination();
const loadShareJobs = useCallback(async () => {
setPending(true);
@@ -189,7 +190,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={sticky} pagination={tablePagination()} scroll={{ x: horizontal ? 1000 : undefined }} locale={{ emptyText: '暂无分享链接解析任务。' }} />
<Table rowKey="id" columns={columns} dataSource={jobs} loading={pending} sticky={sticky} pagination={pagination} scroll={{ x: horizontal ? 1000 : 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="可直接粘贴平台分享文案,系统会自动提取其中的链接并识别作者。">
+6 -2
View File
@@ -13,6 +13,7 @@ import {
import type { TableColumnsType } from "antd";
import { useEffect, useState } from "react";
import { creatorGet, getList } from "@/services/api";
import { usePageState } from "@/hooks/usePageState";
import { conflictMessage, dateTime } from "@/utils/helpers";
import { WorkCover } from "@/components/creator/WorksGallery";
@@ -96,10 +97,10 @@ export function CommentAggregationTab({
}: {
sourceType: SourceType;
}) {
const [filters, setFilters] = useState<{ sourceId?: string; hours: number }>({
const [filters, setFilters] = usePageState<{ sourceId?: string; hours: number }>(`${sourceType}-filters`, {
hours: 24,
});
const [pagination, setPagination] = useState({ page: 1, pageSize: 25 });
const [pagination, setPagination] = usePageState(`${sourceType}-pagination`, { page: 1, pageSize: 25 });
const [comments, setComments] = useState<AggregatedComment[]>([]);
const [total, setTotal] = useState(0);
const [pending, setPending] = useState(true);
@@ -288,8 +289,11 @@ export function CommentAggregationTab({
}
export default function CommentAggregationPage() {
const [activeTab, setActiveTab] = usePageState('active-tab', 'owned');
return (
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
destroyOnHidden={false}
items={[
{
+11 -8
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Alert, App, Button, Card, DatePicker, Flex, Form, InputNumber, Select, Tabs, Typography } from 'antd';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { usePageState } from '@/hooks/usePageState';
import WorksGallery from '@/components/creator/WorksGallery';
import { creatorGet } from '@/services/api';
import { conflictMessage } from '@/utils/helpers';
@@ -8,7 +9,7 @@ import { conflictMessage } from '@/utils/helpers';
type SourceType = 'owned' | 'competitor';
interface Filters {
sourceId?: string;
dates?: [Dayjs, Dayjs];
dates?: [string, string];
minLikes?: number;
minComments?: number;
minShares?: number;
@@ -42,7 +43,7 @@ const commonSortOptions = [
// 每个 TAB 独立挂载并保留条件、分页,不混合两种来源。
export function WorkAnalysisTab({ sourceType }: { sourceType: SourceType }) {
const [filters, setFilters] = useState<Filters>({});
const [filters, setFilters] = usePageState<Filters>(`${sourceType}-filters`, {});
const [accounts, setAccounts] = useState<AccountOption[]>([]);
const [accountsPending, setAccountsPending] = useState(true);
const [accountsError, setAccountsError] = useState<unknown>(null);
@@ -77,8 +78,8 @@ export function WorkAnalysisTab({ sourceType }: { sourceType: SourceType }) {
growth_hours: 24,
sort_by: filters.sortBy ?? 'published_at',
sort_order: filters.sortOrder ?? 'desc',
published_after: filters.dates?.[0].startOf('day').toISOString(),
published_before: filters.dates?.[1].endOf('day').toISOString(),
published_after: filters.dates ? dayjs(filters.dates[0]).startOf('day').toISOString() : undefined,
published_before: filters.dates ? dayjs(filters.dates[1]).endOf('day').toISOString() : undefined,
min_likes: filters.minLikes,
min_comments: filters.minComments,
min_shares: filters.minShares,
@@ -116,11 +117,11 @@ export function WorkAnalysisTab({ sourceType }: { sourceType: SourceType }) {
<Form.Item label="发布时间">
<DatePicker.RangePicker
aria-label="发布时间"
value={filters.dates}
value={filters.dates ? [dayjs(filters.dates[0]), dayjs(filters.dates[1])] : undefined}
onChange={(dates) =>
setFilters((current) => ({
...current,
dates: dates?.[0] && dates[1] ? [dates[0], dates[1]] : undefined,
dates: dates?.[0] && dates[1] ? [dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')] : undefined,
}))
}
/>
@@ -190,9 +191,11 @@ export function WorkAnalysisTab({ sourceType }: { sourceType: SourceType }) {
}
export default function Page() {
const [activeTab, setActiveTab] = usePageState('active-tab', 'competitor');
return (
<Tabs
defaultActiveKey="competitor"
activeKey={activeTab}
onChange={setActiveTab}
destroyOnHidden={false}
items={[
{
+3 -2
View File
@@ -3,6 +3,7 @@ import { Alert, Avatar, Button, Card, Flex, Select, Table, Tag, Tooltip, Typogra
import { ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { creatorGet } from '@/services/api';
import { usePageState } from '@/hooks/usePageState';
import { conflictMessage, dateTime } from '@/utils/helpers';
import { tablePagination, useTableSticky } from '@/utils/table';
import { eventQuery, eventTypeLabel, eventTypeLabels, messageTypeLabel } from '@/utils/account-events';
@@ -66,8 +67,8 @@ const columns: ColumnsType<AccountEvent> = [
];
export default function EventAggregationPage() {
const [filters, setFilters] = useState<EventFilters>({ hours: 0 });
const [pagination, setPagination] = useState({ page: 1, pageSize: 25 });
const [filters, setFilters] = usePageState<EventFilters>('filters', { hours: 0 });
const [pagination, setPagination] = usePageState('pagination', { page: 1, pageSize: 25 });
const [events, setEvents] = useState<AccountEvent[]>([]);
const [accounts, setAccounts] = useState<Account[]>([]);
const [total, setTotal] = useState(0);
+3 -2
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, dateTime } from '@/utils/helpers';
import { fixedLeft, fixedRight, tablePagination, useTableSticky } from '@/utils/table';
import { fixedLeft, fixedRight, useTablePagination, useTableSticky } 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}$/;
@@ -97,6 +97,7 @@ export default function Page() {
const { message: messageApi } = App.useApp();
const horizontal = !Grid.useBreakpoint().xl;
const sticky = useTableSticky();
const pagination = useTablePagination();
const healthReloadTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const load = useCallback(async () => {
@@ -235,7 +236,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={sticky} pagination={tablePagination()} scroll={{ x: horizontal ? 960 : undefined }} locale={{ emptyText: '暂无网关;注册后环境即可调度到该网关。' }} />
<Table rowKey="name" columns={columns} dataSource={gateways} loading={pending} sticky={sticky} pagination={pagination} scroll={{ x: horizontal ? 960 : undefined }} locale={{ emptyText: '暂无网关;注册后环境即可调度到该网关。' }} />
<GatewayFormModal
open={createOpen}
initial={null}
+3 -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 { fixedLeft, fixedRight, tablePagination, useTableSticky } from '@/utils/table';
import { fixedLeft, fixedRight, useTablePagination, useTableSticky } from '@/utils/table';
const healthText: Record<string, string> = {
unchecked: '未检测',
@@ -115,6 +115,7 @@ export default function Page() {
const { message: messageApi } = App.useApp();
const horizontal = !Grid.useBreakpoint().xl;
const sticky = useTableSticky();
const pagination = useTablePagination();
const load = useCallback(async () => {
setPending(true);
@@ -306,7 +307,7 @@ export default function Page() {
dataSource={exits}
loading={pending}
sticky={sticky}
pagination={tablePagination()}
pagination={pagination}
scroll={{ x: horizontal ? 1100 : undefined }}
locale={{ emptyText: '暂无网络出口。创建并检测健康后,才能绑定运行环境。' }}
/>
+12
View File
@@ -3,6 +3,7 @@
import { ProProvider, RouteContext } from '@ant-design/pro-components';
import type { ColumnsType, ColumnType } from 'antd/es/table';
import { useContext } from 'react';
import { usePageState } from '@/hooks/usePageState';
export const TABLE_PAGE_SIZE = 20;
@@ -18,6 +19,17 @@ export function tablePagination() {
return { pageSize: TABLE_PAGE_SIZE, hideOnSinglePage: true };
}
export function useTablePagination() {
const [pagination, setPagination] = usePageState('pagination', { current: 1, pageSize: TABLE_PAGE_SIZE });
return {
...tablePagination(),
...pagination,
onChange: (current: number, pageSize: number) => {
setPagination(previous => ({ current: pageSize === previous.pageSize ? current : 1, pageSize }));
},
};
}
export function fixedLeft<RecordType>(column: ColumnType<RecordType>): ColumnType<RecordType> {
return { ...column, fixed: 'left' as const };
}
+46 -1
View File
@@ -4,6 +4,7 @@ const path = require('node:path');
const Module = require('node:module');
const { test } = require('node:test');
const ts = require('typescript');
const { loadPageState } = require('./helpers/page-state.cjs');
const root = path.join(__dirname, '..');
const element = (type, props = {}) => ({ type, props });
@@ -72,7 +73,7 @@ function harness(relativePath, api = {}, componentName = 'default') {
const modules = {
react,
'react/jsx-runtime': { jsx: element, jsxs: element, Fragment: 'Fragment' },
'@umijs/max': { useParams: () => ({ id: 'account-1' }), history: { push: (url) => calls.navigation.push(url) } },
'@umijs/max': { useParams: () => ({ id: 'account-1' }), useLocation: () => ({ search: api.search || '' }), history: { push: (url) => calls.navigation.push(url) } },
'@ant-design/charts': { Line: 'Line' },
'@ant-design/icons': new Proxy({}, { get: (_, name) => String(name) }),
antd,
@@ -122,6 +123,9 @@ function harness(relativePath, api = {}, componentName = 'default') {
fingerprintFormValues: (fingerprint) => ({ fingerprint }), fingerprintPayload: (value) => value,
},
};
modules['@/hooks/usePageState'] = loadPageState(react, {
storage: api.storage, pathname: () => api.pathname || '/test',
});
const filename = path.join(root, relativePath);
const loaded = new Module(filename);
loaded.filename = filename;
@@ -158,6 +162,47 @@ const text = (tree) => {
if (Array.isArray(tree)) return tree.map(text).join('');
return typeof tree === 'object' ? text(tree.props?.children) : String(tree);
};
for (const sourceType of ['owned', 'competitor']) {
test(`${sourceType}: 账号详情作品页码和增长筛选返回时恢复,不混用其他账号`, async t => {
const storage = new Map();
const api = { storage, pathname: `/accounts/${sourceType}/account-1`, collectionStatus: { works: { status: 'pending' }, comments: { status: 'pending' } }, workPages: { 1: { data: [], total: 100 }, 3: { data: [], total: 100 } } };
const props = { sourceType, sourceId: 'account-1' };
const first = harness('src/components/creator/WorksGallery.tsx', api);
t.after(() => first.unmount());
let tree = await first.mount(props);
byType(tree, 'InputNumber')[0].props.onChange(100);
first.render(); await flush(); tree = first.render();
byType(tree, 'Pagination')[0].props.onChange(3);
first.render(); await flush(); first.render();
first.unmount();
const returned = harness('src/components/creator/WorksGallery.tsx', api);
t.after(() => returned.unmount());
tree = await returned.mount(props);
assert.equal(byType(tree, 'Pagination')[0].props.current, 3);
assert.equal(byType(tree, 'InputNumber')[0].props.value, 100);
assert.equal(returned.calls.lists[0].page, 3, '首个作品请求即恢复正确页码');
const other = harness('src/components/creator/WorksGallery.tsx', { ...api, pathname: `/accounts/${sourceType}/account-2` });
t.after(() => other.unmount());
tree = await other.mount({ sourceType, sourceId: 'account-2' });
assert.equal(byType(tree, 'Pagination')[0].props.current, 1);
assert.equal(byType(tree, 'InputNumber')[0].props.value, null);
});
}
test('作品筛选改回原条件也保持第一页,不恢复筛选前的旧页码', async t => {
const page = harness('src/components/creator/WorksGallery.tsx', { workPages: { 1: { data: [], total: 100 }, 3: { data: [], total: 100 } } });
t.after(() => page.unmount());
let tree = await page.mount({ sourceType: 'competitor', sourceId: 'account-1' });
byType(tree, 'Pagination')[0].props.onChange(3);
page.render(); await flush(); tree = page.render();
byType(tree, 'InputNumber')[0].props.onChange(100);
page.render(); await flush(); tree = page.render();
assert.equal(byType(tree, 'Pagination')[0].props.current, 1);
byType(tree, 'InputNumber')[0].props.onChange(null);
page.render(); await flush(); tree = page.render();
assert.equal(byType(tree, 'Pagination')[0].props.current, 1);
});
test('analysis filters and sorting reset pagination before requesting, but ordinary paging is preserved', async () => {
const page = harness('src/components/creator/WorksGallery.tsx', {
workPages: { 1: { data: [], total: 48 }, 2: { data: [], total: 48 } },
@@ -4,6 +4,9 @@ const { Module, createRequire } = require('node:module');
const { resolve } = require('node:path');
const { test } = require('node:test');
const ts = require('typescript');
const { loadPageState, loadTable } = require('./helpers/page-state.cjs');
const cleanups = new Set();
test.afterEach(() => { for (const cleanup of cleanups) cleanup(); cleanups.clear(); });
const requireWeb = createRequire(resolve(__dirname, '../package.json'));
const file = resolve(__dirname, '../src/components/accounts/AccountManagementList.tsx');
const eventsFile = resolve(__dirname, '../src/utils/account-events.ts');
@@ -13,9 +16,15 @@ collectionModule._compile(ts.transpileModule(readFileSync(collectionFile, 'utf8'
const eventsModule = new Module(eventsFile, module);
eventsModule._compile(ts.transpileModule(readFileSync(eventsFile, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, eventsFile);
function setup({ accounts = [{ id: 'account-1', name: '真实昵称', business_status: 'normal', tags: ['主账号'], login_status: 'logged_in', environment: { alias: 'env-account' } }], environments = [{ alias: 'env-1', gateway: 'gw-a' }], browsers = [{ alias: 'env-account', state: 'running', status: 'ready', gateway_reachable: true }], currentAccount, verification, liveBrowserError, browserError, loadError, actionError } = {}) {
function setup({ accounts = [{ id: 'account-1', name: '真实昵称', business_status: 'normal', tags: ['主账号'], login_status: 'logged_in', environment: { alias: 'env-account' } }], environments = [{ alias: 'env-1', gateway: 'gw-a' }], browsers = [{ alias: 'env-account', state: 'running', status: 'ready', gateway_reachable: true }], currentAccount, verification, liveBrowserError, browserError, loadError, actionError, storage = new Map() } = {}) {
const states = [], calls = [], messages = [], paths = [], confirmations = [];
let cursor = 0, started = false;
let cursor = 0;
const effectDeps = [], memoSlots = [], effectCleanups = [];
const memo = (factory, deps) => {
const index = cursor++;
if (!memoSlots[index] || deps.some((value, i) => !Object.is(value, memoSlots[index].deps[i]))) memoSlots[index] = { value: factory(), deps };
return memoSlots[index].value;
};
const marker = displayName => Object.assign(() => null, { displayName });
const form = { setFieldsValue(values) { form.values = values; }, async validateFields() { return form.values; } };
const antd = {};
@@ -29,10 +38,19 @@ function setup({ accounts = [{ id: 'account-1', name: '真实昵称', business_s
const options = { accounts, environments, browsers, currentAccount, verification, liveBrowserError, browserError, loadError, actionError };
loaded.require = id => {
if (id === 'react') return {
useMemo: memo,
useState(value) { const index = cursor++; if (!(index in states)) states[index] = value; return [states[index], next => { states[index] = typeof next === 'function' ? next(states[index]) : next; }]; },
useRef(value) { const index = cursor++; if (!(index in states)) states[index] = { current: value }; return states[index]; },
useCallback: callback => callback,
useEffect(effect) { if (!started) { started = true; effect(); } },
useCallback: (callback, deps) => memo(() => callback, deps),
useEffect(effect, deps) {
const index = cursor++;
if (!effectDeps[index] || deps.some((value, i) => !Object.is(value, effectDeps[index][i]))) {
if (effectCleanups[index]) { effectCleanups[index](); cleanups.delete(effectCleanups[index]); }
effectDeps[index] = deps;
effectCleanups[index] = effect();
if (typeof effectCleanups[index] === 'function') cleanups.add(effectCleanups[index]);
}
},
};
if (id === 'antd') return antd;
if (id === '@umijs/max') return { history: { push: path => paths.push(path) } };
@@ -61,10 +79,12 @@ function setup({ accounts = [{ id: 'account-1', name: '真实昵称', business_s
if (id === '@/utils/account-events') return eventsModule.exports;
if (id === '@/utils/account-collection-status') return collectionModule.exports;
if (id === '@/utils/helpers') return { accountReadiness: () => ({ ready: true, canResume: 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: () => ({ pageSize: 20, hideOnSinglePage: true }), useTableSticky: () => ({ offsetHeader: 56 }) };
if (id === '@/utils/table') return { fixedLeft: column => ({ ...column, fixed: 'left' }), fixedRight: column => ({ ...column, fixed: 'right' }), useTablePagination: () => table.useTablePagination(), tablePagination: () => ({ pageSize: 20, hideOnSinglePage: true }), useTableSticky: () => ({ offsetHeader: 56 }) };
if (id.startsWith('@/components/accounts/')) return { default: marker(id.split('/').at(-1)) };
return requireWeb(id);
};
const react = loaded.require('react');
const table = loadTable(react, loadPageState(react, { storage }));
actionLoaded.require = loaded.require;
actionLoaded._compile(ts.transpileModule(readFileSync(actionFile, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText, actionFile);
loaded._compile(ts.transpileModule(readFileSync(file, 'utf8'), { fileName: file, compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, inlineSourceMap: true, inlineSources: true } }).outputText, file);
@@ -84,6 +104,23 @@ const flush = async () => { for (let i = 0; i < 10; i++) await Promise.resolve()
const text = node => node == null ? '' : typeof node === 'object' ? [node.props?.children].flat(Infinity).map(text).join('') : String(node);
const ready = async options => { const s = setup(options); await flush(); s.render(); return s; };
test('我的账号返回时恢复页码和每页数量', async () => {
const storage = new Map();
const first = await ready({ storage });
first.find('Table').props.pagination.onChange(3, 20);
first.render();
const returned = await ready({ storage });
assert.equal(returned.find('Table').props.pagination.current, 3);
returned.find('Table').props.pagination.onChange(3, 50);
returned.render();
assert.equal(returned.find('Table').props.pagination.current, 1);
returned.find('Table').props.pagination.onChange(2, 50);
returned.render();
const refreshed = await ready({ storage });
assert.equal(refreshed.find('Table').props.pagination.current, 2);
assert.equal(refreshed.find('Table').props.pagination.pageSize, 50);
});
test('待登录环境与已登录账号共用唯一列表、计数及分页', async () => {
const s = await ready();
assert.equal(s.findAll('Table').length, 1);
@@ -102,7 +139,10 @@ for (const rowCount of [1, 20, 100]) {
const { columns, scroll, dataSource, sticky, pagination } = s.find('Table').props;
assert.equal(dataSource.length, rowCount);
assert.deepEqual(sticky, { offsetHeader: 56 });
assert.deepEqual(pagination, { pageSize: 20, hideOnSinglePage: true });
assert.equal(pagination.current, 1);
assert.equal(pagination.pageSize, 20);
assert.equal(pagination.hideOnSinglePage, true);
assert.equal(typeof pagination.onChange, 'function');
assert.equal(columns[0].title, '账号');
assert.equal(columns[0].fixed, 'left');
assert.equal(columns[0].width, 240);
+57 -2
View File
@@ -4,6 +4,7 @@ const Module = require("node:module");
const path = require("node:path");
const test = require("node:test");
const ts = require("../node_modules/typescript");
const { loadPageState } = require('./helpers/page-state.cjs');
const target = path.resolve(
__dirname,
@@ -37,6 +38,7 @@ let harness;
Module._load = function (request, parent, isMain) {
if (parent?.filename === target) {
if (request === "react") return harness.react;
if (request === "@/hooks/usePageState") return harness.pageState;
if (request === "antd") return components;
if (request === "@ant-design/icons")
return { ReloadOutlined: "ReloadOutlined" };
@@ -73,7 +75,7 @@ function load() {
mod._compile(source, target);
return mod.exports;
}
function makeHarness() {
function makeHarness(storage = new Map()) {
const state = [];
const effects = [];
const pending = [];
@@ -87,6 +89,8 @@ function makeHarness() {
queries,
react: {
createElement,
useMemo: (factory) => factory(),
useCallback: (callback) => callback,
useState(initial) {
const index = cursor++;
if (!(index in state)) state[index] = initial;
@@ -130,6 +134,7 @@ function makeHarness() {
effects.forEach((effect) => effect?.cleanup?.());
},
};
h.pageState = loadPageState(h.react, { storage });
harness = h;
return h;
}
@@ -160,10 +165,58 @@ function deferred() {
return { promise, resolve, reject };
}
for (const sourceType of ['owned', 'competitor']) {
test(`${sourceType}: 评论返回时恢复筛选和分页,各 TAB 独立`, async t => {
const storage = new Map();
const first = makeHarness(storage);
t.after(() => first.cleanup());
const page = load();
const render = h => h.render(page.CommentAggregationTab, { sourceType });
render(first);
await tick();
let tree = render(first);
nodes(tree, 'Select').find(node => node.props.options.some(option => option.value === 6)).props.onChange(6);
render(first);
await tick();
tree = render(first);
nodes(tree, 'Table')[0].props.onChange({ current: 1, pageSize: 50 });
render(first);
tree = render(first);
nodes(tree, 'Table')[0].props.onChange({ current: 3, pageSize: 50 });
render(first);
first.cleanup();
const returned = makeHarness(storage);
t.after(() => returned.cleanup());
render(returned);
await tick();
tree = render(returned);
assert.equal(nodes(tree, 'Table')[0].props.pagination.current, 3);
assert.equal(nodes(tree, 'Table')[0].props.pagination.pageSize, 50);
assert.equal(nodes(tree, 'Select').find(node => node.props.options.some(option => option.value === 6)).props.value, 6);
const other = makeHarness(storage);
t.after(() => other.cleanup());
tree = other.render(page.CommentAggregationTab, { sourceType: sourceType === 'owned' ? 'competitor' : 'owned' });
assert.equal(nodes(tree, 'Table')[0].props.pagination.current, 1);
assert.equal(nodes(tree, 'Select').find(node => node.props.options.some(option => option.value === 6)).props.value, 24);
});
}
test('评论返回时恢复选中的 TAB', t => {
const storage = new Map();
const first = makeHarness(storage);
t.after(() => first.cleanup());
const page = load();
nodes(first.render(page.default), 'Tabs')[0].props.onChange('competitor');
first.render(page.default);
const returned = makeHarness(storage);
t.after(() => returned.cleanup());
assert.equal(nodes(returned.render(page.default), 'Tabs')[0].props.activeKey, 'competitor');
});
test("two independently retained tabs and seven relative publication windows", async () => {
const h = makeHarness();
const page = load();
const root = page.default();
const root = h.render(page.default);
const tabs = nodes(root, "Tabs")[0];
assert.deepEqual(
tabs.props.items.map((item) => [item.key, item.label]),
@@ -173,6 +226,8 @@ test("two independently retained tabs and seven relative publication windows", a
],
);
assert.equal(tabs.props.destroyOnHidden, false);
h.state.length = 0;
h.effects.length = 0;
const props = { sourceType: "owned" };
h.render(page.CommentAggregationTab, props);
await tick();
+41 -3
View File
@@ -4,9 +4,10 @@ const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
const ts = require('typescript');
const { loadPageState } = require('./helpers/page-state.cjs');
const filename = path.join(__dirname, '../src/pages/creator/competitors/index.tsx');
function harness({ sourceType = 'competitor', failure = false, response } = {}) {
function harness({ sourceType = 'competitor', failure = false, response, storage = new Map() } = {}) {
let cursor = 0;
const states = [];
const effects = [];
@@ -18,6 +19,7 @@ function harness({ sourceType = 'competitor', failure = false, response } = {})
createElement(type, props, ...children) { return typeof type === 'function' ? type({ ...props, children }) : { name: type, props: { ...props, children } }; },
useState(initial) { const index = cursor++; if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial; return [states[index], next => { states[index] = typeof next === 'function' ? next(states[index]) : next; }]; },
useMemo(fn) { return fn(); },
useCallback(callback) { return callback; },
useEffect(fn, deps) { const index = cursor++; if (!dependencies[index] || deps.some((value, i) => value !== dependencies[index][i])) { dependencies[index] = deps; effects.push(fn); } },
};
const DatePicker = component('DatePicker');
@@ -30,6 +32,8 @@ function harness({ sourceType = 'competitor', failure = false, response } = {})
antd.App = { useApp: () => ({ message }) };
const context = { exports: {}, console, React, require(name) {
if (name === 'react') return { ...React, default: React };
if (name === '@/hooks/usePageState') return loadPageState(React, { storage });
if (name === 'dayjs') return require('dayjs');
if (name === '@/utils/helpers') return { conflictMessage: error => error.message };
if (name === 'antd') return antd;
if (name === '@ant-design/pro-components') return { PageContainer: component('PageContainer') };
@@ -73,14 +77,14 @@ for (const sourceType of ['competitor', 'owned']) {
assert.equal('published_at_status' in nodes(tree, 'WorksGallery')[0].props.extraFilters, false);
control(tree, 'Select', '排序指标').props.onChange('likes_growth');
control(tree, 'Select', '排序方向').props.onChange('asc');
const dates = [{ startOf: () => ({ toISOString: () => '2026-01-01T00:00:00.000Z' }) }, { endOf: () => ({ toISOString: () => '2026-01-03T23:59:59.999Z' }) }];
const dates = [require('dayjs')('2026-01-01'), require('dayjs')('2026-01-03')];
control(tree, 'RangePicker', '发布时间').props.onChange(dates);
if (sourceType === 'owned') control(tree, 'InputNumber', '最低观看').props.onChange(0);
else assert.equal(control(tree, 'InputNumber', '最低观看'), undefined);
tree = app.render();
const gallery = nodes(tree, 'WorksGallery')[0];
assert.equal(gallery.props.sourceId, `${sourceType}-1`);
assert.deepEqual(plain(gallery.props.extraFilters), { growth_hours: 24, sort_by: 'likes_growth', sort_order: 'asc', published_after: '2026-01-01T00:00:00.000Z', published_before: '2026-01-03T23:59:59.999Z', min_likes: 0, min_comments: 2, min_shares: 3, min_collect_count: 4, min_likes_growth: 5, ...(sourceType === 'owned' ? { min_play_count: 0 } : {}) });
assert.deepEqual(plain(gallery.props.extraFilters), { growth_hours: 24, sort_by: 'likes_growth', sort_order: 'asc', published_after: dates[0].startOf('day').toISOString(), published_before: dates[1].endOf('day').toISOString(), min_likes: 0, min_comments: 2, min_shares: 3, min_collect_count: 4, min_likes_growth: 5, ...(sourceType === 'owned' ? { min_play_count: 0 } : {}) });
const sorts = control(tree, 'Select', '排序指标').props.options.map(option => option.value);
assert.equal(sorts.includes('play_count'), sourceType === 'owned');
assert(sorts.includes('collect_count'));
@@ -91,6 +95,40 @@ for (const sourceType of ['competitor', 'owned']) {
});
}
test('作品分析返回时恢复上次选中的 TAB', async () => {
const storage = new Map();
const first = harness({ storage });
nodes(first.render(false), 'Tabs')[0].props.onChange('owned');
first.render(false);
await first.flush();
assert.equal(nodes(harness({ storage }).render(false), 'Tabs')[0].props.activeKey, 'owned');
});
for (const sourceType of ['owned', 'competitor']) {
test(`${sourceType}: 作品分析返回时恢复账号、日期、指标和排序`, async () => {
const storage = new Map();
const first = harness({ sourceType, storage });
let tree = first.render();
control(tree, 'Select', '所属账号').props.onChange(`${sourceType}-1`);
control(tree, 'InputNumber', '最低点赞').props.onChange(123);
control(tree, 'Select', '排序指标').props.onChange('comments');
control(tree, 'Select', '排序方向').props.onChange('asc');
control(tree, 'RangePicker', '发布时间').props.onChange([require('dayjs')('2026-01-01'), require('dayjs')('2026-01-03')]);
first.render();
await first.flush();
const returned = harness({ sourceType, storage });
tree = returned.render();
assert.equal(nodes(tree, 'WorksGallery')[0].props.sourceId, `${sourceType}-1`);
assert.equal(control(tree, 'InputNumber', '最低点赞').props.value, 123);
assert.equal(control(tree, 'Select', '排序指标').props.value, 'comments');
assert.equal(control(tree, 'Select', '排序方向').props.value, 'asc');
assert.equal(control(tree, 'RangePicker', '发布时间').props.value[0].format('YYYY-MM-DD'), '2026-01-01');
const other = harness({ sourceType: sourceType === 'owned' ? 'competitor' : 'owned', storage }).render();
assert.equal(nodes(other, 'WorksGallery')[0].props.sourceId, undefined);
assert.equal(control(other, 'InputNumber', '最低点赞').props.value, undefined);
});
}
test('账号读取失败明确提示,支持重试,不显示为正常空列表', async () => {
const app = harness({ failure: true });
app.render(); await app.flush();
+31 -1
View File
@@ -4,6 +4,7 @@ const { readFileSync } = require('node:fs');
const { resolve } = require('node:path');
const Module = require('node:module');
const ts = require('typescript');
const { loadPageState } = require('./helpers/page-state.cjs');
const React = require('react');
function transpile(file) { return ts.transpileModule(readFileSync(file,'utf8'), {compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX,target:ts.ScriptTarget.ES2020}}).outputText; }
@@ -18,13 +19,14 @@ function harness(options={}) {
const file=resolve(__dirname,'../src/pages/creator/events/index.tsx');
const loaded=new Module(file,module);loaded.filename=file;
loaded.require=id=>{
if(id==='react')return{useState(initial){const index=cursor++;if(!(index in states))states[index]=initial;return[states[index],value=>{states[index]=typeof value==='function'?value(states[index]):value;}]},
if(id==='react')return{useMemo:factory=>factory(),useCallback:callback=>callback,useState(initial){const index=cursor++;if(!(index in states))states[index]=initial;return[states[index],value=>{states[index]=typeof value==='function'?value(states[index]):value;}]},
useEffect(callback,deps){const index=cursor++;const prior=effects[index];if(!prior||deps.some((v,i)=>!Object.is(v,prior.deps[i]))){if(prior?.cleanup)prior.cleanup();effects[index]={deps};queued.push(()=>{effects[index].cleanup=callback();});}}};
if(id==='react/jsx-runtime')return require(id);
if(id==='antd')return antd;
if(id==='@ant-design/icons')return{ReloadOutlined:()=>null,VideoCameraOutlined:()=>null};
if(id==='@/components/creator/WorksGallery')return{WorkCover:Object.assign(()=>null,{displayName:'WorkCover'})};
if(id==='@/utils/account-events')return utility.exports;
if(id==='@/hooks/usePageState')return loadPageState(loaded.require('react'),{storage:options.storage});
if(id==='@/utils/table')return{useTableSticky:()=>false,tablePagination:()=>({})};
if(id==='@/utils/helpers')return{dateTime:value=>`time:${value}`,conflictMessage:(cause,fallback)=>cause.message||fallback};
if(id==='@/services/api')return{creatorGet:async path=>{calls.push(path);if(options.get)return options.get(path);if(path==='/creator/accounts')return options.accounts||[];return options.page||{data:[],total:0};}};
@@ -38,6 +40,34 @@ function text(node){if(node==null)return'';if(typeof node==='string'||typeof nod
function select(h,tree,label){return all(tree,node=>node.type===h.antd.Select&&node.props['aria-label']===label)[0];}
function table(h,tree){return all(tree,node=>node.type===h.antd.Table)[0];}
test('event aggregation restores pagination and filters together after returning', async t => {
const storage = new Map();
const options = {storage, accounts:[{id:'on',name:'账号',listener_enabled:true}],page:{data:[],total:150}};
const first = harness(options); t.after(()=>first.dispose());
first.render(); await first.effects(); let tree = first.render();
select(first,tree,'所属账号').props.onChange('on');
select(first,tree,'事件类型').props.onChange('comment');
select(first,tree,'接收时间范围').props.onChange(6);
first.render(); await first.effects(); tree = first.render();
table(first,tree).props.pagination.onChange(1,50);
first.render(); await first.effects(); tree = first.render();
table(first,tree).props.pagination.onChange(3,50);
first.render(); await first.effects();
const returned = harness(options); t.after(()=>returned.dispose());
returned.render(); await returned.effects(); tree = returned.render();
assert.equal(table(returned,tree).props.pagination.current,3);
assert.equal(table(returned,tree).props.pagination.pageSize,50);
assert.equal(select(returned,tree,'所属账号').props.value,'on');
assert.equal(select(returned,tree,'事件类型').props.value,'comment');
assert.equal(select(returned,tree,'接收时间范围').props.value,6);
const query = new URLSearchParams(returned.calls.find(url=>url.startsWith('/creator/events')).split('?')[1]);
assert.equal(query.get('page'),'3'); assert.equal(query.get('page_size'),'50');
assert.equal(query.get('account_id'),'on'); assert.equal(query.get('event_type'),'comment');
select(returned,tree,'接收时间范围').props.onChange(12);
returned.render(); await returned.effects(); tree = returned.render();
assert.equal(table(returned,tree).props.pagination.current,1);
});
test('only enabled accounts are offered, event filters are server-wide and changes reset page',async t=>{
const h=harness({accounts:[{id:'on',name:'开启账号',platform_account_key:'1',listener_enabled:true},{id:'off',name:'关闭账号',listener_enabled:false}],page:{data:[{id:'event'}],total:40}});t.after(()=>h.dispose());
h.render();await h.effects();let tree=h.render();
+32
View File
@@ -0,0 +1,32 @@
const { readFileSync } = require('node:fs');
const { resolve } = require('node:path');
const vm = require('node:vm');
const ts = require('typescript');
function load(relativePath, mocks, globals = {}) {
const filename = resolve(__dirname, '../../src', relativePath);
const exports = {};
const code = ts.transpileModule(readFileSync(filename, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText;
vm.runInNewContext(code, {
exports, JSON, console, ...globals,
require(name) {
if (!(name in mocks)) throw new Error(`Unexpected page-state dependency: ${name}`);
return mocks[name];
},
}, { filename });
return exports;
}
function loadPageState(react, { storage = new Map(), pathname = () => '/test' } = {}) {
return load('hooks/usePageState.ts', { react, '@umijs/max': { useLocation: () => ({ pathname: pathname() }) } }, {
sessionStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) },
});
}
function loadTable(react, pageState) {
return load('utils/table.ts', { react, '@/hooks/usePageState': pageState, '@ant-design/pro-components': {} });
}
module.exports = { loadPageState, loadTable };
+59 -7
View File
@@ -4,11 +4,12 @@ const path = require('node:path');
const Module = require('node:module');
const test = require('node:test');
const ts = require('typescript');
const { loadPageState } = require('./helpers/page-state.cjs');
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') {
function harness(initialURL = '/accounts/monitoring', storage = new Map()) {
const entries = [initialURL];
let position = 0;
let pageActions = [];
@@ -23,11 +24,30 @@ function harness(initialURL = '/accounts/monitoring') {
back() { position -= 1; },
};
const element = (type, props, ...children) => ({ type, props: props || {}, children });
let slots = [], cursor = 0, activeComponent, dirty = false;
const effects = [];
const react = {
createElement: element,
Fragment: 'Fragment',
useState: (value) => [typeof value === 'function' ? value() : value, () => {}],
useEffect: () => {},
useMemo: factory => factory(),
useState(value) {
const index = cursor++, currentSlots = slots;
if (!(index in slots)) slots[index] = typeof value === 'function' ? value() : value;
return [slots[index], next => {
const value = typeof next === 'function' ? next(currentSlots[index]) : next;
if (!Object.is(value, currentSlots[index])) {
currentSlots[index] = value;
if (slots === currentSlots) dirty = true;
}
}];
},
useEffect(callback, deps) {
const index = cursor++;
if (!slots[index] || deps.some((value, i) => !Object.is(value, slots[index][i]))) {
slots[index] = deps;
effects.push(callback);
}
},
useCallback: (callback) => callback,
};
const components = new Proxy({
@@ -54,7 +74,8 @@ function harness(initialURL = '/accounts/monitoring') {
else history.push(url);
}],
},
'@/services/api': {},
'@/services/api': { getList: async () => [], getOne: async () => undefined, creatorGet: async () => ({}) },
'@/hooks/usePageState': loadPageState(react, { storage, pathname: () => location().pathname }),
'@/components/PageActions': { usePageActions: (actions) => { pageActions = actions; } },
'@/utils/helpers': { conflictMessage: () => '', dateTime: () => '' },
'@/utils/table': {
@@ -82,16 +103,27 @@ function harness(initialURL = '/accounts/monitoring') {
if (node.type === type) return node;
return node.children?.flat(Infinity).map((child) => find(child, type)).find(Boolean);
}
function render(Component) {
if (activeComponent !== Component) { slots = []; activeComponent = Component; }
for (let i = 0; i < 10; i++) {
cursor = 0;
dirty = false;
const tree = Component();
while (effects.length) effects.shift()();
if (!dirty) return tree;
}
throw new Error('Pagination render did not settle');
}
const List = load(listFile);
const Detail = load(detailFile);
return {
table: () => find(List(), 'Table').props,
table: () => find(render(List), 'Table').props,
enterDetail() {
const column = find(List(), 'Table').props.columns.find((column) => column.dataIndex === 'nickname');
const column = find(render(List), 'Table').props.columns.find((column) => column.dataIndex === 'nickname');
find(column.render('', { id: '42' }), 'Typography.Link').props.onClick();
},
returnFromDetail() {
Detail();
render(Detail);
const button = find(pageActions, 'Button');
button.props.onClick();
},
@@ -101,6 +133,26 @@ function harness(initialURL = '/accounts/monitoring') {
};
}
test('returning through the menu restores saved monitoring pagination without query parameters', () => {
const storage = new Map();
const h = harness('/accounts/monitoring', storage);
h.table().pagination.onChange(1, 50);
h.table().pagination.onChange(4, 50);
h.table();
const returned = harness('/accounts/monitoring', storage);
assert.equal(returned.table().pagination.current, 4);
assert.equal(returned.table().pagination.pageSize, 50);
});
test('an explicit monitoring URL overrides saved pagination and becomes the new saved position', () => {
const storage = new Map();
harness('/accounts/monitoring?page=4&pageSize=50', storage).table();
const explicit = harness('/accounts/monitoring?page=2&pageSize=20', storage);
assert.equal(explicit.table().pagination.current, 2);
assert.equal(explicit.table().pagination.pageSize, 20);
assert.equal(harness('/accounts/monitoring', storage).table().pagination.current, 2);
});
test('monitoring list starts at page one with the existing page size', () => {
const h = harness();
assert.equal(h.table().pagination.current, 1);
+157
View File
@@ -0,0 +1,157 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const Module = require('node:module');
const test = require('node:test');
const ts = require('typescript');
const { loadTable } = require('./helpers/page-state.cjs');
const filename = path.join(__dirname, '../src/hooks/usePageState.ts');
function harness(pathname = '/accounts', storage = new Map()) {
const slots = [];
let cursor = 0;
let currentPath = pathname;
const effects = [];
const useMemo = (factory, deps) => {
const index = cursor++;
if (!slots[index] || deps.some((value, i) => !Object.is(value, slots[index].deps[i]))) {
slots[index] = { value: factory(), deps };
}
return slots[index].value;
};
const react = {
useMemo,
useCallback: (callback, deps) => useMemo(() => callback, deps),
useState: (initial) => {
const index = cursor++;
if (!(index in slots)) slots[index] = typeof initial === 'function' ? initial() : initial;
return [slots[index], (next) => { slots[index] = typeof next === 'function' ? next(slots[index]) : next; }];
},
useEffect: (callback, deps) => {
const index = cursor++;
if (!slots[index] || deps.some((value, i) => !Object.is(value, slots[index].deps[i]))) {
slots[index] = { deps };
effects.push(callback);
}
},
};
const mocks = { react, '@umijs/max': { useLocation: () => ({ pathname: currentPath }) } };
const loaded = new Module(filename);
loaded.filename = filename;
loaded.require = (name) => {
assert.ok(name in mocks, `Unexpected import: ${name}`);
return mocks[name];
};
loaded._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText, filename);
const table = loadTable(react, loaded.exports);
return {
storage,
pathname(value) { currentPath = value; },
render(name = 'pagination', initial = { current: 1, pageSize: 20 }, isTable = false) {
global.sessionStorage = {
getItem: (key) => storage.get(key) ?? null,
setItem: (key, value) => storage.set(key, value),
};
cursor = 0;
const result = isTable ? table.useTablePagination() : loaded.exports.usePageState(name, initial);
while (effects.length) effects.shift()();
return result;
},
renderTablePagination() { return this.render(undefined, undefined, true); },
};
}
test.afterEach(() => { delete global.sessionStorage; });
for (const pathname of ['/accounts/managed', '/accounts/import', '/gateways', '/network-exits']) {
test(`${pathname}: pagination restores after navigation and page-size changes reset to one`, () => {
const h = harness(pathname);
h.renderTablePagination().onChange(3, 20);
h.renderTablePagination();
const restored = harness(pathname, h.storage);
assert.equal(restored.renderTablePagination().current, 3);
restored.renderTablePagination().onChange(3, 50);
assert.equal(restored.renderTablePagination().current, 1);
restored.renderTablePagination().onChange(2, 50);
restored.renderTablePagination();
const reloaded = harness(pathname, h.storage).renderTablePagination();
assert.equal(reloaded.current, 2);
assert.equal(reloaded.pageSize, 50);
assert.equal(harness(`${pathname}/edit`, h.storage).renderTablePagination().current, 1);
});
}
test('page state restores pagination after unmount and refresh', () => {
const first = harness();
const [, set] = first.render();
set({ current: 3, pageSize: 50 });
first.render();
assert.deepEqual(harness('/accounts', first.storage).render()[0], { current: 3, pageSize: 50 });
});
test('functional updates use the latest value and retain page size', () => {
const h = harness();
const [, set] = h.render();
set((value) => ({ ...value, current: value.current + 1 }));
set((value) => ({ ...value, current: value.current + 1 }));
assert.equal(h.render()[0].current, 3);
});
test('pages and browser tabs never share pagination', () => {
const h = harness('/accounts');
const [, set] = h.render();
set({ current: 4, pageSize: 50 });
h.render();
assert.equal(harness('/gateways', h.storage).render()[0].current, 1);
assert.equal(harness('/accounts').render()[0].current, 1);
});
test('switching account routes restores the correct state on the first render', () => {
const h = harness('/accounts/one');
const [, set] = h.render();
set({ current: 3, pageSize: 20 });
h.render();
h.pathname('/accounts/two');
const [second, setSecond] = h.render();
assert.equal(second.current, 1);
setSecond((value) => ({ ...value, current: 2 }));
h.render();
h.pathname('/accounts/one');
assert.equal(h.render()[0].current, 3);
});
test('owned and competitor tabs independently retain filters and pagination', () => {
const h = harness('/creator/comments');
const [, set] = h.render('owned', { hours: 24, page: 1 });
set({ hours: 6, page: 3 });
h.render('owned', { hours: 24, page: 1 });
const [other, setOther] = h.render('competitor', { hours: 24, page: 1 });
assert.deepEqual(other, { hours: 24, page: 1 });
setOther({ hours: 12, page: 2 });
h.render('competitor', { hours: 24, page: 1 });
assert.deepEqual(h.render('owned', { hours: 24, page: 1 })[0], { hours: 6, page: 3 });
});
test('zero, false, empty filters, and date strings restore without being replaced by defaults', () => {
for (const value of [0, false, {}, { dates: ['2026-07-01', '2026-07-03'] }]) {
const h = harness();
const [, set] = h.render('filters', { hours: 24 });
set(value);
h.render('filters', { hours: 24 });
assert.deepEqual(harness('/accounts', h.storage).render('filters', { hours: 24 })[0], value);
}
});
test('corrupt storage and storage write errors are not silently ignored', () => {
const h = harness();
h.render();
const key = [...h.storage.keys()][0];
h.storage.set(key, '{invalid');
assert.throws(() => harness('/accounts', h.storage).render(), SyntaxError);
const failing = new Map();
failing.set = () => { throw new Error('storage unavailable'); };
assert.throws(() => harness('/accounts', failing).render(), /storage unavailable/);
});
+3 -1
View File
@@ -40,7 +40,8 @@ for (const [file, width] of files) {
assert.ok(pagination?.initializer && ts.isJsxExpression(pagination.initializer));
const config = pagination.initializer.expression;
assert.ok(
(ts.isCallExpression(config) && config.expression.getText(source) === 'tablePagination')
(ts.isIdentifier(config) && code.includes(`const ${config.text} = useTablePagination()`))
|| (ts.isCallExpression(config) && config.expression.getText(source) === 'tablePagination')
|| (ts.isObjectLiteralExpression(config) && config.properties.some(prop =>
ts.isSpreadAssignment(prop) && prop.expression.getText(source) === 'tablePagination()',
)),
@@ -84,6 +85,7 @@ let layout = {}, token = {};
vm.runInNewContext(tableCode, { exports: tableModule.exports, require: id => {
if (id === '@ant-design/pro-components') return { RouteContext: routeContext, ProProvider: proProvider };
if (id === 'react') return { useContext: context => context === routeContext ? layout : { token } };
if (id === '@/hooks/usePageState') return {};
throw new Error(`Unexpected import: ${id}`);
} }, { filename: tableFile });
const helpers = tableModule.exports;