feat(web): 竞品分析页对齐 card-list——作品改 Row/Col+Card 卡片网格、详情 Drawer、移除死平台筛选;修复待核验筛选值 unverified→pending_verification

This commit is contained in:
2026-09-24 18:36:55 +08:00
parent 1a0640f54a
commit 08e813c0ca
+170 -160
View File
@@ -1,25 +1,27 @@
// 竞品分析:语义对齐 web.archived/src/features/creator/competitors/CreatorCompetitorsPage.jsx(提交 d97cade)。
// 仅用 antd 默认组件;筛选自动刷新、手动上一页/下一页(后端 has_next)、素材三步处理与仿写流程原样保留。
// 竞品分析:作品列表对齐 ant-design-pro card-list——antd Row/Col 响应式栅格 + Card 原样组合;
// 作品详情改走 Drawer;素材三步处理与仿写流程内联卡片原样保留;筛选自动刷新、手动翻页不变。
// 平台筛选已随 migration 038(platform 收敛 douyin)移除。
import { useEffect, useState } from 'react';
import { Flex, Alert, Button, Card, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CommentOutlined, LikeOutlined, ShareAltOutlined } from '@ant-design/icons';
import { Flex, Alert, Button, Card, Col, Drawer, Empty, Input, InputNumber, Modal, Row, Select, Space, Spin, Table, Tag, Typography, message } from 'antd';
import { creatorAction, creatorGet, creatorUpdate, getList } from '@/services/api';
import { conflictMessage, dateTime } from '@/utils/helpers';
const platformOptions = [
{ value: 'douyin', label: '抖音' },
{ value: 'xiaohongshu', label: '小红书' },
];
const publishedAtStatusOptions = [
{ value: 'verified', label: '已核验' },
{ value: 'future', label: '未来时间' },
{ value: 'unverified', label: '待核验' },
{ value: 'pending_verification', label: '待核验' },
{ value: 'invalid', label: '无效时间' },
];
const publishedAtStatusMeta: Record<string, { color?: string; label: string }> = {
verified: { color: 'green', label: '已核验' },
future: { color: 'orange', label: '未来时间' },
pending_verification: { label: '待核验' },
invalid: { color: 'red', label: '无效时间' },
};
interface Filters {
platform?: string;
minLikes?: number;
minComments?: number;
minShares?: number;
@@ -28,8 +30,13 @@ interface Filters {
publishedBefore?: string;
}
function statusTag(tone: 'success' | 'error' | 'default', label: string) {
return <Tag color={tone === 'default' ? undefined : tone}>{label}</Tag>;
function statusTag(status?: string) {
const meta = publishedAtStatusMeta[status || ''] || { label: status || '—' };
return <Tag color={meta.color}>{meta.label}</Tag>;
}
function stepTag(status: string, label: string) {
return <Tag color={status === 'succeeded' ? 'success' : status === 'failed' ? 'error' : undefined}>{label}</Tag>;
}
export default function Page() {
@@ -59,7 +66,6 @@ export default function Page() {
page: workPage,
pageSize: 25,
filters: {
platform: filters.platform,
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),
@@ -201,60 +207,6 @@ export default function Page() {
}
}
const columns: ColumnsType<any> = [
{
title: '作品',
dataIndex: 'title',
width: 280,
render: (_, work) => (
<div style={{ minWidth: 0 }}>
<Button type="link" size="small" style={{ padding: 0, height: 'auto' }} onClick={() => openWorkDetail(work.id)} disabled={workDetailPending}>
<Typography.Text strong ellipsis style={{ maxWidth: 260 }}>
{work.title || '无标题'}
</Typography.Text>
</Button>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 12 }} ellipsis>
{work.author_name || work.source_id} · {work.published_at_status}
</Typography.Paragraph>
</div>
),
},
{
title: '发布时间',
dataIndex: 'published_at',
render: (value?: string) => (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{value ? dateTime(value) : '待核验'}
</Typography.Text>
),
},
{ title: '点赞 / 评论 / 转发', key: 'metrics', render: (_, work) => <Typography.Text style={{ fontSize: 12 }}>{work.likes ?? '—'} / {work.comments_count ?? '—'} / {work.shares ?? '—'}</Typography.Text> },
{
title: '指标下次采集',
dataIndex: 'next_metric_at',
render: (value?: string, work?: any) => (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{value ? dateTime(value) : work?.metric_stop_reason || '—'}
</Typography.Text>
),
},
{
title: '操作',
key: 'actions',
align: 'right',
render: (_, work) => (
<Space>
<Button size="small" onClick={() => openWorkDetail(work.id)} disabled={workDetailPending}>
详情
</Button>
<Button size="small" type="primary" onClick={() => selectMaterial(work.id)}>
选择素材
</Button>
</Space>
),
},
];
const materialReadyForConfirm =
material &&
material.download_status === 'succeeded' &&
@@ -262,19 +214,11 @@ export default function Page() {
!['not_started', 'running', 'failed'].includes(material.transcription_status);
return (
<div>
<>
{contextHolder}
<Flex>
<Card title="筛选作品" extra={<Typography.Text type="secondary" style={{ fontSize: 12 }}>调整条件后自动刷新</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' }}>
<Select
style={{ width: 140 }}
placeholder="全部平台"
allowClear
value={filters.platform}
onChange={(value) => setFilters((f) => ({ ...f, platform: value }))}
options={platformOptions}
/>
<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 }))} />
@@ -289,72 +233,102 @@ export default function Page() {
<Input type="date" style={{ width: 160 }} value={filters.publishedAfter} onChange={(e) => setFilters((f) => ({ ...f, publishedAfter: e.target.value }))} />
<Input type="date" style={{ width: 160 }} value={filters.publishedBefore} onChange={(e) => setFilters((f) => ({ ...f, publishedBefore: e.target.value }))} />
</Space>
</Card>
<Card title="作品与指标">
{error ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
) : null}
<Table rowKey="id" columns={columns} dataSource={works} loading={pending} pagination={false} locale={{ emptyText: '暂无符合条件的作品。' }} />
{workPageInfo.total > works.length ? (
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end' }}>
<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>
</div>
) : null}
</Card>
{workDetail ? (
<Card
size="small"
title={<span>作品详情 <Typography.Text type="secondary" style={{ fontSize: 12, fontWeight: 'normal' }}>{workDetail.work_key || workDetail.id}</Typography.Text></span>}
extra={<Button size="small" onClick={() => setWorkDetail(null)}>关闭</Button>}
>
<Flex>
{workDetail.cover_url ? <img src={workDetail.cover_url} alt="作品封面" style={{ maxHeight: 256, borderRadius: 4, border: '1px solid #f0f0f0' }} /> : null}
<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>
<Typography.Text type="secondary">状态:{workDetail.published_at_status || '—'}</Typography.Text>
{workDetail.original_url ? (
<Typography.Link href={workDetail.original_url} target="_blank">
打开原文
</Typography.Link>
) : null}
</Card>
<Card title="作品与指标">
{error ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} title={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
) : null}
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Typography.Text type="secondary">共 {workPageInfo.total} 个作品</Typography.Text>
</Flex>
<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%' }}
cover={
work.cover_url ? (
<img src={work.cover_url} alt={work.title || '作品封面'} style={{ width: '100%', aspectRatio: '16 / 9', objectFit: 'cover' }} />
) : (
<Flex align="center" justify="center" style={{ aspectRatio: '16 / 9', background: '#f5f5f5' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>无封面</Typography.Text>
</Flex>
)
}
actions={[
<Button key="detail" type="link" size="small" onClick={() => openWorkDetail(work.id)} disabled={workDetailPending}>
详情
</Button>,
<Button key="select" type="primary" size="small" onClick={() => selectMaterial(work.id)}>
选择素材
</Button>,
...(work.original_url ? [<Typography.Link key="origin" href={work.original_url} target="_blank">原文</Typography.Link>] : []),
]}
>
<Flex vertical gap={8} style={{ minWidth: 0 }}>
<Typography.Paragraph strong ellipsis={{ rows: 2 }} style={{ marginBottom: 0 }}>
{work.title || '无标题'}
</Typography.Paragraph>
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{work.author_name || work.source_id}
</Typography.Text>
<Space size={8} wrap>
{statusTag(work.published_at_status)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{work.published_at ? dateTime(work.published_at) : '—'}
</Typography.Text>
</Space>
<Flex gap={16}>
<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>
{work.next_metric_at || work.metric_stop_reason ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{work.next_metric_at ? `下次采集 ${dateTime(work.next_metric_at)}` : work.metric_stop_reason}
</Typography.Text>
) : null}
</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>
<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>
</Card>
) : null}
) : null}
</Card>
{material ? (
<Card size="small" title="素材处理">
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>每一步都保留真实状态;失败不会被显示为成功。</Typography.Paragraph>
<Button type="primary" onClick={processMaterial} loading={materialPending || material.download_status === 'running'} style={{ marginBottom: 16 }}>
开始处理素材
</Button>
<Flex>
<Flex vertical gap={12}>
<div>
<Button type="primary" onClick={processMaterial} loading={materialPending || material.download_status === 'running'}>
开始处理素材
</Button>
</div>
<Space wrap>
{statusTag(material.download_status === 'succeeded' ? 'success' : material.download_status === 'failed' ? 'error' : 'default', `下载:${material.download_status}`)}
{statusTag(material.audio_status === 'succeeded' ? 'success' : material.audio_status === 'failed' ? 'error' : 'default', `音轨:${material.audio_status}`)}
{statusTag(material.transcription_status === 'succeeded' ? 'success' : material.transcription_status === 'failed' ? 'error' : 'default', `转写:${material.transcription_status}`)}
{stepTag(material.download_status, `下载:${material.download_status}`)}
{stepTag(material.audio_status, `音轨:${material.audio_status}`)}
{stepTag(material.transcription_status, `转写:${material.transcription_status}`)}
</Space>
{material.failure_reason ? <Alert type="error" showIcon title={`失败步骤:${material.failed_step || '未记录'};${material.failure_reason}`} /> : null}
{material.failure_reason ? (
<Alert type="error" showIcon title={`失败步骤:${material.failed_step || '未记录'};${material.failure_reason}`} />
) : null}
<div>
<Typography.Text strong>仿写要求</Typography.Text>
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 4 }}>必须先确认要求,才允许保存可编辑结果。</Typography.Paragraph>
@@ -363,38 +337,74 @@ export default function Page() {
value={rewriteRequirement}
onChange={(event) => setRewriteRequirement(event.target.value)}
disabled={Boolean(material.rewrite_confirmed_at) || materialPending}
style={{ marginBottom: 12 }}
/>
{material.rewrite_confirmed_at ? (
<Flex>
<Space>
<Button onClick={generateRewrite} disabled={materialPending}>
生成仿写草稿
</Button>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>生成结果不会自动发布,保存前必须人工复核。</Typography.Text>
</Space>
<div>
<Typography.Text>可编辑标题</Typography.Text>
<Input value={rewriteTitle} onChange={(event) => setRewriteTitle(event.target.value)} style={{ marginTop: 4 }} />
</div>
<div>
<Typography.Text>可编辑口播</Typography.Text>
<Input.TextArea rows={5} value={rewriteScript} onChange={(event) => setRewriteScript(event.target.value)} style={{ marginTop: 4 }} />
</div>
</div>
{material.rewrite_confirmed_at ? (
<>
<Flex gap={8} align="center">
<Button onClick={generateRewrite} disabled={materialPending}>
生成仿写草稿
</Button>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>生成结果不会自动发布,保存前必须人工复核。</Typography.Text>
</Flex>
<div>
<Typography.Text>可编辑标题</Typography.Text>
<Input value={rewriteTitle} onChange={(event) => setRewriteTitle(event.target.value)} style={{ marginTop: 4 }} />
</div>
<div>
<Typography.Text>可编辑口播</Typography.Text>
<Input.TextArea rows={5} value={rewriteScript} onChange={(event) => setRewriteScript(event.target.value)} style={{ marginTop: 4 }} />
</div>
<div>
<Button type="primary" onClick={saveRewrite} disabled={materialPending}>
保存仿写结果
</Button>
</Flex>
) : (
</div>
</>
) : (
<div>
<Button type="primary" onClick={confirmRewrite} disabled={materialPending || !materialReadyForConfirm}>
确认仿写要求
</Button>
)}
</div>
</div>
)}
</Flex>
</Card>
) : null}
</Flex>
<Drawer open={Boolean(workDetail)} onClose={() => setWorkDetail(null)} size="large" title="作品详情">
{workDetail ? (
<Flex vertical gap={12}>
{workDetail.cover_url ? (
<img src={workDetail.cover_url} alt="作品封面" style={{ maxHeight: 256, borderRadius: 4, border: '1px solid #f0f0f0' }} />
) : null}
<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>
{statusTag(workDetail.published_at_status)}
{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>
<Modal
open={Boolean(pendingSwitchWork)}
title="放弃未保存的素材文稿"
@@ -405,6 +415,6 @@ export default function Page() {
>
当前素材文稿尚未保存,确定放弃并切换吗?
</Modal>
</div>
</>
);
}