feat(web): 监控账号详情页承载作品列表,点击作品弹出统计 Drawer

This commit is contained in:
2026-09-27 13:22:54 +08:00
parent 7cb2a0a2b4
commit aeae1049ad
4 changed files with 352 additions and 12 deletions
@@ -22,8 +22,8 @@ import {
message,
} from 'antd';
import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons';
import { getList, remove, creatorAction, creatorUpdate } from '@/services/api';
import { conflictMessage, dateTime, isCollectionAccount, platformLabel } from '@/utils/helpers';
import { getList, remove, creatorAction, creatorUpdate, syncCompetitorAuto } from '@/services/api';
import { conflictMessage, dateTime, platformLabel } from '@/utils/helpers';
interface CompetitorRow {
id: string;
@@ -88,14 +88,7 @@ export default function MonitoringAccountList() {
async function syncCompetitor(competitor: CompetitorRow) {
setActionBusy(competitor.id);
try {
const result = await getList({ resource: 'creator-accounts' });
const account = (result.data ?? []).find(
(candidate: any) => candidate.platform === competitor.platform && isCollectionAccount(candidate),
);
if (!account) {
throw new Error(`没有可用于同步${platformLabel(competitor.platform)}监控账号的已登录自有账号。`);
}
await creatorAction(`/creator/competitors/${encodeURIComponent(competitor.id)}/sync`, { account_id: account.id });
await syncCompetitorAuto(competitor);
messageApi.success('监测账号同步任务已提交。');
await load();
} catch (actionError) {
@@ -177,7 +170,10 @@ export default function MonitoringAccountList() {
itemRender={(row) => {
const meta = syncStatusMeta[row.sync_status ?? ''] || { label: row.sync_status || '—' };
return (
<div style={{ padding: '16px 0', borderBottom: '1px solid rgba(5,5,5,0.06)' }}>
<div
style={{ padding: '16px 0', borderBottom: '1px solid rgba(5,5,5,0.06)', cursor: 'pointer' }}
onClick={() => history.push(`/accounts/monitoring/${row.id}`)}
>
<Flex justify="space-between" align="flex-start" gap={16}>
<Flex align="flex-start" gap={12}>
<Avatar size={44} src={row.avatar_url || undefined}>
@@ -197,7 +193,7 @@ export default function MonitoringAccountList() {
</Typography.Paragraph>
</div>
</Flex>
<Space wrap>
<Space wrap onClick={(e) => e.stopPropagation()}>
<Button size="small" onClick={() => openTags(row)}>
编辑标签
</Button>
@@ -0,0 +1,330 @@
// 监控账号详情:形态对齐账号详情页(页头槽 + 卡片流)——
// 页头 info 为账号标识(头像 + 昵称 + 同步状态 + 平台),actions 为返回/同步/暂停恢复;
// 主体两张 Card:账号画像(Descriptions)与作品列表(Row/Col card 网格,分页)。
// 点击作品弹出 Drawer 展示统计信息:作品详情 + 指标采集记录表。
// 作品列表走 GET /creator/works?source_id=<competitor_id>,定时采集由后端调度器维护。
import { useCallback, useEffect, useState } from 'react';
import { history, useParams } from '@umijs/max';
import {
Alert,
Avatar,
Button,
Card,
Col,
Descriptions,
Drawer,
Empty,
Flex,
Row,
Space,
Spin,
Table,
Tag,
Typography,
message,
} from 'antd';
import { CommentOutlined, LikeOutlined, ReloadOutlined, ShareAltOutlined } from '@ant-design/icons';
import { creatorAction, creatorGet, getOne, getList, syncCompetitorAuto } from '@/services/api';
import { conflictMessage, dateTime, platformLabel } from '@/utils/helpers';
import { usePageActions, usePageInfo } from '@/components/PageActions';
interface CompetitorDetail {
id: string;
platform: string;
platform_account_key: string;
unique_id?: string;
nickname: string;
avatar_url: string;
homepage_url?: string;
tags?: string[];
enabled: boolean;
follower_count?: number | null;
following_count?: number | null;
aweme_count?: number | null;
sync_status?: string;
sync_error?: string;
last_sync_at?: string | null;
next_sync_at?: string | null;
}
const syncStatusMeta: Record<string, { color?: string; label: string }> = {
idle: { color: 'success', label: '监控中' },
running: { color: 'processing', label: '同步中' },
paused: { label: '已暂停' },
failed: { color: 'error', label: '同步失败' },
blocked: { color: 'warning', label: '同步受阻' },
};
function formatCount(value?: number | null): string {
if (value === null || value === undefined) return '—';
return value >= 10000 ? `${(value / 10000).toFixed(1)}w` : `${value}`;
}
export default function Page() {
const { id = '' } = useParams<{ id: string }>();
const [competitor, setCompetitor] = useState<CompetitorDetail | null>(null);
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(null);
const [actionBusy, setActionBusy] = useState(false);
const [works, setWorks] = useState<any[]>([]);
const [workPage, setWorkPage] = useState(1);
const [workPageInfo, setWorkPageInfo] = useState({ total: 0, hasNext: false });
const [worksPending, setWorksPending] = useState(true);
const [worksError, setWorksError] = useState<any>(null);
const [detailWork, setDetailWork] = useState<any>(null);
const [workMetrics, setWorkMetrics] = useState<any[]>([]);
const [metricsPending, setMetricsPending] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const loadCompetitor = useCallback(async () => {
setPending(true);
setError(null);
try {
setCompetitor(await getOne<CompetitorDetail>('creator-competitors', id));
} catch (loadError) {
setError(loadError);
} finally {
setPending(false);
}
}, [id]);
const loadWorks = useCallback(async () => {
setWorksPending(true);
setWorksError(null);
try {
const result = await getList({ resource: 'creator-works', page: workPage, pageSize: 25, filters: { source_id: id } });
setWorks(result.data);
setWorkPageInfo({ total: result.total, hasNext: Boolean(result.hasNext) });
} catch (loadError) {
setWorksError(loadError);
} finally {
setWorksPending(false);
}
}, [id, workPage]);
useEffect(() => {
loadCompetitor();
}, [loadCompetitor]);
useEffect(() => {
loadWorks();
}, [loadWorks]);
const syncMeta = syncStatusMeta[competitor?.sync_status ?? ''] || { label: competitor?.sync_status || '—' };
// 页头 content 行:左侧账号标识,右侧操作(返回/同步/暂停恢复),对齐账号详情页形态。
usePageInfo(
competitor ? (
<Flex align="center" gap={8} wrap="wrap">
<Avatar size={28} src={competitor.avatar_url || undefined}>
{(competitor.nickname || competitor.platform_account_key).slice(0, 1).toUpperCase()}
</Avatar>
<Typography.Title level={5} style={{ marginBottom: 0 }} ellipsis>
{competitor.nickname || competitor.platform_account_key}
</Typography.Title>
<Tag color={syncMeta.color}>{syncMeta.label}</Tag>
<Typography.Text type="secondary">
{competitor.unique_id || competitor.platform_account_key} · {platformLabel(competitor.platform)}
</Typography.Text>
</Flex>
) : null,
[competitor?.id, competitor?.nickname, competitor?.sync_status],
);
usePageActions(
competitor ? (
<Space>
<Button onClick={() => history.push('/accounts/monitoring')}>返回监控账号</Button>
<Button
disabled={actionBusy || !competitor.enabled}
onClick={async () => {
setActionBusy(true);
try {
await syncCompetitorAuto(competitor);
messageApi.success('监测账号同步任务已提交。');
await loadCompetitor();
} catch (actionError) {
messageApi.error(conflictMessage(actionError, '监测账号同步失败'));
} finally {
setActionBusy(false);
}
}}
>
立即同步
</Button>
<Button
type="primary"
disabled={actionBusy}
onClick={async () => {
setActionBusy(true);
try {
await creatorAction(
`/creator/competitors/${encodeURIComponent(competitor.id)}/${competitor.enabled ? 'pause' : 'resume'}`,
);
messageApi.success(competitor.enabled ? '监测账号已暂停。' : '监测账号已恢复。');
await loadCompetitor();
} catch (actionError) {
messageApi.error(conflictMessage(actionError, '监测账号状态更新失败'));
} finally {
setActionBusy(false);
}
}}
>
{competitor.enabled ? '暂停监控' : '恢复监控'}
</Button>
</Space>
) : null,
[competitor?.id, competitor?.enabled],
);
async function openWorkDetail(work: any) {
setDetailWork(work);
setWorkMetrics([]);
setMetricsPending(true);
try {
setWorkMetrics((await creatorGet(`/creator/works/${encodeURIComponent(work.id)}/metrics`)) || []);
} catch (actionError) {
messageApi.error(conflictMessage(actionError, '作品统计读取失败'));
} finally {
setMetricsPending(false);
}
}
if (pending) return <Card loading />;
if (error || !competitor) return <Alert type="error" showIcon title={error?.message || '监控账号不存在'} />;
return (
<Flex vertical gap={16}>
{contextHolder}
<Card title="账号画像">
<Descriptions
column={{ xs: 1, sm: 2, md: 3 }}
size="small"
items={[
{ key: 'key', label: '平台账号', children: competitor.platform_account_key },
{ key: 'uid', label: '抖音号', children: competitor.unique_id || '—' },
{ key: 'follower', label: '粉丝', children: formatCount(competitor.follower_count) },
{ key: 'following', label: '关注', children: formatCount(competitor.following_count) },
{ key: 'aweme', label: '作品总数', children: formatCount(competitor.aweme_count) },
{ key: 'tags', label: '标签', children: competitor.tags?.length ? competitor.tags.join('、') : '无' },
{ key: 'lastSync', label: '上次同步', children: competitor.last_sync_at ? dateTime(competitor.last_sync_at) : '—' },
{ key: 'nextSync', label: '下次自动同步', children: competitor.next_sync_at ? dateTime(competitor.next_sync_at) : '—' },
{
key: 'syncError',
label: '同步异常',
children: competitor.sync_error ? (
<Typography.Text type="danger" style={{ fontSize: 12 }}>{competitor.sync_error}</Typography.Text>
) : (
'—'
),
},
]}
/>
</Card>
<Card
title="作品列表"
extra={
<Button icon={<ReloadOutlined />} size="small" onClick={loadWorks} disabled={worksPending} aria-label="刷新作品" />
}
>
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Typography.Text type="secondary">共 {workPageInfo.total} 个作品;作品由系统按采集间隔自动同步。</Typography.Text>
</Flex>
{worksError ? (
<Alert
type="error"
showIcon
style={{ marginBottom: 16 }}
title={worksError.message}
action={<Button size="small" onClick={loadWorks}>重试</Button>}
/>
) : null}
<Spin spinning={worksPending}>
{works.length ? (
<Row gutter={[16, 16]}>
{works.map((work) => (
<Col key={work.id} xs={24} sm={12} lg={8} xl={6}>
<Card
hoverable
style={{ height: '100%' }}
onClick={() => openWorkDetail(work)}
cover={
work.cover_url ? (
<img src={work.cover_url} alt={work.title || '作品封面'} style={{ width: '100%', aspectRatio: '16 / 9', objectFit: 'cover' }} />
) : (
<Flex align="center" justify="center" style={{ aspectRatio: '16 / 9', background: '#f5f5f5' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>无封面</Typography.Text>
</Flex>
)
}
>
<Flex vertical gap={8} style={{ minWidth: 0 }}>
<Typography.Paragraph strong ellipsis={{ rows: 2 }} style={{ marginBottom: 0 }}>
{work.title || '无标题'}
</Typography.Paragraph>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{work.published_at ? dateTime(work.published_at) : '发布时间待核验'}
</Typography.Text>
<Flex gap={16}>
<Typography.Text style={{ fontSize: 12 }}><LikeOutlined /> {work.likes ?? '—'}</Typography.Text>
<Typography.Text style={{ fontSize: 12 }}><CommentOutlined /> {work.comments_count ?? '—'}</Typography.Text>
<Typography.Text style={{ fontSize: 12 }}><ShareAltOutlined /> {work.shares ?? '—'}</Typography.Text>
</Flex>
</Flex>
</Card>
</Col>
))}
</Row>
) : (
<Empty description={worksPending ? '加载中…' : '暂无作品;新账号会在下一轮自动采集中拉取作品。'} />
)}
</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>
</Flex>
) : null}
</Card>
<Drawer open={Boolean(detailWork)} onClose={() => setDetailWork(null)} size="large" title="作品统计">
{detailWork ? (
<Flex vertical gap={12}>
{detailWork.cover_url ? (
<img src={detailWork.cover_url} alt="作品封面" style={{ maxHeight: 256, borderRadius: 4, border: '1px solid #f0f0f0' }} />
) : null}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>ID:{detailWork.work_key || detailWork.id}</Typography.Text>
<Typography.Paragraph strong style={{ marginBottom: 0 }}>{detailWork.title || '无标题'}</Typography.Paragraph>
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>
{detailWork.body || '暂无正文'}
</Typography.Paragraph>
<Space wrap>
<Typography.Text type="secondary">发布:{detailWork.published_at ? dateTime(detailWork.published_at) : '—'}</Typography.Text>
{detailWork.original_url ? (
<Typography.Link href={detailWork.original_url} target="_blank">打开原文</Typography.Link>
) : null}
</Space>
<Table
rowKey="collected_at"
size="small"
loading={metricsPending}
pagination={false}
dataSource={workMetrics}
columns={[
{ title: '采集时间', dataIndex: 'collected_at', render: (value: string) => dateTime(value) },
{ title: '点赞', dataIndex: 'likes', render: (v) => v ?? '—' },
{ title: '评论', dataIndex: 'comments_count', render: (v) => v ?? '—' },
{ title: '转发', dataIndex: 'shares', render: (v) => v ?? '—' },
]}
locale={{ emptyText: '暂无指标记录。' }}
/>
</Flex>
) : null}
</Drawer>
</Flex>
);
}
+13
View File
@@ -1,6 +1,7 @@
// 领域 API 层:资源路径与语义逐一对照 web.archived/src/shared/api/dataProvider.js。
// 全部经由 requestErrorConfig.request(Basic Auth + 401 全局登出)。
import { AUTH_STORAGE_KEY, RequestError, jsonOptions, request, unauthorized } from '@/requestErrorConfig';
import { isCollectionAccount, platformLabel } from '@/utils/helpers';
export const resourcePaths = {
browsers: '/browsers',
@@ -149,6 +150,18 @@ export function creatorGet(path: string) {
return request(path);
}
// 监控账号同步:自动挑选可用的自有采集账号(登录且授权)作为执行身份。
export async function syncCompetitorAuto(competitor: { id: string; platform: string }): Promise<void> {
const result = await getList({ resource: 'creator-accounts' });
const account = (result.data ?? []).find(
(candidate: any) => candidate.platform === competitor.platform && isCollectionAccount(candidate),
);
if (!account) {
throw new Error(`没有可用于同步${platformLabel(competitor.platform)}监控账号的已登录自有账号。`);
}
await creatorAction(`/creator/competitors/${encodeURIComponent(competitor.id)}/sync`, { account_id: account.id });
}
export function creatorUpdate(path: string, data: unknown) {
return request(path, jsonOptions('PUT', data));
}
+1
View File
@@ -19,6 +19,7 @@ const rules: Array<{ test: (path: string) => boolean; title: string; subtitle: s
{ test: (p) => p === '/accounts/import', title: '账号导入', subtitle: '管理分享链接解析任务,解析成功后自动加入监控账号。' },
{ test: (p) => p === '/accounts', title: '我的账号', subtitle: '创建和管理自己维护的账号。' },
{ test: (p) => p === '/accounts/monitoring', title: '监控账号', subtitle: '管理需要持续跟踪的竞品账号及采集状态。' },
{ test: (p) => /^\/accounts\/monitoring\/.+/.test(p), title: '监控账号详情', subtitle: '查看监控账号画像与作品列表,点击作品可追溯指标统计。' },
{ test: (p) => p === '/accounts/new', title: '创建社媒账号', subtitle: '创建账号后,再在编辑页配置登录身份与账号策略。' },
{ test: (p) => /\/edit$/.test(p) && p.startsWith('/accounts/'), title: '编辑社媒账号', subtitle: '维护账号资料、登录核验与自动响应策略。' },
{ test: (p) => p.startsWith('/accounts/'), title: '账号详情', subtitle: '查看账号状态、登录身份与运行环境绑定。' },