// 作品数据共享组件:作品 Card 网格(分页 + 24h 点赞增速爆品筛选) // + 作品 Drawer(作品详情、增长曲线含播放序列、指标采集记录表含播放列、评论列表)。 // 竞品账号详情页与自有账号详情页共用;数据通道 GET /creator/works?source_id=&source_type=, // 指标 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, Pagination, Row, Space, Spin, Table, Typography } from 'antd'; import { CommentOutlined, EyeOutlined, StarOutlined, FireOutlined, LikeOutlined, ReloadOutlined, ShareAltOutlined } from '@ant-design/icons'; import { creatorAction, creatorGet, fetchCreatorBinary, getList } from '@/services/api'; import { conflictMessage, dateTime } from '@/utils/helpers'; // 作品封面:从本地缓存接口读取(认证 fetch + blob URL);缓存缺失时展示占位。 // 原始 douyinpic URL 带签名会过期,与竞品分析页共用同一条 /works/:id/cover 链路。 export function WorkCover({ workID, title }: { workID: string; title?: string }) { const [src, setSrc] = useState(null); const [missing, setMissing] = useState(false); useEffect(() => { let objectURL: string | null = null; setSrc(null); setMissing(false); fetchCreatorBinary(`/creator/works/${encodeURIComponent(workID)}/cover`) .then((blob) => { objectURL = URL.createObjectURL(blob); setSrc(objectURL); }) .catch(() => setMissing(true)); return () => { if (objectURL) URL.revokeObjectURL(objectURL); }; }, [workID]); if (src) { return {title; } return ( {missing ? '封面未缓存' : '封面加载中'} ); } // 竞品突出互动指标;自有账号额外展示观看量。缺失值与 0 保持区分。 function WorkStatsGrid({ work, showViews }: { work: any; showViews: boolean }) { const items = [ ...(showViews ? [{ key: 'play_count', label: '观看', icon: EyeOutlined }] : []), { key: 'likes', label: '点赞', icon: LikeOutlined }, { key: 'comments_count', label: '评论', icon: CommentOutlined }, { key: 'shares', label: '分享', icon: ShareAltOutlined }, { key: 'collect_count', label: '收藏', icon: StarOutlined }, ]; return ( {items.map(({ key, label, icon: Icon }) => ( {work[key] ?? '—'} {label} ))} ); } const metricSeriesMeta = [ { key: 'play_count', label: '播放' }, { key: 'likes', label: '点赞' }, { key: 'collect_count', label: '收藏' }, { key: 'comments_count', label: '评论' }, { key: 'shares', label: '转发' }, ] as const; // 增长曲线:把指标时序点展开成长表(时间 × 指标 × 数值)供折线图渲染。 function GrowthChart({ metrics }: { metrics: any[] }) { const data = useMemo( () => metrics.flatMap((point) => metricSeriesMeta .filter((series) => point[series.key] != null) .map((series) => ({ time: dateTime(point.collected_at), metric: series.label, value: point[series.key], })), ), [metrics], ); if (!data.length) return null; return ( (v >= 10000 ? `${(v / 10000).toFixed(1)}w` : `${v}`), }} legend={{ position: 'top' }} /> ); } interface CollectionProgress { work_count: number; aweme_count: number | null; works: { status: string; last_error?: string }; } interface WorksGalleryProps { /** 不传则不限定账号;仍按 sourceType 限定来源类型。监控/自有账号详情传具体账号 ID。 */ sourceId?: string; sourceType?: 'owned' | 'competitor'; emptyHint?: string; /** 页面自有筛选与排序条件透传到作品列表查询。 */ extraFilters?: Record; /** 分析页在外部筛选卡中统一管理增量筛选,避免重复入口。 */ showGrowthFilter?: boolean; } export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFilters, showGrowthFilter = true }: WorksGalleryProps) { const [works, setWorks] = useState([]); const [pageState, setPageState] = useState({ filterKey: '', page: 1 }); const [worksRefresh, setWorksRefresh] = useState(0); const [workPageInfo, setWorkPageInfo] = useState({ total: 0 }); const [collectionProgress, setCollectionProgress] = useState(null); const [progressError, setProgressError] = useState(null); const [worksPending, setWorksPending] = useState(true); const [worksError, setWorksError] = useState(null); const [minGrowth, setMinGrowth] = useState(null); const filterKey = JSON.stringify([sourceId, sourceType, extraFilters, minGrowth]); const workPage = pageState.filterKey === filterKey ? pageState.page : 1; const setWorkPage = (page: number) => setPageState({ filterKey, page }); const [detailWork, setDetailWork] = useState(null); const [workMetrics, setWorkMetrics] = useState([]); const [metricsPending, setMetricsPending] = useState(false); const [comments, setComments] = useState([]); const [commentPage, setCommentPage] = useState(1); const [commentPageSize, setCommentPageSize] = useState(10); const [commentTotal, setCommentTotal] = useState(0); const [commentsPending, setCommentsPending] = useState(false); const { message: messageApi } = App.useApp(); const loadWorks = () => setWorksRefresh((value) => value + 1); useEffect(() => { let active = true; async function fetchWorks() { setWorksPending(true); setWorksError(null); setProgressError(null); setCollectionProgress(null); const progressRequest = sourceType === 'owned' && sourceId ? creatorGet(`/creator/accounts/${encodeURIComponent(sourceId)}/collection-status`) .then((progress) => { if (active) setCollectionProgress(progress); }) .catch((error) => { console.error('读取账号作品采集进度失败', { sourceId, sourceType, error, }); if (active) setProgressError(error); }) : Promise.resolve(); try { const filters: Record = { ...extraFilters, }; if (sourceId) filters.source_id = sourceId; if (sourceType) filters.source_type = sourceType; if (minGrowth !== null) { // 爆品筛选:growth_hours 开启后端 24h 点赞增量计算,min_likes_growth 设下限。 filters.growth_hours = 24; filters.min_likes_growth = minGrowth; } const result = await getList({ resource: 'creator-works', page: workPage, pageSize: 25, filters, }); if (active) { setWorks(result.data); setWorkPageInfo({ total: result.total }); } } catch (loadError) { console.error('读取作品列表失败', { sourceId, sourceType, page: workPage, minGrowth, extraFilters, error: loadError, }); if (active) setWorksError(loadError); } finally { await progressRequest; if (active) setWorksPending(false); } } fetchWorks(); return () => { active = false; }; }, [sourceId, sourceType, workPage, minGrowth, extraFilters, worksRefresh]); const loadComments = useCallback(async (workID: string, page: number, pageSize = 10) => { setCommentsPending(true); try { const result = await getList({ resource: 'creator-comments', page, pageSize, filters: { work_id: workID }, }); setComments(result.data); setCommentTotal(result.total); } catch { // 评论加载失败不打断 Drawer 主体(指标数据已在上方),清空并提示。 setComments([]); setCommentTotal(0); } finally { setCommentsPending(false); } }, []); async function openWorkDetail(work: any) { setDetailWork(work); setWorkMetrics([]); setComments([]); setCommentTotal(0); setCommentPage(1); setMetricsPending(true); try { setWorkMetrics((await creatorGet(`/creator/works/${encodeURIComponent(work.id)}/metrics`)) || []); } catch (actionError) { messageApi.error(conflictMessage(actionError, '作品统计读取失败')); } finally { setMetricsPending(false); } 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 ( <> } size="small" onClick={loadWorks} disabled={worksPending} aria-label="刷新作品" />}> {sourceType === 'owned' && sourceId ? {progressHint} : null} {worksError ? '作品列表暂不可用' : `${minGrowth !== null || extraFilters ? '符合筛选' : sourceType === 'owned' ? '当前列表' : '共'} ${workPageInfo.total} 个作品`} ;作品由系统按采集间隔自动同步。 {showGrowthFilter ? ( 24h 点赞增量 ≥ { setMinGrowth(value ?? null); setWorkPage(1); }} /> ) : null} {progressError ? ( 重试 } /> ) : null} {worksError ? ( 重试 } /> ) : null} {works.length ? ( {works.map((work) => ( openWorkDetail(work)} cover={} >
{/* 无标题作品不渲染标题行,避免占位文案增加卡片噪音。 */} {work.title ? ( {work.title} ) : null} {work.body ? ( {work.body} ) : null} {work.published_at ? dateTime(work.published_at) : '发布时间待核验'}
{work.likes_growth != null || extraFilters?.growth_hours ? ( 24h 点赞增量 {work.likes_growth == null ? '—' : `+${work.likes_growth}`} {work.likes_growth_coverage === 'partial' ? '(样本不足)' : ''} ) : null}
))}
) : ( )}
{workPageInfo.total > works.length ? ( `${sourceType === 'owned' ? '当前列表共' : '共'} ${total} 个作品`} disabled={worksPending} onChange={(page) => setWorkPage(page)} /> ) : null}
setDetailWork(null)} size="large" title="作品统计"> {detailWork ? ( ID:{detailWork.work_key || detailWork.id} {detailWork.title ? ( {detailWork.title} ) : null} {detailWork.body || '暂无正文'} 发布: {detailWork.published_at ? dateTime(detailWork.published_at) : '—'} {detailWork.original_url ? ( 打开原文 ) : null} {workMetrics.length >= 2 ? : null} dateTime(value), }, { title: '播放', dataIndex: 'play_count', render: (v) => v ?? '—', }, { title: '点赞', dataIndex: 'likes', render: (v) => v ?? '—' }, { title: '收藏', dataIndex: 'collect_count', render: (v) => v ?? '—', }, { title: '评论', dataIndex: 'comments_count', render: (v) => v ?? '—', }, { title: '转发', dataIndex: 'shares', render: (v) => v ?? '—' }, ]} locale={{ emptyText: '暂无指标记录。' }} /> 评论
{ setCommentPage(page); setCommentPageSize(pageSize); loadComments(detailWork.id, page, pageSize); }, hideOnSinglePage: true, }} columns={[ { title: '作者 / 时间', width: 160, render: (_, record) => ( {record.author_name || '—'} {record.published_at ? dateTime(record.published_at) : '—'} ), }, { title: '内容', dataIndex: 'content' }, ]} locale={{ emptyText: '暂无评论。' }} /> ) : null} ); }