Files
creator-hub/web/src/components/creator/WorksGallery.tsx
T

489 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 作品数据共享组件:作品 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>
</>
);
}