refactor(web): 竞品分析复用 WorksGallery,与监控账号作品列表同一套展现
- WorksGallery 支持 sourceId 可选与 extraFilters 透传 - 竞品页删除自建作品卡片/详情抽屉/分页,仅保留筛选卡 - 修复日期筛选序列化(dayjs 模板串产生非法 RFC3339)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user