feat: unify owned and competitor work analysis
This commit is contained in:
@@ -5,13 +5,7 @@
|
||||
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,
|
||||
FireOutlined,
|
||||
LikeOutlined,
|
||||
ReloadOutlined,
|
||||
ShareAltOutlined } from '@ant-design/icons';
|
||||
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';
|
||||
|
||||
@@ -39,21 +33,30 @@ export function WorkCover({ workID, title }: { workID: string; title?: string })
|
||||
}
|
||||
return (
|
||||
<Flex align="center" justify="center" style={{ aspectRatio: '3 / 4', background: '#f5f5f5' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{missing ? '封面未缓存' : '封面加载中'}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{missing ? '封面未缓存' : '封面加载中'}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
// 观看/点赞/评论/分享全宽四列 GRID,紧贴封面(卡片 body 顶部,本行自带上下边框)。
|
||||
function WorkStatsGrid({ work }: { work: any }) {
|
||||
// 竞品突出互动指标;自有账号额外展示观看量。缺失值与 0 保持区分。
|
||||
function WorkStatsGrid({ work, showViews }: { work: any; showViews: boolean }) {
|
||||
const items = [
|
||||
{ key: 'play_count', label: '观看', icon: EyeOutlined },
|
||||
...(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' }}>
|
||||
<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' }}>
|
||||
@@ -86,7 +89,8 @@ function GrowthChart({ metrics }: { metrics: any[] }) {
|
||||
.map((series) => ({
|
||||
time: dateTime(point.collected_at),
|
||||
metric: series.label,
|
||||
value: point[series.key] })),
|
||||
value: point[series.key],
|
||||
})),
|
||||
),
|
||||
[metrics],
|
||||
);
|
||||
@@ -98,7 +102,9 @@ function GrowthChart({ metrics }: { metrics: any[] }) {
|
||||
yField="value"
|
||||
colorField="metric"
|
||||
height={240}
|
||||
yAxis={{ labelFormatter: (v: number) => (v >= 10000 ? `${(v / 10000).toFixed(1)}w` : `${v}`) }}
|
||||
yAxis={{
|
||||
labelFormatter: (v: number) => (v >= 10000 ? `${(v / 10000).toFixed(1)}w` : `${v}`),
|
||||
}}
|
||||
legend={{ position: 'top' }}
|
||||
/>
|
||||
);
|
||||
@@ -115,19 +121,25 @@ interface WorksGalleryProps {
|
||||
sourceId?: string;
|
||||
sourceType?: 'owned' | 'competitor';
|
||||
emptyHint?: string;
|
||||
/** 页面自有筛选(如竞品分析页的互动量/发布时间条件)透传到作品列表查询。 */
|
||||
/** 页面自有筛选与排序条件透传到作品列表查询。 */
|
||||
extraFilters?: Record<string, string | number | undefined>;
|
||||
/** 分析页在外部筛选卡中统一管理增量筛选,避免重复入口。 */
|
||||
showGrowthFilter?: boolean;
|
||||
}
|
||||
|
||||
export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFilters }: WorksGalleryProps) {
|
||||
export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFilters, showGrowthFilter = true }: WorksGalleryProps) {
|
||||
const [works, setWorks] = useState<any[]>([]);
|
||||
const [workPage, setWorkPage] = useState(1);
|
||||
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);
|
||||
@@ -138,48 +150,80 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
const [commentsPending, setCommentsPending] = useState(false);
|
||||
const { message: messageApi } = App.useApp();
|
||||
|
||||
const loadWorks = useCallback(async () => {
|
||||
setWorksPending(true);
|
||||
setWorksError(null);
|
||||
setProgressError(null);
|
||||
setCollectionProgress(null);
|
||||
const progressRequest = sourceType === 'owned' && sourceId
|
||||
? creatorGet(`/creator/accounts/${encodeURIComponent(sourceId)}/collection-status`)
|
||||
.then((progress) => setCollectionProgress(progress))
|
||||
.catch((error) => {
|
||||
console.error('读取账号作品采集进度失败', { sourceId, sourceType, error });
|
||||
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 });
|
||||
setWorks(result.data);
|
||||
setWorkPageInfo({ total: result.total });
|
||||
} catch (loadError) {
|
||||
console.error('读取作品列表失败', { sourceId, sourceType, page: workPage, minGrowth, extraFilters, error: loadError });
|
||||
setWorksError(loadError);
|
||||
} finally {
|
||||
await progressRequest;
|
||||
setWorksPending(false);
|
||||
}
|
||||
}, [sourceId, sourceType, workPage, minGrowth, extraFilters]);
|
||||
|
||||
const loadWorks = () => setWorksRefresh((value) => value + 1);
|
||||
useEffect(() => {
|
||||
loadWorks();
|
||||
}, [loadWorks]);
|
||||
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 } });
|
||||
const result = await getList({
|
||||
resource: 'creator-comments',
|
||||
page,
|
||||
pageSize,
|
||||
filters: { work_id: workID },
|
||||
});
|
||||
setComments(result.data);
|
||||
setCommentTotal(result.total);
|
||||
} catch {
|
||||
@@ -222,37 +266,47 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title="作品列表"
|
||||
extra={
|
||||
<Button icon={<ReloadOutlined />} size="small" onClick={loadWorks} disabled={worksPending} aria-label="刷新作品" />
|
||||
}
|
||||
>
|
||||
<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' ? <Typography.Text>{progressHint}</Typography.Text> : null}
|
||||
{sourceType === 'owned' && sourceId ? <Typography.Text>{progressHint}</Typography.Text> : null}
|
||||
<Typography.Text type="secondary">
|
||||
{worksError ? '作品列表暂不可用' : `${minGrowth !== null || extraFilters ? '符合筛选' : sourceType === 'owned' ? '当前列表' : '共'} ${workPageInfo.total} 个作品`};作品由系统按采集间隔自动同步。
|
||||
{worksError
|
||||
? '作品列表暂不可用'
|
||||
: `${minGrowth !== null || extraFilters ? '符合筛选' : sourceType === 'owned' ? '当前列表' : '共'} ${workPageInfo.total} 个作品`}
|
||||
;作品由系统按采集间隔自动同步。
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<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>
|
||||
{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>} />
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title={progressError.message}
|
||||
style={{ marginBottom: 16 }}
|
||||
action={
|
||||
<Button size="small" onClick={loadWorks}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{worksError ? (
|
||||
<Alert
|
||||
@@ -260,7 +314,11 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={worksError.message}
|
||||
action={<Button size="small" onClick={loadWorks}>重试</Button>}
|
||||
action={
|
||||
<Button size="small" onClick={loadWorks}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Spin spinning={worksPending}>
|
||||
@@ -275,7 +333,7 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
onClick={() => openWorkDetail(work)}
|
||||
cover={<WorkCover workID={work.id} title={work.title} />}
|
||||
>
|
||||
<WorkStatsGrid work={work} />
|
||||
<WorkStatsGrid work={work} showViews={sourceType !== 'competitor'} />
|
||||
<div style={{ padding: '10px 16px 0' }}>
|
||||
<Flex vertical gap={8} style={{ minWidth: 0 }}>
|
||||
{/* 无标题作品不渲染标题行,避免占位文案增加卡片噪音。 */}
|
||||
@@ -294,9 +352,17 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</div>
|
||||
{work.likes_growth != null && work.likes_growth > 0 ? (
|
||||
<Typography.Text type="warning" style={{ display: 'block', padding: '8px 16px 0', fontSize: 12 }}>
|
||||
<FireOutlined /> 24h +{work.likes_growth}
|
||||
{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>
|
||||
@@ -324,17 +390,24 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
<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>
|
||||
<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>
|
||||
<Typography.Paragraph strong style={{ marginBottom: 0 }}>
|
||||
{detailWork.title}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>
|
||||
{detailWork.body || '暂无正文'}
|
||||
</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>
|
||||
<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>
|
||||
<Typography.Link href={detailWork.original_url} target="_blank">
|
||||
打开原文
|
||||
</Typography.Link>
|
||||
) : null}
|
||||
</Space>
|
||||
{workMetrics.length >= 2 ? <GrowthChart metrics={workMetrics} /> : null}
|
||||
@@ -345,16 +418,34 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
pagination={false}
|
||||
dataSource={workMetrics}
|
||||
columns={[
|
||||
{ title: '采集时间', dataIndex: 'collected_at', render: (value: string) => dateTime(value) },
|
||||
{ title: '播放', dataIndex: 'play_count', render: (v) => v ?? '—' },
|
||||
{
|
||||
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: '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>
|
||||
<Typography.Title level={5} style={{ marginBottom: 0 }}>
|
||||
评论
|
||||
</Typography.Title>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
@@ -370,7 +461,8 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
|
||||
setCommentPageSize(pageSize);
|
||||
loadComments(detailWork.id, page, pageSize);
|
||||
},
|
||||
hideOnSinglePage: true }}
|
||||
hideOnSinglePage: true,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '作者 / 时间',
|
||||
|
||||
Reference in New Issue
Block a user