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
@@ -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)}
+53 -12
View File
@@ -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>