diff --git a/AGENTS.md b/AGENTS.md index 96e28ed..76691f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 结果为准。 diff --git a/web/src/components/accounts/AccountManagementList.tsx b/web/src/components/accounts/AccountManagementList.tsx index 22c22d0..0ec32cd 100644 --- a/web/src/components/accounts/AccountManagementList.tsx +++ b/web/src/components/accounts/AccountManagementList.tsx @@ -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(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: '暂无账号或待登录环境。' }} /> diff --git a/web/src/components/accounts/MonitoringAccountList.tsx b/web/src/components/accounts/MonitoringAccountList.tsx index a9d589e..51b7c9a 100644 --- a/web/src/components/accounts/MonitoringAccountList.tsx +++ b/web/src/components/accounts/MonitoringAccountList.tsx @@ -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([]); const [pending, setPending] = useState(true); const [error, setError] = useState(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 }); }, }} diff --git a/web/src/components/creator/WorksGallery.tsx b/web/src/components/creator/WorksGallery.tsx index 57a3c3e..bd35825 100644 --- a/web/src/components/creator/WorksGallery.tsx +++ b/web/src/components/creator/WorksGallery.tsx @@ -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([]); - 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(null); const [progressError, setProgressError] = useState(null); const [worksPending, setWorksPending] = useState(true); const [worksError, setWorksError] = useState(null); - const [minGrowth, setMinGrowth] = useState(null); + const [minGrowth, setMinGrowth] = usePageState(`${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(null); const [workMetrics, setWorkMetrics] = useState([]); diff --git a/web/src/hooks/usePageState.ts b/web/src/hooks/usePageState.ts new file mode 100644 index 0000000..ba05575 --- /dev/null +++ b/web/src/hooks/usePageState.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; +import { useLocation } from '@umijs/max'; + +/** 当前标签页中的列表状态;值必须能够用 JSON 保存。 */ +export function usePageState(name: string, defaultValue: T): [T, Dispatch>] { + 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) => { + 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]; +} diff --git a/web/src/pages/accounts/import.tsx b/web/src/pages/accounts/import.tsx index 906eb63..21690e1 100644 --- a/web/src/pages/accounts/import.tsx +++ b/web/src/pages/accounts/import.tsx @@ -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 ? ( 重试} /> ) : null} - +
diff --git a/web/src/pages/creator/comments/index.tsx b/web/src/pages/creator/comments/index.tsx index 4ae0cda..6a59d11 100644 --- a/web/src/pages/creator/comments/index.tsx +++ b/web/src/pages/creator/comments/index.tsx @@ -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([]); 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 ( ({}); + const [filters, setFilters] = usePageState(`${sourceType}-filters`, {}); const [accounts, setAccounts] = useState([]); const [accountsPending, setAccountsPending] = useState(true); const [accountsError, setAccountsError] = useState(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 }) { 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 ( = [ ]; export default function EventAggregationPage() { - const [filters, setFilters] = useState({ hours: 0 }); - const [pagination, setPagination] = useState({ page: 1, pageSize: 25 }); + const [filters, setFilters] = usePageState('filters', { hours: 0 }); + const [pagination, setPagination] = usePageState('pagination', { page: 1, pageSize: 25 }); const [events, setEvents] = useState([]); const [accounts, setAccounts] = useState([]); const [total, setTotal] = useState(0); diff --git a/web/src/pages/gateways/index.tsx b/web/src/pages/gateways/index.tsx index 096a64d..2c635df 100644 --- a/web/src/pages/gateways/index.tsx +++ b/web/src/pages/gateways/index.tsx @@ -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>(undefined); const load = useCallback(async () => { @@ -235,7 +236,7 @@ export default function Page() { {error ? ( 重试} /> ) : null} -
+
= { 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: '暂无网络出口。创建并检测健康后,才能绑定运行环境。' }} /> diff --git a/web/src/utils/table.ts b/web/src/utils/table.ts index 1a8be3c..ffc76ad 100644 --- a/web/src/utils/table.ts +++ b/web/src/utils/table.ts @@ -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(column: ColumnType): ColumnType { return { ...column, fixed: 'left' as const }; } diff --git a/web/tests/account-detail-resources.test.cjs b/web/tests/account-detail-resources.test.cjs index f0061f6..d59738d 100644 --- a/web/tests/account-detail-resources.test.cjs +++ b/web/tests/account-detail-resources.test.cjs @@ -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 } }, diff --git a/web/tests/account-management-unified-list.test.cjs b/web/tests/account-management-unified-list.test.cjs index ee51177..e49552f 100644 --- a/web/tests/account-management-unified-list.test.cjs +++ b/web/tests/account-management-unified-list.test.cjs @@ -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); diff --git a/web/tests/comment-aggregation.test.cjs b/web/tests/comment-aggregation.test.cjs index cb2a2d1..0fc0d96 100644 --- a/web/tests/comment-aggregation.test.cjs +++ b/web/tests/comment-aggregation.test.cjs @@ -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(); diff --git a/web/tests/competitor-analysis-source.test.cjs b/web/tests/competitor-analysis-source.test.cjs index c77d647..45e662f 100644 --- a/web/tests/competitor-analysis-source.test.cjs +++ b/web/tests/competitor-analysis-source.test.cjs @@ -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(); diff --git a/web/tests/event-aggregation-page.test.cjs b/web/tests/event-aggregation-page.test.cjs index 78e7d83..b3af4e9 100644 --- a/web/tests/event-aggregation-page.test.cjs +++ b/web/tests/event-aggregation-page.test.cjs @@ -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(); diff --git a/web/tests/helpers/page-state.cjs b/web/tests/helpers/page-state.cjs new file mode 100644 index 0000000..28728f3 --- /dev/null +++ b/web/tests/helpers/page-state.cjs @@ -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 }; diff --git a/web/tests/monitoring-pagination.test.cjs b/web/tests/monitoring-pagination.test.cjs index 722032b..5628e31 100644 --- a/web/tests/monitoring-pagination.test.cjs +++ b/web/tests/monitoring-pagination.test.cjs @@ -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); diff --git a/web/tests/page-state.test.cjs b/web/tests/page-state.test.cjs new file mode 100644 index 0000000..5417e68 --- /dev/null +++ b/web/tests/page-state.test.cjs @@ -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/); +}); diff --git a/web/tests/table-fixed-header.test.cjs b/web/tests/table-fixed-header.test.cjs index 998077e..6aeab06 100644 --- a/web/tests/table-fixed-header.test.cjs +++ b/web/tests/table-fixed-header.test.cjs @@ -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;