diff --git a/web/src/components/accounts/MonitoringAccountList.tsx b/web/src/components/accounts/MonitoringAccountList.tsx index 8dc4020..429907c 100644 --- a/web/src/components/accounts/MonitoringAccountList.tsx +++ b/web/src/components/accounts/MonitoringAccountList.tsx @@ -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 ( -
+
history.push(`/accounts/monitoring/${row.id}`)} + > @@ -197,7 +193,7 @@ export default function MonitoringAccountList() {
- + e.stopPropagation()}> diff --git a/web/src/pages/accounts/monitoring/$id/index.tsx b/web/src/pages/accounts/monitoring/$id/index.tsx new file mode 100644 index 0000000..b298978 --- /dev/null +++ b/web/src/pages/accounts/monitoring/$id/index.tsx @@ -0,0 +1,330 @@ +// 监控账号详情:形态对齐账号详情页(页头槽 + 卡片流)—— +// 页头 info 为账号标识(头像 + 昵称 + 同步状态 + 平台),actions 为返回/同步/暂停恢复; +// 主体两张 Card:账号画像(Descriptions)与作品列表(Row/Col card 网格,分页)。 +// 点击作品弹出 Drawer 展示统计信息:作品详情 + 指标采集记录表。 +// 作品列表走 GET /creator/works?source_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 = { + 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(null); + const [pending, setPending] = useState(true); + const [error, setError] = useState(null); + const [actionBusy, setActionBusy] = useState(false); + const [works, setWorks] = useState([]); + const [workPage, setWorkPage] = useState(1); + const [workPageInfo, setWorkPageInfo] = useState({ total: 0, hasNext: false }); + const [worksPending, setWorksPending] = useState(true); + const [worksError, setWorksError] = useState(null); + const [detailWork, setDetailWork] = useState(null); + const [workMetrics, setWorkMetrics] = useState([]); + const [metricsPending, setMetricsPending] = useState(false); + const [messageApi, contextHolder] = message.useMessage(); + + const loadCompetitor = useCallback(async () => { + setPending(true); + setError(null); + try { + setCompetitor(await getOne('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 ? ( + + + {(competitor.nickname || competitor.platform_account_key).slice(0, 1).toUpperCase()} + + + {competitor.nickname || competitor.platform_account_key} + + {syncMeta.label} + + {competitor.unique_id || competitor.platform_account_key} · {platformLabel(competitor.platform)} + + + ) : null, + [competitor?.id, competitor?.nickname, competitor?.sync_status], + ); + + usePageActions( + competitor ? ( + + + + + + ) : 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 ; + if (error || !competitor) return ; + + return ( + + {contextHolder} + + {competitor.sync_error} + ) : ( + '—' + ), + }, + ]} + /> + + } size="small" onClick={loadWorks} disabled={worksPending} aria-label="刷新作品" /> + } + > + + 共 {workPageInfo.total} 个作品;作品由系统按采集间隔自动同步。 + + {worksError ? ( + 重试} + /> + ) : null} + + {works.length ? ( + + {works.map((work) => ( + + openWorkDetail(work)} + cover={ + work.cover_url ? ( + {work.title + ) : ( + + 无封面 + + ) + } + > + + + {work.title || '无标题'} + + + {work.published_at ? dateTime(work.published_at) : '发布时间待核验'} + + + {work.likes ?? '—'} + {work.comments_count ?? '—'} + {work.shares ?? '—'} + + + + + ))} + + ) : ( + + )} + + {workPageInfo.total > works.length ? ( + + + + + + + ) : null} + + setDetailWork(null)} size="large" title="作品统计"> + {detailWork ? ( + + {detailWork.cover_url ? ( + 作品封面 + ) : null} + ID:{detailWork.work_key || detailWork.id} + {detailWork.title || '无标题'} + + {detailWork.body || '暂无正文'} + + + 发布:{detailWork.published_at ? dateTime(detailWork.published_at) : '—'} + {detailWork.original_url ? ( + 打开原文 + ) : null} + + dateTime(value) }, + { title: '点赞', dataIndex: 'likes', render: (v) => v ?? '—' }, + { title: '评论', dataIndex: 'comments_count', render: (v) => v ?? '—' }, + { title: '转发', dataIndex: 'shares', render: (v) => v ?? '—' }, + ]} + locale={{ emptyText: '暂无指标记录。' }} + /> + + ) : null} + + + ); +} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index cc4e270..5576b60 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -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 { + 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)); } diff --git a/web/src/utils/metadata.tsx b/web/src/utils/metadata.tsx index 653b4ef..8607f8d 100644 --- a/web/src/utils/metadata.tsx +++ b/web/src/utils/metadata.tsx @@ -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: '查看账号状态、登录身份与运行环境绑定。' },