@@ -275,7 +333,7 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
onClick={() => openWorkDetail(work)}
cover={}
>
-
+
{/* 无标题作品不渲染标题行,避免占位文案增加卡片噪音。 */}
@@ -294,9 +352,17 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
- {work.likes_growth != null && work.likes_growth > 0 ? (
-
- 24h +{work.likes_growth}
+ {work.likes_growth != null || extraFilters?.growth_hours ? (
+
+ 24h 点赞增量 {work.likes_growth == null ? '—' : `+${work.likes_growth}`}
+ {work.likes_growth_coverage === 'partial' ? '(样本不足)' : ''}
) : null}
@@ -324,17 +390,24 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
setDetailWork(null)} size="large" title="作品统计">
{detailWork ? (
- ID:{detailWork.work_key || detailWork.id}
+
+ ID:{detailWork.work_key || detailWork.id}
+
{detailWork.title ? (
- {detailWork.title}
+
+ {detailWork.title}
+
) : null}
-
- {detailWork.body || '暂无正文'}
-
+ {detailWork.body || '暂无正文'}
- 发布:{detailWork.published_at ? dateTime(detailWork.published_at) : '—'}
+
+ 发布:
+ {detailWork.published_at ? dateTime(detailWork.published_at) : '—'}
+
{detailWork.original_url ? (
- 打开原文
+
+ 打开原文
+
) : null}
{workMetrics.length >= 2 ? : null}
@@ -345,16 +418,34 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
pagination={false}
dataSource={workMetrics}
columns={[
- { title: '采集时间', dataIndex: 'collected_at', render: (value: string) => dateTime(value) },
- { title: '播放', dataIndex: 'play_count', render: (v) => v ?? '—' },
+ {
+ title: '采集时间',
+ dataIndex: 'collected_at',
+ render: (value: string) => dateTime(value),
+ },
+ {
+ title: '播放',
+ dataIndex: 'play_count',
+ render: (v) => v ?? '—',
+ },
{ title: '点赞', dataIndex: 'likes', render: (v) => v ?? '—' },
- { title: '收藏', dataIndex: 'collect_count', render: (v) => v ?? '—' },
- { title: '评论', dataIndex: 'comments_count', render: (v) => v ?? '—' },
+ {
+ title: '收藏',
+ dataIndex: 'collect_count',
+ render: (v) => v ?? '—',
+ },
+ {
+ title: '评论',
+ dataIndex: 'comments_count',
+ render: (v) => v ?? '—',
+ },
{ title: '转发', dataIndex: 'shares', render: (v) => v ?? '—' },
]}
locale={{ emptyText: '暂无指标记录。' }}
/>
- 评论
+
+ 评论
+
({});
+ const [accounts, setAccounts] = useState([]);
+ const [accountsPending, setAccountsPending] = useState(true);
+ const [accountsError, setAccountsError] = useState(null);
+ const [retry, setRetry] = useState(0);
+ const { message } = App.useApp();
+ useEffect(() => {
+ let active = true;
+ setAccountsPending(true);
+ setAccountsError(null);
+ const endpoint = sourceType === 'owned' ? '/creator/accounts' : '/creator/competitors';
+ creatorGet(endpoint)
+ .then((result: AccountOption[]) => {
+ if (active) setAccounts(result);
+ })
+ .catch((error) => {
+ console.error('读取作品分析账号失败', { sourceType, endpoint, error });
+ if (active) {
+ setAccountsError(error);
+ message.error(conflictMessage(error, '账号读取失败'));
+ }
+ })
+ .finally(() => {
+ if (active) setAccountsPending(false);
+ });
+ return () => {
+ active = false;
+ };
+ }, [sourceType, retry, message]);
- // 序列化为后端 workFilter 的 query 参数;日期取本地日历日的 00:00/23:59(UTC 边界)。
const extraFilters = useMemo(
() => ({
+ 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_at_status: filters.publishedAtStatus,
min_likes: filters.minLikes,
min_comments: filters.minComments,
min_shares: filters.minShares,
- published_at_status: filters.publishedAtStatus,
- published_after: filters.publishedAfter ? `${filters.publishedAfter.format('YYYY-MM-DD')}T00:00:00Z` : undefined,
- published_before: filters.publishedBefore ? `${filters.publishedBefore.format('YYYY-MM-DD')}T23:59:59Z` : undefined,
+ min_collect_count: filters.minCollectCount,
+ min_play_count: sourceType === 'owned' ? filters.minPlayCount : undefined,
+ min_likes_growth: filters.minLikesGrowth,
}),
- [filters],
+ [filters, sourceType],
);
+ const metrics = sourceType === 'owned' ? [...metricFilters, { key: 'minPlayCount' as const, label: '最低观看' }] : metricFilters;
+ const sortOptions = sourceType === 'owned' ? [...commonSortOptions, { value: 'play_count', label: '观看' }] : commonSortOptions;
return (
- 调整条件后自动刷新}>
-
- setFilters((f) => ({ ...f, minLikes: value ?? undefined }))} />
- setFilters((f) => ({ ...f, minComments: value ?? undefined }))} />
- setFilters((f) => ({ ...f, minShares: value ?? undefined }))} />
-
+ setFilters({})}>重置}>
+
+
+
+
+
+ setFilters((current) => ({
+ ...current,
+ dates: dates?.[0] && dates[1] ? [dates[0], dates[1]] : undefined,
+ }))
+ }
+ />
+
+
+
+
+
+
+ setFilters((current) => ({
+ ...current,
+ [key]: value ?? undefined,
+ }))
+ }
+ />
+
+ ))}
+
+
+
+
+
+ 调整条件后自动刷新;缺失指标显示“—”,不计为 0。
+ {accountsError ? (
+ setRetry((value) => value + 1)}>
+ 重试
+
+ }
+ />
+ ) : null}
+
-
+ {sourceType === 'owned' ? (
+ 仅展示已采集作品,未采集作品不参与筛选和排序;观看量未返回时显示“—”。
+ ) : null}
+
);
}
+
+export default function Page() {
+ return (
+ ,
+ },
+ {
+ key: 'owned',
+ label: '我的账号',
+ children: ,
+ },
+ ]}
+ />
+ );
+}
diff --git a/web/src/services/api.ts b/web/src/services/api.ts
index 24242d4..f57e7ad 100644
--- a/web/src/services/api.ts
+++ b/web/src/services/api.ts
@@ -21,7 +21,7 @@ export type Resource = keyof typeof resourcePaths;
const filterKeys: Partial> = {
'creator-competitors': ['platform'],
'creator-competitor-share-jobs': ['platform', 'status'],
- 'creator-works': ['platform', 'source_id', 'source_type', 'published_at_status', 'published_after', 'published_before', 'min_likes', 'min_comments', 'min_shares', 'growth_hours', 'min_likes_growth'],
+ 'creator-works': ['platform', 'source_id', 'source_type', 'published_at_status', 'published_after', 'published_before', 'min_likes', 'min_comments', 'min_shares', 'min_collect_count', 'min_play_count', 'sort_by', 'sort_order', 'growth_hours', 'min_likes_growth'],
'creator-comments': ['platform', 'work_id'],
'creator-leads': ['platform'],
};
diff --git a/web/src/utils/metadata.tsx b/web/src/utils/metadata.tsx
index 451b71d..4e58b91 100644
--- a/web/src/utils/metadata.tsx
+++ b/web/src/utils/metadata.tsx
@@ -21,7 +21,7 @@ const rules: Array<{ test: (path: string) => boolean; title: string; subtitle: s
{ test: (p) => /^\/accounts\/monitoring\/.+/.test(p), title: '监控账号详情', subtitle: '查看监控账号画像与作品列表,点击作品可追溯指标统计。' },
{ test: (p) => /\/edit$/.test(p) && p.startsWith('/accounts/'), title: '编辑社媒账号', subtitle: '维护账号资料、登录核验与自动响应策略。' },
{ test: (p) => p.startsWith('/accounts/'), title: '账号详情', subtitle: '查看账号状态、登录身份与运行环境绑定。' },
- { test: (p) => p === '/creator/competitors', title: '竞品分析', subtitle: '查看竞品作品与指标,分享链接导入请前往账号导入。' },
+ { test: (p) => p === '/creator/competitors', title: '作品分析', subtitle: '分别查看竞品与自有账号作品,按账号、发布时间和表现筛选排序。' },
{ test: (p) => p === '/creator/settings', title: '采集设置', subtitle: '统一配置采集窗口、指标采集和已批准的服务。' },
{ test: (p) => p.startsWith('/creator/competitors/'), title: '竞品账号详情', subtitle: '查看竞品画像与作品列表,点击作品可追溯指标统计。' },
{ test: (p) => p === '/network-exits', title: '网络出口', subtitle: '维护固定出口、健康观测与账号绑定。' },
@@ -39,7 +39,7 @@ export const menu: MenuDataItem[] = [
{ path: '/accounts/import', name: '账号导入', icon: },
{ path: '/accounts', name: '我的账号', icon: },
{ path: '/accounts/monitoring', name: '监控账号', icon: },
- { path: '/creator/competitors', name: '竞品分析', icon: },
+ { path: '/creator/competitors', name: '作品分析', icon: },
{ path: '/creator/settings', name: '采集设置', icon: },
],
},
diff --git a/web/tests/account-detail-resources.test.cjs b/web/tests/account-detail-resources.test.cjs
index 90008d2..22778d6 100644
--- a/web/tests/account-detail-resources.test.cjs
+++ b/web/tests/account-detail-resources.test.cjs
@@ -152,6 +152,68 @@ const text = (tree) => {
if (Array.isArray(tree)) return tree.map(text).join('');
return typeof tree === 'object' ? text(tree.props?.children) : String(tree);
};
+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 } },
+ });
+ let props = { sourceType: 'competitor', showGrowthFilter: false, extraFilters: { sort_by: 'likes', sort_order: 'desc', growth_hours: 24 } };
+ let tree = await page.mount(props);
+ assert.equal(byType(tree, 'InputNumber').length, 0);
+ byType(tree, 'Pagination')[0].props.onChange(2);
+ page.render(); await flush();
+ assert.equal(page.calls.lists.at(-1).page, 2);
+ for (const changes of [{ sourceId: 'competitor-2' }, { extraFilters: { sort_by: 'likes', sort_order: 'asc', growth_hours: 24 } }, { extraFilters: { min_likes: 0, sort_by: 'likes', sort_order: 'asc', growth_hours: 24 } }]) {
+ props = { ...props, ...changes };
+ tree = page.render(props); await flush();
+ assert.equal(page.calls.lists.at(-1).page, 1);
+ assert.equal(byType(tree, 'Pagination')[0].props.current, 1);
+ byType(tree, 'Pagination')[0].props.onChange(2);
+ page.render(); await flush();
+ assert.equal(page.calls.lists.at(-1).page, 2);
+ }
+ page.unmount();
+});
+
+test('stale responses from a previous sort cannot replace current works', async () => {
+ const pending = [];
+ const page = harness('src/components/creator/WorksGallery.tsx', { services: {
+ getList: options => new Promise(resolve => pending.push({ options, resolve })),
+ } });
+ page.render({ sourceType: 'competitor', extraFilters: { sort_by: 'likes', sort_order: 'desc' } });
+ page.render({ sourceType: 'competitor', extraFilters: { sort_by: 'likes', sort_order: 'asc' } });
+ pending[1].resolve({ data: [{ id: 'latest', title: '正确排序的作品' }], total: 1 });
+ await flush();
+ pending[0].resolve({ data: [{ id: 'stale', title: '过期排序的作品' }], total: 1 });
+ await flush();
+ const tree = page.render();
+ assert.match(text(tree), /正确排序的作品/);
+ assert.doesNotMatch(text(tree), /过期排序的作品/);
+ page.unmount();
+});
+
+for (const showViews of [true, false]) {
+ test(`work metric grid views=${showViews} keeps missing values distinct from zero`, () => {
+ const grid = harness('src/components/creator/WorksGallery.tsx', {}, 'WorkStatsGrid');
+ const tree = grid.render({ work: { likes: 0, play_count: null, collect_count: 2 }, showViews });
+ assert.equal(text(tree).includes('观看'), showViews);
+ assert.match(text(tree), /0\s*点赞/);
+ assert.match(text(tree), /2\s*收藏/);
+ assert.match(text(tree), /—\s*评论/);
+ grid.unmount();
+ });
+}
+
+test('analysis cards display zero and unknown growth, and identify partial samples', async () => {
+ const page = harness('src/components/creator/WorksGallery.tsx', { workPages: { 1: { data: [
+ { id: 'zero', likes_growth: 0, likes_growth_coverage: 'partial' },
+ { id: 'unknown', likes_growth: null },
+ ], total: 2 } } });
+ const tree = await page.mount({ sourceType: 'competitor', showGrowthFilter: false, extraFilters: { growth_hours: 24 } });
+ assert.match(text(tree), /24h 点赞增量\s*\+0\s*(样本不足)/);
+ assert.match(text(tree), /24h 点赞增量\s*—/);
+ page.unmount();
+});
+
const detailPath = 'src/pages/accounts/$id/index.tsx';
const editPath = 'src/pages/accounts/$id/edit.tsx';
const rebindPath = 'src/components/accounts/RebindEnvironment.tsx';
@@ -311,7 +373,9 @@ test('读取作品进度失败明确提示,已采集作品仍然展示,刷
assert.match(text(tree), /采集进度暂不可用/);
api.collectionStatusError = null;
api.collectionStatus = { work_count: 1, aweme_count: 1, works: { status: 'succeeded' } };
- await findAll(tree, (node) => node.type === 'Button' && node.props['aria-label'] === '刷新作品')[0].props.onClick();
+ findAll(tree, (node) => node.type === 'Button' && node.props['aria-label'] === '刷新作品')[0].props.onClick();
+ page.render();
+ await flush();
tree = page.render();
assert.equal(byType(tree, 'Alert').length, 0);
assert.match(text(tree), /已采集 1 \/ 总计 1/);
@@ -333,12 +397,16 @@ test('作品列表读取失败明确提示并可重试,不影响独立采集
assert.equal(byType(tree, 'Alert')[0].props.title, '作品读取失败');
assert.match(text(tree), /正在采集/);
api.workListError = null;
- await byType(tree, 'Alert')[0].props.action.props.onClick();
+ byType(tree, 'Alert')[0].props.action.props.onClick();
+ page.render();
+ await flush();
tree = page.render();
assert.equal(byType(tree, 'Alert').length, 0);
for (const status of ['failed', 'blocked']) {
api.collectionStatus.works = { status, last_error: '浏览器未登录' };
- await findAll(tree, (node) => node.type === 'Button' && node.props['aria-label'] === '刷新作品')[0].props.onClick();
+ findAll(tree, (node) => node.type === 'Button' && node.props['aria-label'] === '刷新作品')[0].props.onClick();
+ page.render();
+ await flush();
tree = page.render();
assert.match(text(tree), /采集未完成:浏览器未登录/);
}
diff --git a/web/tests/competitor-analysis-source.test.cjs b/web/tests/competitor-analysis-source.test.cjs
index fbcd78a..d9b5fe8 100644
--- a/web/tests/competitor-analysis-source.test.cjs
+++ b/web/tests/competitor-analysis-source.test.cjs
@@ -1,87 +1,115 @@
-const test = require('node:test');
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 vm = require('node:vm');
const ts = require('typescript');
+const filename = path.join(__dirname, '../src/pages/creator/competitors/index.tsx');
-const pagePath = path.join(__dirname, '../src/pages/creator/competitors/index.tsx');
-
-function createPage() {
- let filters = {};
- const gallery = 'WorksGallery';
- const element = (type, props) => ({ type, props });
- const mocks = {
- react: {
- useMemo: (factory) => factory(),
- useState: () => [filters, (update) => { filters = update(filters); }],
- },
- 'react/jsx-runtime': { jsx: element, jsxs: element },
- antd: {
- Card: 'Card', DatePicker: 'DatePicker', Flex: 'Flex', InputNumber: 'InputNumber',
- Select: 'Select', Space: 'Space', Typography: { Text: 'Text' },
- },
- '@/components/creator/WorksGallery': { __esModule: true, default: gallery },
+function harness({ sourceType = 'competitor', failure = false, response } = {}) {
+ let cursor = 0;
+ const states = [];
+ const effects = [];
+ const dependencies = [];
+ const requests = [];
+ const errors = [];
+ const component = name => Object.assign(props => ({ name, props }), { displayName: name });
+ const React = {
+ 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(); },
+ useEffect(fn, deps) { const index = cursor++; if (!dependencies[index] || deps.some((value, i) => value !== dependencies[index][i])) { dependencies[index] = deps; effects.push(fn); } },
};
- const compiled = ts.transpileModule(fs.readFileSync(pagePath, 'utf8'), {
- fileName: pagePath,
- compilerOptions: {
- module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true,
- inlineSourceMap: true, inlineSources: true,
- },
- }).outputText;
- const loaded = new Module(pagePath, module);
- loaded.filename = pagePath;
- loaded.paths = Module._nodeModulePaths(path.dirname(pagePath));
- loaded.require = (name) => Object.hasOwn(mocks, name) ? mocks[name] : module.require(name);
- loaded._compile(compiled, pagePath);
-
- function descendants(node) {
- if (!node || typeof node !== 'object') return [];
- return [node, ...[node.props?.children].flat(Infinity).flatMap(descendants)];
- }
- return () => {
- const nodes = descendants(loaded.exports.default());
- return {
- gallery: nodes.find((node) => node.type === gallery).props,
- controls: nodes.filter((node) => node.props.onChange),
- };
- };
-}
-
-function assertCompetitorOnly(props) {
- assert.equal(props.sourceType, 'competitor', '竞品分析必须排除自有账号作品');
- assert.equal(props.sourceId, undefined, '竞品分析应包含所有监测账号,而非限定某个账号');
-}
-
-test('竞品分析首次加载只查询监测账号作品', () => {
- const render = createPage();
- assertCompetitorOnly(render().gallery);
-});
-
-test('调整及清空各项筛选后仍只查询监测账号作品', () => {
- const render = createPage();
- const date = { format: (pattern) => {
- assert.equal(pattern, 'YYYY-MM-DD');
- return '2026-05-18';
+ const DatePicker = component('DatePicker');
+ DatePicker.RangePicker = component('RangePicker');
+ const antd = Object.fromEntries(['Card', 'Flex', 'Form', 'InputNumber', 'Select', 'Tabs', 'Button', 'Alert'].map(name => [name, component(name)]));
+ antd.DatePicker = DatePicker;
+ antd.Form.Item = component('Form.Item');
+ antd.Typography = { Text: component('Text') };
+ const message = { error: value => errors.push(value) };
+ antd.App = { useApp: () => ({ message }) };
+ const context = { exports: {}, console, React, require(name) {
+ if (name === 'react') return { ...React, default: React };
+ if (name === '@/utils/helpers') return { conflictMessage: error => error.message };
+ if (name === 'antd') return antd;
+ if (name === '@ant-design/pro-components') return { PageContainer: component('PageContainer') };
+ if (name === '@/components/creator/WorksGallery') return { __esModule: true, default: component('WorksGallery') };
+ if (name === '@/services/api') return { creatorGet: async endpoint => { requests.push(endpoint); if (failure) throw new Error('账号读取失败'); return response ?? (endpoint.endsWith('/accounts') ? [{ id: 'owned-1', name: '我的账号', platform_account_key: 'uid-1' }] : [{ id: 'competitor-1', nickname: '竞品账号', platform_account_key: 'uid-2' }]); } };
+ throw new Error(`Unexpected dependency: ${name}`);
} };
- const cases = [
- ['最低点赞', 'min_likes', 12, 12],
- ['最低评论', 'min_comments', 3, 3],
- ['最低转发', 'min_shares', 5, 5],
- ['全部状态', 'published_at_status', 'verified', 'verified'],
- ['发布开始日期', 'published_after', date, '2026-05-18T00:00:00Z'],
- ['发布结束日期', 'published_before', date, '2026-05-18T23:59:59Z'],
- ];
- for (const [placeholder, key, value, expected] of cases) {
- render().controls.find((control) => control.props.placeholder === placeholder).props.onChange(value);
- assertCompetitorOnly(render().gallery);
- assert.equal(render().gallery.extraFilters[key], expected);
- }
- for (const [placeholder, key] of cases) {
- const clearValue = key === 'published_at_status' ? undefined : null;
- render().controls.find((control) => control.props.placeholder === placeholder).props.onChange(clearValue);
- assertCompetitorOnly(render().gallery);
- assert.equal(render().gallery.extraFilters[key], undefined);
- }
+ const compiled = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { compilerOptions: { jsx: ts.JsxEmit.React, module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true }, fileName: filename }).outputText;
+ vm.runInNewContext(compiled, context, { filename });
+ return { requests, errors, render(panel = true) { cursor = 0; return panel ? context.exports.WorkAnalysisTab({ sourceType }) : context.exports.default(); }, async flush() { const cleanups = effects.splice(0).map(fn => fn()); await new Promise(resolve => setImmediate(resolve)); return cleanups; } };
+}
+function nodes(tree, name) {
+ if (!tree || typeof tree !== 'object') return [];
+ if (Array.isArray(tree)) return tree.flatMap(child => nodes(child, name));
+ return [...(tree.name === name ? [tree] : []), ...nodes(tree.props?.children, name), ...nodes(tree.props?.extra, name)];
+}
+function control(tree, name, label) { return nodes(tree, name).find(node => node.props['aria-label'] === label); }
+function plain(value) { return JSON.parse(JSON.stringify(value)); }
+
+test('作品分析只有两个分开挂载的来源 TAB', () => {
+ const page = harness().render(false);
+ const tabs = nodes(page, 'Tabs')[0];
+ assert.deepEqual(plain(tabs.props.items.map(({ key, label }) => ({ key, label }))), [{ key: 'competitor', label: '竞品账号' }, { key: 'owned', label: '我的账号' }]);
+ assert.equal(tabs.props.destroyOnHidden, false);
+});
+
+for (const sourceType of ['competitor', 'owned']) {
+ test(`${sourceType}: 筛选、排序、账号、重置与指标范围`, async () => {
+ const app = harness({ sourceType });
+ let tree = app.render();
+ assert.equal(nodes(tree, 'WorksGallery')[0].props.sourceType, sourceType);
+ assert.deepEqual(plain(nodes(tree, 'WorksGallery')[0].props.extraFilters), { growth_hours: 24, sort_by: 'published_at', sort_order: 'desc' });
+ await app.flush();
+ tree = app.render();
+ assert.deepEqual(app.requests, [sourceType === 'owned' ? '/creator/accounts' : '/creator/competitors']);
+ const account = control(tree, 'Select', '所属账号');
+ assert.equal(account.props.options[0].value, `${sourceType}-1`);
+ account.props.onChange(`${sourceType}-1`);
+ for (const [label, value] of [['最低点赞', 0], ['最低评论', 2], ['最低分享', 3], ['最低收藏', 4], ['最低24h点赞增量', 5]]) control(tree, 'InputNumber', label).props.onChange(value);
+ control(tree, 'Select', '时间核验状态').props.onChange('verified');
+ 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' }) }];
+ 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', published_at_status: 'verified', 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'));
+ nodes(tree, 'Button').find(node => node.props.children.includes('重置')).props.onClick();
+ tree = app.render();
+ assert.equal(nodes(tree, 'WorksGallery')[0].props.sourceId, undefined);
+ assert.deepEqual(plain(nodes(tree, 'WorksGallery')[0].props.extraFilters), { growth_hours: 24, sort_by: 'published_at', sort_order: 'desc' });
+ });
+}
+
+test('账号读取失败明确提示,支持重试,不显示为正常空列表', async () => {
+ const app = harness({ failure: true });
+ app.render(); await app.flush();
+ const tree = app.render();
+ assert.deepEqual(app.errors, ['账号读取失败']);
+ const alert = nodes(tree, 'Alert')[0];
+ assert.equal(alert.props.type, 'error');
+ nodes(alert.props.action, 'Button')[0].props.onClick();
+ app.render(); await app.flush();
+ assert.equal(app.requests.length, 2);
+});
+
+test('TAB 卸载后忽略账号请求结果', async () => {
+ let resolve;
+ const app = harness({ response: new Promise(done => { resolve = done; }) });
+ app.render();
+ const cleanups = await app.flush();
+ cleanups.forEach(cleanup => cleanup?.());
+ resolve([{ id: 'late', nickname: '卸载后返回的账号', platform_account_key: 'late' }]);
+ await app.flush();
+ assert.equal(control(app.render(), 'Select', '所属账号').props.options.length, 0);
+ assert.equal(app.errors.length, 0);
});