feat: unify account environments and work collection

This commit is contained in:
2026-10-06 13:00:29 +08:00
parent b72727451d
commit 950b23da32
22 changed files with 935 additions and 93 deletions
+171 -7
View File
@@ -10,13 +10,13 @@ const element = (type, props = {}) => ({ type, props });
const flush = () => new Promise((resolve) => setImmediate(resolve));
// Execute the real page/component with isolated hooks and API calls, without a browser.
function harness(relativePath, api = {}) {
function harness(relativePath, api = {}, componentName = 'default') {
let cursor = 0;
let props = {};
const slots = [];
const effects = [];
const forms = [];
const calls = { success: [], error: [], requests: [], updates: [], navigation: [] };
const calls = { success: [], error: [], requests: [], updates: [], navigation: [], lists: [], collectionReads: [] };
const useState = (initial) => {
const index = cursor++;
if (!(index in slots)) slots[index] = typeof initial === 'function' ? initial() : initial;
@@ -43,7 +43,7 @@ function harness(relativePath, api = {}) {
};
const named = (name, children = []) => Object.assign(function Component() {},
{ displayName: name }, Object.fromEntries(children.map((child) => [child, `${name}.${child}`])));
const antd = Object.fromEntries(['Alert', 'Button', 'Card', 'Col', 'Flex', 'Modal', 'Row', 'Select', 'Space', 'Statistic', 'Tag'].map((name) => [name, name]));
const antd = Object.fromEntries(['Alert', 'Button', 'Card', 'Col', 'Drawer', 'Empty', 'Flex', 'InputNumber', 'Modal', 'Pagination', 'Row', 'Select', 'Space', 'Spin', 'Statistic', 'Table', 'Tag'].map((name) => [name, name]));
antd.Descriptions = named('Descriptions', ['Item']);
antd.Typography = named('Typography', ['Title', 'Text', 'Paragraph']);
antd.Input = named('Input', ['Password', 'TextArea']);
@@ -69,11 +69,28 @@ function harness(relativePath, api = {}) {
'react/jsx-runtime': { jsx: element, jsxs: element, Fragment: 'Fragment' },
'@umijs/max': { useParams: () => ({ id: 'account-1' }), history: { push: (url) => calls.navigation.push(url) } },
'@ant-design/charts': { Line: 'Line' },
'@ant-design/icons': new Proxy({}, { get: (_, name) => String(name) }),
antd,
'@/services/api': {
getOne: async () => api.detail,
getList: async ({ resource }) => ({ data: resource === 'network-exits' ? (api.exits || []) : [api.profile || {}] }),
creatorGet: async (url) => url.endsWith('/metrics') ? (api.metrics || []) : (api.collectionStatus || {}),
getList: async (options) => {
calls.lists.push(options);
if (options.resource === 'creator-works') {
if (api.workListError) throw api.workListError;
return api.workPages?.[options.page] || { data: [], total: 0, hasNext: false };
}
if (options.resource === 'creator-comments') return { data: api.comments || [], total: (api.comments || []).length };
return { data: options.resource === 'network-exits' ? (api.exits || []) : [api.profile || {}] };
},
creatorGet: async (url) => {
if (url.endsWith('/metrics')) {
if (api.metricsError) throw api.metricsError;
return api.metrics || [];
}
calls.collectionReads.push(url);
if (api.collectionStatusError) throw api.collectionStatusError;
return api.collectionStatus || {};
},
creatorAction: async () => {}, accountAction: async () => {},
creatorUpdate: async (url, payload) => { calls.updates.push({ url, payload }); return api.updateResult || payload; },
...api.services,
@@ -108,13 +125,13 @@ function harness(relativePath, api = {}) {
};
loaded._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
fileName: filename, compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2022 },
}).outputText, filename);
}).outputText + (componentName === 'default' ? '' : `\nexports[${JSON.stringify(componentName)}] = ${componentName};`), filename);
return {
calls, forms,
render(nextProps = props) {
props = nextProps;
cursor = 0;
const tree = loaded.exports.default(props);
const tree = loaded.exports[componentName](props);
while (effects.length) effects.shift()();
return tree;
},
@@ -162,6 +179,153 @@ test('owned account metrics are one horizontal row with each number above its la
assert.equal(byType(dataCard, 'Descriptions.Item').length, 0);
});
test('作品总数保留最近非空值,不会被下一次缺失字段的快照清空', async () => {
const page = harness(detailPath, { detail, metrics: [{ aweme_count: 100 }, { follower_count: 20, aweme_count: null }] });
const tree = await page.mount();
assert.equal(byType(card(tree, '账号数据'), 'Statistic')[4].props.value, '100');
});
test('详情作品总数使用完整采集历史,而非仅依赖最近指标记录', async () => {
const page = harness(detailPath, { detail, metrics: [{ follower_count: 20 }], collectionStatus: { work_count: 28, aweme_count: 100 } });
const tree = await page.mount();
assert.equal(byType(card(tree, '账号数据'), 'Statistic')[4].props.value, '100');
});
test('自有账号作品展示独立采集进度,历史作品可翻页且筛选不改变进度分母', async () => {
const api = {
collectionStatus: { work_count: 28, aweme_count: 100, works: { status: 'succeeded' } },
workPages: {
1: { data: [{ id: 'new-work' }], total: 28, hasNext: true },
2: { data: [{ id: 'historical-work' }], total: 28, hasNext: false },
},
};
const page = harness('src/components/creator/WorksGallery.tsx', api);
let tree = await page.mount({ sourceId: 'account/1', sourceType: 'owned' });
assert.match(text(tree), /已采集 28 \/ 总计 100/);
assert.match(text(tree), /尚未采集完整/);
assert.equal(page.calls.collectionReads[0], '/creator/accounts/account%2F1/collection-status');
assert.equal(byType(tree, 'Pagination')[0].props.total, 28);
byType(tree, 'Pagination')[0].props.onChange(2);
page.render();
await flush();
tree = page.render();
assert.equal(byType(tree, 'Pagination')[0].props.current, 2);
assert.equal(page.calls.lists.at(-1).page, 2);
assert.equal(page.calls.lists.at(-1).filters.source_type, 'owned');
assert.equal(byType(tree, 'Col')[0].props.children.props.cover.props.workID, 'historical-work');
api.workPages[1] = { data: [], total: 0, hasNext: false };
byType(tree, 'InputNumber')[0].props.onChange(10);
page.render();
await flush();
tree = page.render();
assert.equal(page.calls.lists.at(-1).page, 1);
assert.equal(page.calls.lists.at(-1).filters.min_likes_growth, 10);
assert.match(text(tree), /已采集 28 \/ 总计 100/);
assert.match(text(tree), /符合筛选 0 个作品/);
});
test('作品进度区分未知、零作品与数量不一致,不把采集成功当作完整', async () => {
for (const [count, total, expected] of [[7, null, /总计 未知/], [0, 0, /已采集 0 \/ 总计 0/], [7, 3, /数量与抖音总数不一致/]]) {
const page = harness('src/components/creator/WorksGallery.tsx', { collectionStatus: { work_count: count, aweme_count: total, works: { status: 'succeeded' } } });
const tree = await page.mount({ sourceId: 'account-1', sourceType: 'owned' });
assert.match(text(tree), expected);
if (total === null) assert.match(text(tree), /暂无法判断是否完整/);
}
});
test('读取作品进度失败明确提示,已采集作品仍然展示,刷新能重试', async (t) => {
t.mock.method(console, 'error', () => {});
const api = { collectionStatusError: new Error('进度读取失败'), workPages: { 1: { data: [{ id: 'work-1' }], total: 1, hasNext: false } } };
const page = harness('src/components/creator/WorksGallery.tsx', api);
let tree = await page.mount({ sourceId: 'account-1', sourceType: 'owned' });
assert.equal(byType(tree, 'Col').length, 1);
assert.equal(byType(tree, 'Alert')[0].props.title, '进度读取失败');
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();
tree = page.render();
assert.equal(byType(tree, 'Alert').length, 0);
assert.match(text(tree), /已采集 1 \/ 总计 1/);
assert.equal(page.calls.collectionReads.length, 2);
});
test('监控账号仍展示已采集列表,不请求自有账号采集进度', async () => {
const page = harness('src/components/creator/WorksGallery.tsx');
const tree = await page.mount({ sourceId: 'monitor-1', sourceType: 'competitor' });
assert.equal(page.calls.collectionReads.length, 0);
assert.match(text(tree), /共 0 个作品/);
});
test('作品列表读取失败明确提示并可重试,不影响独立采集进度', async (t) => {
t.mock.method(console, 'error', () => {});
const api = { workListError: new Error('作品读取失败'), collectionStatus: { work_count: 28, aweme_count: 100, works: { status: 'running' } } };
const page = harness('src/components/creator/WorksGallery.tsx', api);
let tree = await page.mount({ sourceId: 'account-1', sourceType: 'owned' });
assert.equal(byType(tree, 'Alert')[0].props.title, '作品读取失败');
assert.match(text(tree), /正在采集/);
api.workListError = null;
await byType(tree, 'Alert')[0].props.action.props.onClick();
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();
tree = page.render();
assert.match(text(tree), /采集未完成:浏览器未登录/);
}
});
test('作品抽屉读取该作品的指标与评论,评论翻页保留每页条数', async () => {
const work = { id: 'work/1', title: '历史作品', description: '作品正文', published_at: '2025-01-01', likes_growth: 10, likes: 100 };
const page = harness('src/components/creator/WorksGallery.tsx', {
workPages: { 1: { data: [work], total: 1, hasNext: false } },
metrics: [{ collected_at: '2025-01-01', likes: 100 }],
comments: [{ id: 'comment-1', author_name: '作者', published_at: '2025-01-01', content: '评论正文' }],
});
let tree = await page.mount({ sourceType: 'competitor', sourceId: 'monitor-1' });
await byType(tree, 'Col')[0].props.children.props.onClick();
await flush();
tree = page.render();
const tables = byType(tree, 'Table');
assert.equal(tables[0].props.dataSource[0].likes, 100);
assert.equal(tables[1].props.dataSource[0].content, '评论正文');
for (const column of tables[0].props.columns) {
if (column.render && column.dataIndex !== 'collected_at') {
assert.ok(column.render(0) != null);
assert.ok(column.render(null) != null);
}
}
assert.match(text(tables[1].props.columns[0].render(null, {})), /—/);
assert.match(text(tables[1].props.columns[0].render(null, { author_name: '作者', published_at: '2025-01-01' })), /作者/);
tables[1].props.pagination.onChange(2, 20);
page.render();
await flush();
tree = page.render();
assert.equal(byType(tree, 'Table')[1].props.pagination.current, 2);
assert.equal(byType(tree, 'Table')[1].props.pagination.pageSize, 20);
assert.equal(page.calls.lists.at(-1).filters.work_id, 'work/1');
assert.equal(page.calls.lists.at(-1).pageSize, 20);
byType(tree, 'Drawer')[0].props.onClose();
assert.equal(byType(page.render(), 'Drawer')[0].props.open, false);
});
test('作品统计缺失时使用明确占位,增长曲线保留零值并排除未知值', async () => {
const stats = harness('src/components/creator/WorksGallery.tsx', {}, 'WorkStatsGrid');
const statsTree = await stats.mount({ work: { play_count: 0, likes: 100 } });
assert.match(text(statsTree), /0/);
assert.match(text(statsTree), /100/);
assert.match(text(statsTree), /—/);
const chart = harness('src/components/creator/WorksGallery.tsx', {}, 'GrowthChart');
let tree = await chart.mount({ metrics: [] });
assert.equal(tree, null);
tree = await chart.mount({ metrics: [{ collected_at: '2025-01-01', play_count: 0, likes: 100, shares: null }] });
assert.equal(tree.props.data.length, 2);
assert.equal(tree.props.data[0].value, 0);
assert.equal(tree.props.yAxis.labelFormatter(12000), '1.2w');
assert.equal(tree.props.yAxis.labelFormatter(0), '0');
});
test('all fixed resources live in account status and detail has no exit switching', async () => {
const page = harness(detailPath, { detail });
const tree = await page.mount();
@@ -12,6 +12,73 @@ const source = ts.createSourceFile(
);
const code = source.getFullText();
function accountRenderer(file, title = '账号') {
const ast = ts.createSourceFile(file, readFileSync(`${__dirname}/../src/components/accounts/${file}`, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
let renderer;
function visit(node) {
if (!renderer && ts.isObjectLiteralExpression(node) && node.properties.some(p => ts.isPropertyAssignment(p) && p.name.getText(ast) === 'title' && p.initializer.getText(ast) === `'${title}'`)) {
renderer = node.properties.find(p => ts.isPropertyAssignment(p) && p.name.getText(ast) === 'render').initializer.getText(ast);
}
ts.forEachChild(node, visit);
}
visit(ast);
assert.ok(renderer, `${file} 缺少账号列`);
const { createRequire } = require('node:module');
const requireWeb = createRequire(`${__dirname}/../package.json`);
const imports = { ...requireWeb('antd'), history: { push() {} }, formatCount: (value) => value == null ? '—' : String(value), dateTime: (value) => value };
const js = ts.transpileModule(`const render = ${renderer};`, { compilerOptions: { jsx: ts.JsxEmit.ReactJSX, module: ts.ModuleKind.CommonJS } }).outputText;
return require('node:vm').runInNewContext(`${js}\nrender`, { ...imports, exports: {}, require: requireWeb });
}
test('我的账号列不显示 UID,头像与监控账号一致且详情入口不变', () => {
const renderOwned = accountRenderer('AccountManagementList.tsx');
const renderMonitoring = accountRenderer('MonitoringAccountList.tsx');
for (const [name, avatar] of [['豪的抖音', 'https://example.com/avatar.jpg'], ['a', ''], ['', undefined]]) {
const data = { id: 'account-1', name, nickname: name, avatar_url: avatar, platform_account_key: '99491952055' };
const owned = renderOwned(undefined, data);
const monitored = renderMonitoring(undefined, data);
assert.equal(owned.type, monitored.type);
assert.equal(owned.props.align, 'center');
assert.equal(owned.props.gap, monitored.props.gap);
const [ownedAvatar, ownedName] = owned.props.children;
const [monitoredAvatar, monitoredName] = monitored.props.children;
assert.deepEqual(Object.keys(ownedAvatar.props), Object.keys(monitoredAvatar.props));
for (const key of Object.keys(monitoredAvatar.props)) assert.equal(ownedAvatar.props[key], monitoredAvatar.props[key]);
assert.equal(ownedName.props.children, name || '待登录');
if (name) assert.equal(ownedName.props.children, monitoredName.props.children);
assert.equal(data.name, name, '显示占位文案不修改原始昵称');
const requireWeb = require('node:module').createRequire(`${__dirname}/../package.json`);
const html = requireWeb('react-dom/server').renderToStaticMarkup(owned);
assert.ok(!html.includes(data.platform_account_key), '账号列不应显示 UID');
}
assert.match(code, /history\.push\(`\/accounts\/\$\{account\.id\}`\)/);
});
test('作品总数来自抖音,已采集数量只表示进度', () => {
const render = accountRenderer('AccountManagementList.tsx', '作品数据');
const requireWeb = require('node:module').createRequire(`${__dirname}/../package.json`);
const html = requireWeb('react-dom/server').renderToStaticMarkup(render(undefined, { aweme_count: 100, work_count: 7 }));
assert.match(html, /100 个作品/);
assert.match(html, /已采集 7 \/ 总计 100/);
assert.doesNotMatch(html, />7 个作品/);
const unknown = requireWeb('react-dom/server').renderToStaticMarkup(render(undefined, { work_count: 7 }));
assert.match(unknown, /作品总数未知/);
assert.match(unknown, /已采集 7 \/ 总计 未知/);
const zero = requireWeb('react-dom/server').renderToStaticMarkup(render(undefined, { aweme_count: 0, work_count: 0 }));
assert.match(zero, /0 个作品/);
assert.match(zero, /已采集 0 \/ 总计 0/);
});
test('未登录环境没有真实作品总数,不把占位零当作抖音数据', () => {
const render = accountRenderer('AccountManagementList.tsx', '作品数据');
const requireWeb = require('node:module').createRequire(`${__dirname}/../package.json`);
const html = requireWeb('react-dom/server').renderToStaticMarkup(render(undefined, {
pending_environment: true, aweme_count: 0, work_count: 0,
}));
assert.match(html, /登录后显示作品数据/);
assert.doesNotMatch(html, /0 个作品|总计 0/);
});
test('列表展示粉丝、关注、获赞、互关、评论独立数值列', () => {
for (const [title, value] of [
['粉丝', 'account.follower_count'],
@@ -0,0 +1,217 @@
const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const { Module, createRequire } = require('node:module');
const { resolve } = require('node:path');
const { test } = require('node:test');
const ts = require('typescript');
const requireWeb = createRequire(resolve(__dirname, '../package.json'));
const file = resolve(__dirname, '../src/components/accounts/AccountManagementList.tsx');
function setup({ accounts = [{ id: 'account-1', name: '真实昵称', business_status: 'normal', tags: ['主账号'] }], environments = [{ alias: 'env-1', gateway: 'gw-a' }], loadError, actionError, grid = { vertical: true, horizontal: true } } = {}) {
const states = [], calls = [], messages = [], paths = [];
let cursor = 0, started = false;
const marker = displayName => Object.assign(() => null, { displayName });
const form = { setFieldsValue(values) { form.values = values; }, async validateFields() { return form.values; } };
const antd = {};
for (const name of ['Alert', 'Avatar', 'Button', 'Card', 'Flex', 'Modal', 'Popconfirm', 'Select', 'Space', 'Table', 'Tag']) antd[name] = marker(name);
antd.Typography = { Text: marker('Text'), Link: marker('Link') };
antd.Form = Object.assign(marker('Form'), { Item: marker('Form.Item'), useForm: () => [form] });
antd.App = { useApp: () => ({ message: { success: text => messages.push(text), error: text => messages.push(text) } }) };
const loaded = new Module(file, module);
const options = { accounts, environments, loadError, actionError };
loaded.require = id => {
if (id === 'react') return {
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; }]; },
useCallback: callback => callback,
useEffect(effect) { if (!started) { started = true; effect(); } },
};
if (id === 'antd') return antd;
if (id === '@umijs/max') return { history: { push: path => paths.push(path) } };
if (id === '@/services/api') return {
async creatorGet(path) { calls.push({ path }); if (options.loadError) throw options.loadError; return path === '/creator/environments' ? options.environments : options.accounts; },
async creatorAction(path) { calls.push({ path }); if (options.actionError) throw options.actionError; },
async creatorUpdate(path, data) { calls.push({ path, data }); if (options.actionError) throw options.actionError; },
async remove(resource, id) { calls.push({ resource, id }); if (options.actionError) throw options.actionError; },
};
if (id === '@/utils/helpers') return { accountReadiness: () => ({ ready: 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: () => ({}), tableScroll: (x, y) => ({ x, y }), useOverflowGrid: () => grid };
if (id.startsWith('@/components/accounts/')) return { default: marker(id.split('/').at(-1)) };
return requireWeb(id);
};
loaded._compile(ts.transpileModule(readFileSync(file, 'utf8'), { fileName: file, compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, inlineSourceMap: true, inlineSources: true } }).outputText, file);
let tree;
const render = () => { cursor = 0; tree = loaded.exports.default(); return tree; };
const findAll = (name, node = tree) => {
if (!node || typeof node !== 'object') return [];
return [...(node.type?.displayName === name ? [node] : []), ...[node.props?.children].flat(Infinity).filter(child => child != null).flatMap(child => findAll(name, child))];
};
const find = (name, node) => findAll(name, node)[0];
const column = title => find('Table').props.columns.find(column => column.title === title);
const cell = (title, row) => { const col = column(title); return col.render(row[col.dataIndex], row); };
render();
return { render, find, findAll, cell, calls, messages, paths, form, options };
}
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 s = await ready();
assert.equal(s.findAll('Table').length, 1);
assert.equal(s.findAll('Card').length, 1);
const rows = s.find('Table').props.dataSource;
assert.equal(rows.length, 2);
assert.equal(rows[0].name, '真实昵称');
assert.match(text(s.find('Text')), /共 2 项/);
assert.equal(s.find('Table').props.loading, false);
assert.notEqual(rows[0].id, rows[1].id);
});
for (const horizontal of [false, true]) {
for (const vertical of [false, true]) {
test(`账号与操作列不挤压,其他列横向滚动(横向溢出 ${horizontal},纵向溢出 ${vertical})`, async () => {
const s = await ready({ grid: { horizontal, vertical } });
const { columns, scroll, dataSource } = s.find('Table').props;
assert.equal(columns[0].title, '账号');
assert.equal(columns[0].fixed, 'left');
assert.equal(columns[0].width, 240);
assert.equal(columns.at(-1).title, '操作');
assert.equal(columns.at(-1).fixed, 'right');
assert.equal(columns.at(-1).width, 300);
assert.ok(columns.slice(1, -1).every(column => !column.fixed));
assert.equal(scroll.x, 'max-content', '宽屏也应按内容宽度滚动,而不是挤压两侧列');
assert.equal(scroll.y, vertical ? 'calc(100vh - 300px)' : undefined);
const row = dataSource.find(row => !row.pending_environment);
assert.notEqual(s.find('Space', s.cell('操作', row)).props.wrap, true, '操作按钮不得换行折叠');
});
}
}
test('按共用环境数据库ID倒序排列,新建环境与账号在前,登录绑定不改变位置', async () => {
const accounts = [
{ id: 'account-z', name: '旧账号', environment: { id: 2, alias: 'env-z' } },
{ id: 'account-a', name: '新账号', environment: { id: 12, alias: 'env-a' } },
];
const environments = [{ id: 9, alias: 'env-9' }, { id: 20, alias: 'env-20' }];
const s = await ready({ accounts, environments });
assert.deepEqual(s.find('Table').props.dataSource.map(row => row.id), ['environment:env-20', 'account-a', 'environment:env-9', 'account-z']);
assert.deepEqual(accounts.map(row => row.id), ['account-z', 'account-a'], '排序不修改服务返回的原数据');
s.find('Button', s.cell('操作', s.find('Table').props.dataSource[0])).props.onClick(); s.render();
s.options.accounts = [...accounts, { id: 'account-bound', name: '已登录', environment: { id: 20, alias: 'env-20' } }];
s.options.environments = [{ id: 9, alias: 'env-9' }];
s.find('AccountLoginModal').props.onSuccess(); await flush(); s.render();
assert.deepEqual(s.find('Table').props.dataSource.map(row => row.id), ['account-bound', 'account-a', 'environment:env-9', 'account-z']);
});
test('待登录行缺失资料显示默认值,不伪造账号、业务状态或详情入口', async () => {
const s = await ready({ accounts: [] });
const row = s.find('Table').props.dataSource[0];
assert.ok(row, '环境应成为主列表中的一行');
assert.equal(row.name, '');
const name = s.cell('账号', row);
assert.equal(s.find('Avatar', name).props.src, undefined);
assert.match(text(name), /待登录/);
assert.equal(s.find('Link', name), undefined);
for (const title of ['粉丝', '关注', '获赞', '互关', '评论']) assert.equal(s.cell(title, row), '0');
assert.equal(text(s.cell('作品数据', row)), '登录后显示作品数据');
assert.equal(text(s.cell('标签', row)), '未设置');
assert.equal(text(s.cell('业务状态', row)), '—');
assert.equal(text(s.cell('状态', row)), '待登录');
assert.match(text(s.cell('运行环境', row)), /env-1未启动网关 gw-a机器直连/);
const actions = s.cell('操作', row);
assert.equal(s.findAll('Button', actions).length, 1);
assert.equal(text(actions), '登录并同步账号');
assert.equal(s.find('Popconfirm', actions), undefined);
assert.equal(s.paths.length, 0);
assert.deepEqual(s.options.environments, [{ alias: 'env-1', gateway: 'gw-a' }], '默认值不能写回环境数据');
});
test('登录使用真实环境标识,成功后同一列表刷新为真实账号,不保留重复环境', async () => {
const s = await ready({ accounts: [] });
const row = s.find('Table').props.dataSource[0];
assert.ok(row);
s.find('Button', s.cell('操作', row)).props.onClick(); s.render();
let modal = s.find('AccountLoginModal');
assert.equal(modal.props.accountID, 'env-1');
assert.equal(modal.props.resource, 'environments');
modal.props.onClose(); s.render(); assert.equal(s.find('AccountLoginModal'), undefined);
s.find('Button', s.cell('操作', row)).props.onClick(); s.render();
modal = s.find('AccountLoginModal');
s.options.accounts = [{ id: 'account-new', name: '新昵称', business_status: 'normal' }];
s.options.environments = [];
modal.props.onSuccess(); await flush(); s.render();
assert.equal(s.find('AccountLoginModal'), undefined);
assert.deepEqual(s.find('Table').props.dataSource.map(row => row.id), ['account-new']);
});
test('待登录环境真实名称、网关、运行与出口信息仍被保留', async () => {
const s = await ready({ accounts: [], environments: [{ alias: 'env-2', name: '环境二', gateway: 'gw-b', runtime_id: 'runtime-2', runtime_cleanup_pending: true, network_exit: { id: 'exit-2', health_status: 'healthy' } }] });
const row = s.find('Table').props.dataSource[0]; assert.ok(row);
assert.match(text(s.cell('运行环境', row)), /环境二停止待确认网关 gw-b出口 exit-2 · 健康/);
});
test('空列表不生成占位行,环境加载失败不冒充成功并支持重试', async () => {
const empty = await ready({ accounts: [], environments: [] });
assert.equal(empty.findAll('Table').length, 1);
assert.deepEqual(empty.find('Table').props.dataSource, []);
const s = await ready({ loadError: new Error('环境加载失败') });
assert.equal(s.find('Alert').props.title, '环境加载失败');
assert.equal(s.find('Table').props.loading, false);
s.options.loadError = undefined;
await s.find('Alert').props.action.props.onClick(); s.render();
assert.equal(s.find('Alert'), undefined);
assert.equal(s.find('Table').props.dataSource.length, 2);
});
test('已登录账号仍可访问详情、编辑、启动、删除和保存标签', async () => {
const s = await ready({ environments: [] });
let row = s.find('Table').props.dataSource[0];
s.find('Link', s.cell('账号', row)).props.onClick();
assert.equal(s.paths.at(-1), '/accounts/account-1');
const actions = s.cell('操作', row);
s.findAll('Button', actions).find(button => text(button) === '编辑账号').props.onClick();
assert.equal(s.paths.at(-1), '/accounts/account-1/edit');
await s.findAll('Button', actions).find(button => text(button) === '启动').props.onClick(); s.render();
assert.ok(s.calls.some(call => call.path === '/phase-a/accounts/account-1/start'));
let login = s.find('AccountLoginModal'); assert.equal(login.props.accountID, 'account-1');
login.props.onSuccess(); await flush(); s.render();
login = s.find('AccountLoginModal'); login.props.onClose(); s.render();
s.findAll('Button', actions).find(button => text(button) === '编辑标签').props.onClick(); s.render();
assert.deepEqual(s.form.values.tags, ['主账号']);
s.form.values = { tags: ['新标签'] };
await s.find('Modal').props.onOk(); s.render();
row = s.find('Table').props.dataSource[0]; assert.deepEqual(row.tags, ['新标签']);
assert.ok(s.calls.some(call => call.path === '/creator/accounts/account-1/tags'));
await s.find('Popconfirm', actions).props.onConfirm();
assert.ok(s.calls.some(call => call.resource === 'accounts' && call.id === 'account-1'));
});
test('已登录账号缺失业务状态有明确占位,真实指标和标签保持原样', async () => {
const s = await ready({ environments: [], accounts: [{ id: 'account-2', name: '', follower_count: 20000, aweme_count: 100, work_count: 7, latest_published_at: '2026-01-01', environment: { alias: 'env-2', runtime_id: 'runtime-2', network_exit_id: 'exit-2', network_exit_health: 'unchecked' } }] });
const row = s.find('Table').props.dataSource[0];
assert.equal(s.cell('粉丝', row), '2.0w');
assert.equal(s.cell('关注', row), '—');
assert.equal(text(s.cell('业务状态', row)), '—');
assert.match(text(s.cell('作品数据', row)), /100 个作品已采集 7 \/ 总计 100最近发布 2026-01-01/);
assert.match(text(s.cell('运行环境', row)), /env-2运行中网关 —出口 exit-2 · unchecked/);
assert.equal(text(s.cell('状态', row)), '可运行');
});
test('账号操作失败显示实际错误,标签取消及新增关闭刷新保持可用', async () => {
const s = await ready({ actionError: new Error('操作冲突') });
const row = s.find('Table').props.dataSource[0];
const actions = s.cell('操作', row);
const button = label => s.findAll('Button', actions).find(button => text(button) === label);
await button('启动').props.onClick(); s.render(); assert.equal(s.find('AccountLoginModal'), undefined);
await s.find('Popconfirm', actions).props.onConfirm();
button('编辑标签').props.onClick(); s.render(); await s.find('Modal').props.onOk(); s.render();
assert.equal(s.messages.filter(message => message === '操作冲突').length, 3);
assert.equal(s.find('Modal').props.open, true);
s.find('Modal').props.onCancel(); s.render(); assert.equal(s.find('Modal').props.open, false);
await s.find('Modal').props.onOk();
const create = () => s.findAll('Button').find(button => text(button) === '新增账号').props.onClick();
create(); s.render(); s.find('AccountCreateModal').props.onClose(); s.render(); assert.equal(s.find('AccountCreateModal'), undefined);
create(); s.render(); s.find('AccountCreateModal').props.onSuccess(); await flush(); s.render();
assert.equal(s.find('AccountCreateModal'), undefined);
assert.equal(s.find('Table').props.dataSource.length, 2);
});