feat: unify account environments and work collection
This commit is contained in:
@@ -10,12 +10,13 @@ import { 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, tableScroll, useOverflowGrid } from '@/utils/table';
|
||||
import { fixedLeft, fixedRight, tablePagination, useOverflowGrid } from '@/utils/table';
|
||||
import AccountLoginModal from '@/components/accounts/AccountLoginModal';
|
||||
import AccountCreateModal from '@/components/accounts/AccountCreateModal';
|
||||
|
||||
// 仅用 antd 默认组件:Table/Tag/Modal/Popconfirm/Select(tags)。
|
||||
interface EnvironmentView {
|
||||
id?: number;
|
||||
gateway?: string;
|
||||
name?: string;
|
||||
alias?: string;
|
||||
@@ -25,15 +26,21 @@ interface EnvironmentView {
|
||||
network_exit_health?: string;
|
||||
}
|
||||
|
||||
interface PendingEnvironment extends EnvironmentView {
|
||||
alias: string;
|
||||
network_exit?: { id: string; health_status: string };
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
pending_environment?: string;
|
||||
name: string;
|
||||
platform_account_key: string;
|
||||
avatar_url?: string;
|
||||
douyin_number?: string;
|
||||
tags?: string[];
|
||||
runtime_status?: string;
|
||||
business_status: 'normal' | 'muted' | 'banned' | 'deleted';
|
||||
business_status?: 'normal' | 'muted' | 'banned' | 'deleted';
|
||||
environment?: EnvironmentView;
|
||||
work_count?: number;
|
||||
latest_published_at?: string | null;
|
||||
@@ -54,7 +61,6 @@ function formatCount(value?: number | null): string {
|
||||
export default function AccountManagementList() {
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [environments, setEnvironments] = useState<{ alias: string; gateway: string; runtime_id?: string }[]>([]);
|
||||
const [environmentTarget, setEnvironmentTarget] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
@@ -64,7 +70,7 @@ export default function AccountManagementList() {
|
||||
const [actionBusy, setActionBusy] = useState('');
|
||||
const [loginTarget, setLoginTarget] = useState<Row | null>(null);
|
||||
const { message: messageApi } = App.useApp();
|
||||
const { vertical, horizontal } = useOverflowGrid(rows.length);
|
||||
const { vertical } = useOverflowGrid(rows.length);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setPending(true);
|
||||
@@ -73,13 +79,28 @@ export default function AccountManagementList() {
|
||||
const [rows, environments] = await Promise.all([
|
||||
creatorGet('/creator/accounts/monitor-views'), creatorGet('/creator/environments'),
|
||||
]);
|
||||
setEnvironments(environments);
|
||||
setRows(
|
||||
(rows ?? []).map((account: any) => ({
|
||||
...account,
|
||||
name: account.name,
|
||||
tags: account.tags ?? [] })),
|
||||
);
|
||||
setRows([
|
||||
...(rows ?? []).map((account: Row) => ({ ...account, tags: account.tags ?? [] })),
|
||||
...environments.map((environment: PendingEnvironment): Row => ({
|
||||
id: `environment:${environment.alias}`,
|
||||
pending_environment: environment.alias,
|
||||
name: '',
|
||||
platform_account_key: '',
|
||||
tags: [],
|
||||
follower_count: 0,
|
||||
following_count: 0,
|
||||
total_favorited: 0,
|
||||
friend_count: 0,
|
||||
comment_total: 0,
|
||||
aweme_count: 0,
|
||||
work_count: 0,
|
||||
environment: {
|
||||
...environment,
|
||||
network_exit_id: environment.network_exit?.id,
|
||||
network_exit_health: environment.network_exit?.health_status,
|
||||
},
|
||||
})),
|
||||
].sort((a, b) => (b.environment?.id ?? 0) - (a.environment?.id ?? 0)));
|
||||
} catch (loadError) {
|
||||
setError(loadError);
|
||||
} finally {
|
||||
@@ -143,14 +164,18 @@ export default function AccountManagementList() {
|
||||
{
|
||||
title: '账号',
|
||||
dataIndex: 'name',
|
||||
...fixedLeft<Row>({}),
|
||||
...fixedLeft<Row>({ width: 240 }),
|
||||
render: (_, account) => (
|
||||
<div>
|
||||
<Space><Avatar size="small" src={account.avatar_url} /><Typography.Link onClick={() => history.push(`/accounts/${account.id}`)}>{account.name || '待登录'}</Typography.Link></Space>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }} ellipsis={{ tooltip: true }}>
|
||||
{account.platform_account_key}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Flex align="center" gap={12}>
|
||||
<Avatar size={40} shape="square" src={account.avatar_url || undefined}>{(account.name || '账').slice(0, 1).toUpperCase()}</Avatar>
|
||||
{account.pending_environment ? (
|
||||
<Typography.Text>待登录</Typography.Text>
|
||||
) : (
|
||||
<Typography.Link onClick={() => history.push(`/accounts/${account.id}`)}>
|
||||
{account.name || '待登录'}
|
||||
</Typography.Link>
|
||||
)}
|
||||
</Flex>
|
||||
) },
|
||||
{
|
||||
title: '粉丝',
|
||||
@@ -175,9 +200,14 @@ export default function AccountManagementList() {
|
||||
{
|
||||
title: '作品数据',
|
||||
dataIndex: 'work_count',
|
||||
render: (_, account) => (
|
||||
render: (_, account) => account.pending_environment ? (
|
||||
<Typography.Text type="secondary">登录后显示作品数据</Typography.Text>
|
||||
) : (
|
||||
<Flex vertical>
|
||||
<Typography.Text>{formatCount(account.work_count ?? account.aweme_count)} 个作品</Typography.Text>
|
||||
<Typography.Text>{account.aweme_count == null ? '作品总数未知' : `${account.aweme_count} 个作品`}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
已采集 {account.work_count ?? '未知'} / 总计 {account.aweme_count ?? '未知'}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
最近发布 {account.latest_published_at ? dateTime(account.latest_published_at) : '—'}
|
||||
</Typography.Text>
|
||||
@@ -215,6 +245,7 @@ export default function AccountManagementList() {
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (_, account) => {
|
||||
if (account.pending_environment) return <Tag>待登录</Tag>;
|
||||
// 与详情页同款三层判定(绑定/出口/账号),环境未绑定时退化为账号启停状态。
|
||||
const readiness = accountReadiness(account, account.environment ?? null);
|
||||
return (
|
||||
@@ -227,6 +258,7 @@ export default function AccountManagementList() {
|
||||
title: '业务状态',
|
||||
dataIndex: 'business_status',
|
||||
render: (status: Row['business_status']) => {
|
||||
if (!status) return <Typography.Text type="secondary">—</Typography.Text>;
|
||||
const statuses = {
|
||||
normal: { label: '正常', color: 'success' },
|
||||
muted: { label: '禁言', color: 'warning' },
|
||||
@@ -241,35 +273,46 @@ export default function AccountManagementList() {
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
align: 'right',
|
||||
...fixedRight<Row>({}),
|
||||
render: (_, account) => (
|
||||
<Space wrap>
|
||||
<Button type="text" size="small" onClick={() => startAccount(account)} loading={actionBusy === account.id}>
|
||||
启动
|
||||
</Button>
|
||||
<Button type="text" size="small" onClick={() => openTags(account)}>
|
||||
编辑标签
|
||||
</Button>
|
||||
<Button type="text" size="small" onClick={() => history.push(`/accounts/${account.id}/edit`)}>
|
||||
编辑账号
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确定删除自有账号“${account.name || '待登录'}”吗?相关专属资源也会被清理。`}
|
||||
onConfirm={() => deleteAccount(account)}
|
||||
disabled={actionBusy === account.id}
|
||||
>
|
||||
<Button type="text" size="small" danger disabled={actionBusy === account.id}>
|
||||
删除
|
||||
...fixedRight<Row>({ width: 300 }),
|
||||
render: (_, account) => {
|
||||
const environmentAlias = account.pending_environment;
|
||||
if (environmentAlias) {
|
||||
return (
|
||||
<Button type="link" size="small" onClick={() => setEnvironmentTarget(environmentAlias)}>
|
||||
登录并同步账号
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
) },
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Space>
|
||||
<Button type="text" size="small" onClick={() => startAccount(account)} loading={actionBusy === account.id}>
|
||||
启动
|
||||
</Button>
|
||||
<Button type="text" size="small" onClick={() => openTags(account)}>
|
||||
编辑标签
|
||||
</Button>
|
||||
<Button type="text" size="small" onClick={() => history.push(`/accounts/${account.id}/edit`)}>
|
||||
编辑账号
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确定删除自有账号“${account.name || '待登录'}”吗?相关专属资源也会被清理。`}
|
||||
onConfirm={() => deleteAccount(account)}
|
||||
disabled={actionBusy === account.id}
|
||||
>
|
||||
<Button type="text" size="small" danger disabled={actionBusy === account.id}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">共 {rows.length} 个自有账号</Typography.Text>
|
||||
<Typography.Text type="secondary">共 {rows.length} 项</Typography.Text>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} disabled={pending} aria-label="刷新" />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
@@ -283,17 +326,6 @@ export default function AccountManagementList() {
|
||||
<AccountLoginModal accountID={environmentTarget} accountName="待登录环境" resource="environments"
|
||||
onClose={() => setEnvironmentTarget(null)} onSuccess={() => { setEnvironmentTarget(null); void load(); }} />
|
||||
) : null}
|
||||
{environments.length > 0 ? (
|
||||
<Card title="待登录环境">
|
||||
<Table rowKey="alias" dataSource={environments} pagination={tablePagination()} sticky columns={[
|
||||
{ title: '账号', render: () => <Typography.Text>待登录</Typography.Text> },
|
||||
{ title: '网关', dataIndex: 'gateway' },
|
||||
{ title: '环境标识', dataIndex: 'alias', render: (alias: string) => <Typography.Text copyable>{alias}</Typography.Text> },
|
||||
{ title: '状态', render: () => <Tag>待登录</Tag> },
|
||||
{ title: '操作', render: (_, env) => <Button type="link" onClick={() => setEnvironmentTarget(env.alias)}>登录并同步账号</Button> },
|
||||
]} />
|
||||
</Card>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
|
||||
) : null}
|
||||
@@ -304,8 +336,8 @@ export default function AccountManagementList() {
|
||||
loading={pending}
|
||||
sticky
|
||||
pagination={tablePagination()}
|
||||
scroll={tableScroll(horizontal ? 1320 : undefined, vertical ? 'calc(100vh - 300px)' : undefined)}
|
||||
locale={{ emptyText: '暂无自有账号。' }}
|
||||
scroll={{ x: 'max-content', y: vertical ? 'calc(100vh - 300px)' : undefined }}
|
||||
locale={{ emptyText: '暂无账号或待登录环境。' }}
|
||||
/>
|
||||
<Modal
|
||||
open={Boolean(tagTarget)}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 指标 GET /creator/works/:id/metrics,评论 GET /creator/comments?work_id=(定时采集由后端调度器维护)。
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Line } from '@ant-design/charts';
|
||||
import { Alert, App, Button, Card, Col, Drawer, Empty, Flex, InputNumber, Row, Space, Spin, Table, Typography } from 'antd';
|
||||
import { Alert, App, Button, Card, Col, Drawer, Empty, Flex, InputNumber, Pagination, Row, Space, Spin, Table, Typography } from 'antd';
|
||||
import {
|
||||
CommentOutlined,
|
||||
EyeOutlined,
|
||||
@@ -104,6 +104,12 @@ function GrowthChart({ metrics }: { metrics: any[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface CollectionProgress {
|
||||
work_count: number;
|
||||
aweme_count: number | null;
|
||||
works: { status: string; last_error?: string };
|
||||
}
|
||||
|
||||
interface WorksGalleryProps {
|
||||
/** 不传则不过滤来源(如竞品分析页跨全部竞品);监控/自有账号详情传具体账号 ID。 */
|
||||
sourceId?: string;
|
||||
@@ -116,7 +122,9 @@ interface WorksGalleryProps {
|
||||
export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFilters }: WorksGalleryProps) {
|
||||
const [works, setWorks] = useState<any[]>([]);
|
||||
const [workPage, setWorkPage] = useState(1);
|
||||
const [workPageInfo, setWorkPageInfo] = useState({ total: 0, hasNext: false });
|
||||
const [workPageInfo, setWorkPageInfo] = useState({ total: 0 });
|
||||
const [collectionProgress, setCollectionProgress] = useState<CollectionProgress | null>(null);
|
||||
const [progressError, setProgressError] = useState<any>(null);
|
||||
const [worksPending, setWorksPending] = useState(true);
|
||||
const [worksError, setWorksError] = useState<any>(null);
|
||||
const [minGrowth, setMinGrowth] = useState<number | null>(null);
|
||||
@@ -133,6 +141,16 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
const loadWorks = useCallback(async () => {
|
||||
setWorksPending(true);
|
||||
setWorksError(null);
|
||||
setProgressError(null);
|
||||
setCollectionProgress(null);
|
||||
const progressRequest = sourceType === 'owned' && sourceId
|
||||
? creatorGet(`/creator/accounts/${encodeURIComponent(sourceId)}/collection-status`)
|
||||
.then((progress) => setCollectionProgress(progress))
|
||||
.catch((error) => {
|
||||
console.error('读取账号作品采集进度失败', { sourceId, sourceType, error });
|
||||
setProgressError(error);
|
||||
})
|
||||
: Promise.resolve();
|
||||
try {
|
||||
const filters: Record<string, string | number | undefined> = { ...extraFilters };
|
||||
if (sourceId) filters.source_id = sourceId;
|
||||
@@ -144,10 +162,12 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
}
|
||||
const result = await getList({ resource: 'creator-works', page: workPage, pageSize: 25, filters });
|
||||
setWorks(result.data);
|
||||
setWorkPageInfo({ total: result.total, hasNext: Boolean(result.hasNext) });
|
||||
setWorkPageInfo({ total: result.total });
|
||||
} catch (loadError) {
|
||||
console.error('读取作品列表失败', { sourceId, sourceType, page: workPage, minGrowth, extraFilters, error: loadError });
|
||||
setWorksError(loadError);
|
||||
} finally {
|
||||
await progressRequest;
|
||||
setWorksPending(false);
|
||||
}
|
||||
}, [sourceId, sourceType, workPage, minGrowth, extraFilters]);
|
||||
@@ -188,6 +208,18 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
loadComments(work.id, 1);
|
||||
}
|
||||
|
||||
let progressHint = '采集进度暂不可用';
|
||||
if (collectionProgress) {
|
||||
const { work_count: collected, aweme_count: total, works: checkpoint } = collectionProgress;
|
||||
progressHint = `已采集 ${collected} / 总计 ${total ?? '未知'}`;
|
||||
if (total == null) progressHint += ';抖音作品总数未知,暂无法判断是否完整。';
|
||||
else if (collected < total) progressHint += ';尚未采集完整,当前仅展示已采集作品。';
|
||||
else if (collected > total) progressHint += ';已采集数量与抖音总数不一致,请重新同步确认。';
|
||||
else progressHint += ';当前已采集数量与抖音总数一致。';
|
||||
if (checkpoint.status === 'running') progressHint += ' 正在采集。';
|
||||
if (checkpoint.status === 'failed' || checkpoint.status === 'blocked') progressHint += ` 采集未完成:${checkpoint.last_error || '请查看账号采集状态。'}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
@@ -197,7 +229,12 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
}
|
||||
>
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">共 {workPageInfo.total} 个作品;作品由系统按采集间隔自动同步。</Typography.Text>
|
||||
<Flex vertical gap={4}>
|
||||
{sourceType === 'owned' ? <Typography.Text>{progressHint}</Typography.Text> : null}
|
||||
<Typography.Text type="secondary">
|
||||
{worksError ? '作品列表暂不可用' : `${minGrowth !== null || extraFilters ? '符合筛选' : sourceType === 'owned' ? '当前列表' : '共'} ${workPageInfo.total} 个作品`};作品由系统按采集间隔自动同步。
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<Flex align="center" gap={8}>
|
||||
<FireOutlined style={{ color: 'rgba(0,0,0,0.45)' }} />
|
||||
<Typography.Text type="secondary">24h 点赞增量 ≥</Typography.Text>
|
||||
@@ -214,6 +251,9 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{progressError ? (
|
||||
<Alert type="error" showIcon title={progressError.message} style={{ marginBottom: 16 }} action={<Button size="small" onClick={loadWorks}>重试</Button>} />
|
||||
) : null}
|
||||
{worksError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
@@ -269,14 +309,15 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
</Spin>
|
||||
{workPageInfo.total > works.length ? (
|
||||
<Flex justify="flex-end" style={{ marginTop: 12 }}>
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setWorkPage((page) => Math.max(1, page - 1))} disabled={workPage === 1 || worksPending}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setWorkPage((page) => page + 1)} disabled={!workPageInfo.hasNext || worksPending}>
|
||||
下一页
|
||||
</Button>
|
||||
</Space>
|
||||
<Pagination
|
||||
current={workPage}
|
||||
pageSize={25}
|
||||
total={workPageInfo.total}
|
||||
showSizeChanger={false}
|
||||
showTotal={(total) => `${sourceType === 'owned' ? '当前列表共' : '共'} ${total} 个作品`}
|
||||
disabled={worksPending}
|
||||
onChange={(page) => setWorkPage(page)}
|
||||
/>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
@@ -185,7 +185,15 @@ export default function Page() {
|
||||
|
||||
const worksCollection = collectionStatus?.works;
|
||||
const worksMeta = collectionStatusMeta[worksCollection?.status ?? 'pending'] || { label: worksCollection?.status || '待采集' };
|
||||
const latestMetric = accountMetrics.length ? accountMetrics[accountMetrics.length - 1] : null;
|
||||
// 与列表一致:某轮缺少字段时保留该字段最近一次实际采集的值。
|
||||
const latestMetric = accountMetrics.reduce<Record<string, number>>((counts, metric) => {
|
||||
for (const { key } of accountSeriesMeta) {
|
||||
if (metric[key] != null) counts[key] = metric[key];
|
||||
}
|
||||
return counts;
|
||||
}, {});
|
||||
// 作品总数不受曲线查询最近 100 条记录的范围限制。
|
||||
if (collectionStatus?.aweme_count != null) latestMetric.aweme_count = collectionStatus.aweme_count;
|
||||
|
||||
// 账号状态卡局部操作:启动/暂停两态开关留在状态卡右上角(Card extra),不进页头槽。
|
||||
const paused = account.runtime_status === 'paused';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user