refactor(web): 竞品分析复用 WorksGallery,与监控账号作品列表同一套展现

- WorksGallery 支持 sourceId 可选与 extraFilters 透传
- 竞品页删除自建作品卡片/详情抽屉/分页,仅保留筛选卡
- 修复日期筛选序列化(dayjs 模板串产生非法 RFC3339)
This commit is contained in:
2026-09-30 15:50:18 +08:00
parent 5401093818
commit a9358f5c96
3 changed files with 68 additions and 228 deletions
+8 -4
View File
@@ -105,12 +105,15 @@ function GrowthChart({ metrics }: { metrics: any[] }) {
}
interface WorksGalleryProps {
sourceId: string;
/** 不传则不过滤来源(如竞品分析页跨全部竞品);监控/自有账号详情传具体账号 ID。 */
sourceId?: string;
sourceType?: 'owned' | 'competitor';
emptyHint?: string;
/** 页面自有筛选(如竞品分析页的互动量/发布时间条件)透传到作品列表查询。 */
extraFilters?: Record<string, string | number | undefined>;
}
export default function WorksGallery({ sourceId, sourceType, emptyHint }: WorksGalleryProps) {
export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFilters }: WorksGalleryProps) {
const [works, setWorks] = useState<any[]>([]);
const [workPage, setWorkPage] = useState(1);
const [workPageInfo, setWorkPageInfo] = useState({ total: 0, hasNext: false });
@@ -131,7 +134,8 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint }: WorksG
setWorksPending(true);
setWorksError(null);
try {
const filters: Record<string, string | number> = { source_id: sourceId };
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 设下限。
@@ -146,7 +150,7 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint }: WorksG
} finally {
setWorksPending(false);
}
}, [sourceId, sourceType, workPage, minGrowth]);
}, [sourceId, sourceType, workPage, minGrowth, extraFilters]);
useEffect(() => {
loadWorks();
+53 -224
View File
@@ -1,12 +1,10 @@
// 竞品分析:作品列表对齐 ant-design-pro card-list——antd Row/Col 响应式栅格 + Card 原样组合;
// 作品详情走 Drawer;日期筛选用 antd DatePicker;筛选自动刷新、手动翻页不变。
// 封面读本地缓存(/works/:id/cover,认证 fetch + blob)。发布状态仅用于筛选,不在卡片上展示(对齐监控账号作品列表)。
import { useEffect, useState } from 'react';
import { CommentOutlined, LikeOutlined, ShareAltOutlined } from '@ant-design/icons';
import { Alert, App, Button, Card, Col, DatePicker, Drawer, Empty, Flex, InputNumber, Row, Select, Space, Spin, Table, Typography } from 'antd';
// 竞品分析:跨全部竞品的作品筛选与列表。
// 作品列表/详情/指标/评论复用共享组件 WorksGallery(与监控账号、自有账号详情页同一套展现),
// 本页仅保留作品筛选卡,筛选条件经 extraFilters 透传(后端 workFilter 支持同名 query 参数)。
import { useMemo, useState } from 'react';
import { Card, DatePicker, Flex, InputNumber, Select, Space, Typography } from 'antd';
import type { Dayjs } from 'dayjs';
import { creatorGet, fetchCreatorBinary, getList } from '@/services/api';
import { conflictMessage, dateTime } from '@/utils/helpers';
import WorksGallery from '@/components/creator/WorksGallery';
const publishedAtStatusOptions = [
{ value: 'verified', label: '已核验' },
@@ -24,223 +22,54 @@ interface Filters {
publishedBefore?: Dayjs;
}
// 作品封面:从本地缓存接口读取(认证 fetch + blob URL);缓存缺失时展示占位。
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: '16 / 9', objectFit: 'cover' }} />;
}
export default function Page() {
const [filters, setFilters] = useState<Filters>({});
// 序列化为后端 workFilter 的 query 参数;日期取本地日历日的 00:00/23:59(UTC 边界)。
const extraFilters = useMemo(
() => ({
min_likes: filters.minLikes,
min_comments: filters.minComments,
min_shares: filters.minShares,
published_at_status: filters.publishedAtStatus,
published_after: filters.publishedAfter ? `${filters.publishedAfter.format('YYYY-MM-DD')}T00:00:00Z` : undefined,
published_before: filters.publishedBefore ? `${filters.publishedBefore.format('YYYY-MM-DD')}T23:59:59Z` : undefined,
}),
[filters],
);
return (
<Flex align="center" justify="center" style={{ aspectRatio: '16 / 9', background: '#f5f5f5' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{missing ? '封面未缓存' : '封面加载中'}</Typography.Text>
<Flex vertical gap={16}>
<Card title="筛选作品" extra={<Typography.Text type="secondary" style={{ fontSize: 12 }}>调整条件后自动刷新</Typography.Text>}>
<Space size={12} wrap style={{ display: 'flex' }}>
<InputNumber style={{ width: 130 }} placeholder="最低点赞" min={0} value={filters.minLikes} onChange={(value) => setFilters((f) => ({ ...f, minLikes: value ?? undefined }))} />
<InputNumber style={{ width: 130 }} placeholder="最低评论" min={0} value={filters.minComments} onChange={(value) => setFilters((f) => ({ ...f, minComments: value ?? undefined }))} />
<InputNumber style={{ width: 130 }} placeholder="最低转发" min={0} value={filters.minShares} onChange={(value) => setFilters((f) => ({ ...f, minShares: value ?? undefined }))} />
<Select
style={{ width: 140 }}
placeholder="全部状态"
allowClear
value={filters.publishedAtStatus}
onChange={(value) => setFilters((f) => ({ ...f, publishedAtStatus: value }))}
options={publishedAtStatusOptions}
/>
<DatePicker
style={{ width: 160 }}
placeholder="发布开始日期"
value={filters.publishedAfter}
onChange={(value) => setFilters((f) => ({ ...f, publishedAfter: value ?? undefined }))}
allowClear
/>
<DatePicker
style={{ width: 160 }}
placeholder="发布结束日期"
value={filters.publishedBefore}
onChange={(value) => setFilters((f) => ({ ...f, publishedBefore: value ?? undefined }))}
allowClear
/>
</Space>
</Card>
<WorksGallery emptyHint="暂无符合条件的作品。" extraFilters={extraFilters} />
</Flex>
);
}
export default function Page() {
const [works, setWorks] = useState<any[]>([]);
const [workPage, setWorkPage] = useState(1);
const [workPageInfo, setWorkPageInfo] = useState({ total: 0, hasNext: false });
const [filters, setFilters] = useState<Filters>({});
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(null);
const [workDetail, setWorkDetail] = useState<any>(null);
const [workMetrics, setWorkMetrics] = useState<any[]>([]);
const [workDetailPending, setWorkDetailPending] = useState(false);
const { message: messageApi } = App.useApp();
async function load() {
setPending(true);
setError(null);
try {
const result = await getList({
resource: 'creator-works',
page: workPage,
pageSize: 25,
filters: {
min_likes: filters.minLikes === undefined ? undefined : Number(filters.minLikes),
min_comments: filters.minComments === undefined ? undefined : Number(filters.minComments),
min_shares: filters.minShares === undefined ? undefined : Number(filters.minShares),
published_at_status: filters.publishedAtStatus,
published_after: filters.publishedAfter ? `${filters.publishedAfter}T00:00:00Z` : undefined,
published_before: filters.publishedBefore ? `${filters.publishedBefore}T23:59:59Z` : undefined } });
setWorks(result.data);
setWorkPageInfo({ total: result.total, hasNext: Boolean(result.hasNext) });
} catch (loadError) {
setError(loadError);
} finally {
setPending(false);
}
}
// 调整条件后自动刷新;条件变化时回到第一页(对齐归档版两个 useEffect)。
useEffect(() => {
load();
}, [filters, workPage]);
useEffect(() => {
setWorkPage(1);
}, [filters]);
async function openWorkDetail(workID: string) {
setWorkDetailPending(true);
try {
const [detail, metrics] = await Promise.all([
creatorGet(`/creator/works/${encodeURIComponent(workID)}`),
creatorGet(`/creator/works/${encodeURIComponent(workID)}/metrics`),
]);
setWorkDetail(detail);
setWorkMetrics(metrics || []);
} catch (actionError) {
messageApi.error(conflictMessage(actionError, '作品详情读取失败'));
} finally {
setWorkDetailPending(false);
}
}
return (
<>
<Flex vertical gap={16}>
<Card title="筛选作品" extra={<Typography.Text type="secondary" style={{ fontSize: 12 }}>调整条件后自动刷新</Typography.Text>}>
<Space size={12} wrap style={{ display: 'flex' }}>
<InputNumber style={{ width: 130 }} placeholder="最低点赞" min={0} value={filters.minLikes} onChange={(value) => setFilters((f) => ({ ...f, minLikes: value ?? undefined }))} />
<InputNumber style={{ width: 130 }} placeholder="最低评论" min={0} value={filters.minComments} onChange={(value) => setFilters((f) => ({ ...f, minComments: value ?? undefined }))} />
<InputNumber style={{ width: 130 }} placeholder="最低转发" min={0} value={filters.minShares} onChange={(value) => setFilters((f) => ({ ...f, minShares: value ?? undefined }))} />
<Select
style={{ width: 140 }}
placeholder="全部状态"
allowClear
value={filters.publishedAtStatus}
onChange={(value) => setFilters((f) => ({ ...f, publishedAtStatus: value }))}
options={publishedAtStatusOptions}
/>
<DatePicker
style={{ width: 160 }}
placeholder="发布开始日期"
value={filters.publishedAfter}
onChange={(value) => setFilters((f) => ({ ...f, publishedAfter: value ?? undefined }))}
allowClear
/>
<DatePicker
style={{ width: 160 }}
placeholder="发布结束日期"
value={filters.publishedBefore}
onChange={(value) => setFilters((f) => ({ ...f, publishedBefore: value ?? undefined }))}
allowClear
/>
</Space>
</Card>
<Card title="作品与指标" extra={<Typography.Text type="secondary">共 {workPageInfo.total} 个作品</Typography.Text>}>
{error ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
) : null}
<Spin spinning={pending}>
{works.length ? (
<Row gutter={[16, 16]}>
{works.map((work) => (
<Col key={work.id} xs={24} sm={12} lg={8} xl={6}>
<Card
style={{ height: '100%' }}
styles={{ body: { padding: '0 0 12px' } }}
cover={<WorkCover workID={work.id} title={work.title} />}
actions={[
<Button key="detail" type="link" size="small" onClick={() => openWorkDetail(work.id)} disabled={workDetailPending}>
详情
</Button>,
...(work.original_url ? [<Typography.Link key="origin" href={work.original_url} target="_blank">原文</Typography.Link>] : []),
]}
>
<div style={{ padding: '12px 16px 0' }}>
<Flex vertical gap={8} style={{ minWidth: 0 }}>
{work.title ? (
<Typography.Paragraph strong ellipsis={{ rows: 2 }} style={{ marginBottom: 0 }}>
{work.title}
</Typography.Paragraph>
) : null}
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
作者:{work.author_name || '—'}
</Typography.Text>
<Space size={8} wrap>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{work.published_at ? dateTime(work.published_at) : '—'}
</Typography.Text>
</Space>
</Flex>
</div>
{/* 点赞/评论/分享全宽贴在封面下方(body padding 置 0,本行自带上下边框) */}
<Flex gap={16} justify="space-between" style={{ margin: '10px 0 0', padding: '8px 16px', borderTop: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0' }}>
<Typography.Text style={{ fontSize: 12 }}><LikeOutlined /> {work.likes ?? '—'}</Typography.Text>
<Typography.Text style={{ fontSize: 12 }}><CommentOutlined /> {work.comments_count ?? '—'}</Typography.Text>
<Typography.Text style={{ fontSize: 12 }}><ShareAltOutlined /> {work.shares ?? '—'}</Typography.Text>
</Flex>
</Card>
</Col>
))}
</Row>
) : (
<Empty description="暂无符合条件的作品。" />
)}
</Spin>
{workPageInfo.total > works.length ? (
<Flex justify="flex-end" style={{ marginTop: 12 }}>
<Space>
<Button size="small" onClick={() => setWorkPage((page) => Math.max(1, page - 1))} disabled={workPage === 1 || pending}>
上一页
</Button>
<Button size="small" onClick={() => setWorkPage((page) => page + 1)} disabled={!workPageInfo.hasNext || pending}>
下一页
</Button>
</Space>
</Flex>
) : null}
</Card>
</Flex>
<Drawer open={Boolean(workDetail)} onClose={() => setWorkDetail(null)} size="large" title="作品详情">
{workDetail ? (
<Flex vertical gap={12}>
<WorkCover workID={workDetail.id} title={workDetail.title || '作品封面'} />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>ID:{workDetail.work_key || workDetail.id}</Typography.Text>
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{workDetail.body || workDetail.title || '暂无正文'}</Typography.Paragraph>
<Space wrap>
<Typography.Text type="secondary">来源:{workDetail.source_type || '—'}</Typography.Text>
{workDetail.original_url ? (
<Typography.Link href={workDetail.original_url} target="_blank">
打开原文
</Typography.Link>
) : null}
</Space>
<Table
rowKey="collected_at"
size="small"
pagination={false}
dataSource={workMetrics}
columns={[
{ title: '采集时间', dataIndex: 'collected_at', render: (value: string) => dateTime(value) },
{ title: '点赞', dataIndex: 'likes', render: (v) => v ?? '—' },
{ title: '评论', dataIndex: 'comments_count', render: (v) => v ?? '—' },
{ title: '转发', dataIndex: 'shares', render: (v) => v ?? '—' },
]}
locale={{ emptyText: '暂无指标记录。' }}
/>
</Flex>
) : null}
</Drawer>
</>
);
}
+7
View File
@@ -48,3 +48,10 @@ function sourceText() {
return source.getFullText();
}
const competitorsPageText = readFileSync(`${__dirname}/../src/pages/creator/competitors/index.tsx`, 'utf8');
test('竞品分析页复用 WorksGallery,不再自建作品卡片/详情抽屉', () => {
assert.match(competitorsPageText, /import WorksGallery from '@\/components\/creator\/WorksGallery'/);
assert.doesNotMatch(competitorsPageText, /\bWorkCover\b|<Drawer|publishedAtStatusMeta|statusTag/);
});