489 lines
20 KiB
TypeScript
489 lines
20 KiB
TypeScript
// 作品数据共享组件:作品 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<string | null>(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 <img src={src} alt={title || '作品封面'} style={{ width: '100%', aspectRatio: '3 / 4', objectFit: 'cover' }} />;
|
||
}
|
||
return (
|
||
<Flex align="center" justify="center" style={{ aspectRatio: '3 / 4', background: '#f5f5f5' }}>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
{missing ? '封面未缓存' : '封面加载中'}
|
||
</Typography.Text>
|
||
</Flex>
|
||
);
|
||
}
|
||
|
||
// 竞品突出互动指标;自有账号额外展示观看量。缺失值与 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 (
|
||
<Flex
|
||
style={{
|
||
padding: '6px 0',
|
||
borderTop: '1px solid #f0f0f0',
|
||
borderBottom: '1px solid #f0f0f0',
|
||
}}
|
||
>
|
||
{items.map(({ key, label, icon: Icon }) => (
|
||
<Flex key={key} vertical align="center" gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||
<Typography.Text strong style={{ fontSize: 14, lineHeight: '20px' }}>
|
||
{work[key] ?? '—'}
|
||
</Typography.Text>
|
||
<Typography.Text type="secondary" style={{ fontSize: 11, lineHeight: '16px' }}>
|
||
<Icon /> {label}
|
||
</Typography.Text>
|
||
</Flex>
|
||
))}
|
||
</Flex>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Line
|
||
data={data}
|
||
xField="time"
|
||
yField="value"
|
||
colorField="metric"
|
||
height={240}
|
||
yAxis={{
|
||
labelFormatter: (v: number) => (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<string, string | number | undefined>;
|
||
/** 分析页在外部筛选卡中统一管理增量筛选,避免重复入口。 */
|
||
showGrowthFilter?: boolean;
|
||
}
|
||
|
||
export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFilters, showGrowthFilter = true }: WorksGalleryProps) {
|
||
const [works, setWorks] = useState<any[]>([]);
|
||
const [pageState, setPageState] = useState({ filterKey: '', page: 1 });
|
||
const [worksRefresh, setWorksRefresh] = useState(0);
|
||
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);
|
||
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<any>(null);
|
||
const [workMetrics, setWorkMetrics] = useState<any[]>([]);
|
||
const [metricsPending, setMetricsPending] = useState(false);
|
||
const [comments, setComments] = useState<any[]>([]);
|
||
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<string, string | number | undefined> = {
|
||
...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 (
|
||
<>
|
||
<Card title="作品列表" extra={<Button icon={<ReloadOutlined />} size="small" onClick={loadWorks} disabled={worksPending} aria-label="刷新作品" />}>
|
||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||
<Flex vertical gap={4}>
|
||
{sourceType === 'owned' && sourceId ? <Typography.Text>{progressHint}</Typography.Text> : null}
|
||
<Typography.Text type="secondary">
|
||
{worksError
|
||
? '作品列表暂不可用'
|
||
: `${minGrowth !== null || extraFilters ? '符合筛选' : sourceType === 'owned' ? '当前列表' : '共'} ${workPageInfo.total} 个作品`}
|
||
;作品由系统按采集间隔自动同步。
|
||
</Typography.Text>
|
||
</Flex>
|
||
{showGrowthFilter ? (
|
||
<Flex align="center" gap={8}>
|
||
<FireOutlined style={{ color: 'rgba(0,0,0,0.45)' }} />
|
||
<Typography.Text type="secondary">24h 点赞增量 ≥</Typography.Text>
|
||
<InputNumber
|
||
size="small"
|
||
min={0}
|
||
placeholder="不限"
|
||
style={{ width: 96 }}
|
||
value={minGrowth}
|
||
onChange={(value) => {
|
||
setMinGrowth(value ?? null);
|
||
setWorkPage(1);
|
||
}}
|
||
/>
|
||
</Flex>
|
||
) : null}
|
||
</Flex>
|
||
{progressError ? (
|
||
<Alert
|
||
type="error"
|
||
showIcon
|
||
title={progressError.message}
|
||
style={{ marginBottom: 16 }}
|
||
action={
|
||
<Button size="small" onClick={loadWorks}>
|
||
重试
|
||
</Button>
|
||
}
|
||
/>
|
||
) : null}
|
||
{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={{ flex: '20%' }}>
|
||
<Card
|
||
hoverable
|
||
style={{ height: '100%' }}
|
||
styles={{ body: { padding: '0 0 12px' } }}
|
||
onClick={() => openWorkDetail(work)}
|
||
cover={<WorkCover workID={work.id} title={work.title} />}
|
||
>
|
||
<WorkStatsGrid work={work} showViews={sourceType !== 'competitor'} />
|
||
<div style={{ padding: '10px 16px 0' }}>
|
||
<Flex vertical gap={8} style={{ minWidth: 0 }}>
|
||
{/* 无标题作品不渲染标题行,避免占位文案增加卡片噪音。 */}
|
||
{work.title ? (
|
||
<Typography.Paragraph strong ellipsis={{ rows: 2, tooltip: true }} style={{ marginBottom: 0 }}>
|
||
{work.title}
|
||
</Typography.Paragraph>
|
||
) : null}
|
||
{work.body ? (
|
||
<Typography.Paragraph type="secondary" ellipsis={{ rows: 2, tooltip: true }} style={{ fontSize: 12, marginBottom: 0 }}>
|
||
{work.body}
|
||
</Typography.Paragraph>
|
||
) : null}
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
{work.published_at ? dateTime(work.published_at) : '发布时间待核验'}
|
||
</Typography.Text>
|
||
</Flex>
|
||
</div>
|
||
{work.likes_growth != null || extraFilters?.growth_hours ? (
|
||
<Typography.Text
|
||
type="secondary"
|
||
style={{
|
||
display: 'block',
|
||
padding: '8px 16px 0',
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
<FireOutlined /> 24h 点赞增量 {work.likes_growth == null ? '—' : `+${work.likes_growth}`}
|
||
{work.likes_growth_coverage === 'partial' ? '(样本不足)' : ''}
|
||
</Typography.Text>
|
||
) : null}
|
||
</Card>
|
||
</Col>
|
||
))}
|
||
</Row>
|
||
) : (
|
||
<Empty description={worksPending ? '加载中…' : emptyHint || '暂无作品;新账号会在下一轮自动采集中拉取作品。'} />
|
||
)}
|
||
</Spin>
|
||
{workPageInfo.total > works.length ? (
|
||
<Flex justify="flex-end" style={{ marginTop: 12 }}>
|
||
<Pagination
|
||
current={workPage}
|
||
pageSize={25}
|
||
total={workPageInfo.total}
|
||
showSizeChanger={false}
|
||
showTotal={(total) => `${sourceType === 'owned' ? '当前列表共' : '共'} ${total} 个作品`}
|
||
disabled={worksPending}
|
||
onChange={(page) => setWorkPage(page)}
|
||
/>
|
||
</Flex>
|
||
) : null}
|
||
</Card>
|
||
<Drawer open={Boolean(detailWork)} onClose={() => setDetailWork(null)} size="large" title="作品统计">
|
||
{detailWork ? (
|
||
<Flex vertical gap={12}>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
ID:{detailWork.work_key || detailWork.id}
|
||
</Typography.Text>
|
||
{detailWork.title ? (
|
||
<Typography.Paragraph strong style={{ marginBottom: 0 }}>
|
||
{detailWork.title}
|
||
</Typography.Paragraph>
|
||
) : null}
|
||
<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>
|
||
{workMetrics.length >= 2 ? <GrowthChart metrics={workMetrics} /> : null}
|
||
<Table
|
||
rowKey="collected_at"
|
||
size="small"
|
||
loading={metricsPending}
|
||
pagination={false}
|
||
dataSource={workMetrics}
|
||
columns={[
|
||
{
|
||
title: '采集时间',
|
||
dataIndex: 'collected_at',
|
||
render: (value: string) => 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: '暂无指标记录。' }}
|
||
/>
|
||
<Typography.Title level={5} style={{ marginBottom: 0 }}>
|
||
评论
|
||
</Typography.Title>
|
||
<Table
|
||
rowKey="id"
|
||
size="small"
|
||
loading={commentsPending}
|
||
dataSource={comments}
|
||
pagination={{
|
||
current: commentPage,
|
||
pageSize: commentPageSize,
|
||
total: commentTotal,
|
||
// 受控分页必须同时接住 page 和 pageSize:丢掉 pageSize 会让「条/页」下拉选择后立即回弹。
|
||
onChange: (page: number, pageSize: number) => {
|
||
setCommentPage(page);
|
||
setCommentPageSize(pageSize);
|
||
loadComments(detailWork.id, page, pageSize);
|
||
},
|
||
hideOnSinglePage: true,
|
||
}}
|
||
columns={[
|
||
{
|
||
title: '作者 / 时间',
|
||
width: 160,
|
||
render: (_, record) => (
|
||
<Flex vertical gap={2}>
|
||
<Typography.Text>{record.author_name || '—'}</Typography.Text>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
{record.published_at ? dateTime(record.published_at) : '—'}
|
||
</Typography.Text>
|
||
</Flex>
|
||
),
|
||
},
|
||
{ title: '内容', dataIndex: 'content' },
|
||
]}
|
||
locale={{ emptyText: '暂无评论。' }}
|
||
/>
|
||
</Flex>
|
||
) : null}
|
||
</Drawer>
|
||
</>
|
||
);
|
||
}
|