feat: unify owned and competitor work analysis

This commit is contained in:
2026-10-06 14:40:32 +08:00
parent ca3cbb8d37
commit fa7b78797d
15 changed files with 851 additions and 280 deletions
+209 -49
View File
@@ -1,75 +1,235 @@
// 竞品分析:跨全部竞品的作品筛选与列表。
// 作品列表/详情/指标/评论复用共享组件 WorksGallery(与监控账号、自有账号详情页同一套展现),
// 本页仅保留作品筛选卡,筛选条件经 extraFilters 透传(后端 workFilter 支持同名 query 参数)。
import { useMemo, useState } from 'react';
import { Card, DatePicker, Flex, InputNumber, Select, Space, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { Alert, App, Button, Card, DatePicker, Flex, Form, InputNumber, Select, Tabs, Typography } from 'antd';
import type { Dayjs } from 'dayjs';
import WorksGallery from '@/components/creator/WorksGallery';
import { creatorGet } from '@/services/api';
import { conflictMessage } from '@/utils/helpers';
type SourceType = 'owned' | 'competitor';
interface Filters {
sourceId?: string;
dates?: [Dayjs, Dayjs];
publishedAtStatus?: string;
minLikes?: number;
minComments?: number;
minShares?: number;
minCollectCount?: number;
minPlayCount?: number;
minLikesGrowth?: number;
sortBy?: string;
sortOrder?: string;
}
interface AccountOption {
id: string;
name?: string;
nickname?: string;
platform_account_key: string;
}
const publishedAtStatusOptions = [
{ value: 'verified', label: '已核验' },
{ value: 'future', label: '未来时间' },
{ value: 'pending_verification', label: '待核验' },
{ value: 'invalid', label: '无效时间' },
];
const metricFilters: { key: keyof Filters; label: string }[] = [
{ key: 'minLikes', label: '最低点赞' },
{ key: 'minComments', label: '最低评论' },
{ key: 'minShares', label: '最低分享' },
{ key: 'minCollectCount', label: '最低收藏' },
{ key: 'minLikesGrowth', label: '最低24h点赞增量' },
];
const commonSortOptions = [
{ value: 'published_at', label: '发布时间' },
{ value: 'likes', label: '点赞' },
{ value: 'comments_count', label: '评论' },
{ value: 'shares', label: '分享' },
{ value: 'collect_count', label: '收藏' },
{ value: 'likes_growth', label: '24h点赞增量' },
];
interface Filters {
minLikes?: number;
minComments?: number;
minShares?: number;
publishedAtStatus?: string;
publishedAfter?: Dayjs;
publishedBefore?: Dayjs;
}
export default function Page() {
// 每个 TAB 独立挂载并保留条件、分页,不混合两种来源。
export function WorkAnalysisTab({ sourceType }: { sourceType: SourceType }) {
const [filters, setFilters] = useState<Filters>({});
const [accounts, setAccounts] = useState<AccountOption[]>([]);
const [accountsPending, setAccountsPending] = useState(true);
const [accountsError, setAccountsError] = useState<unknown>(null);
const [retry, setRetry] = useState(0);
const { message } = App.useApp();
useEffect(() => {
let active = true;
setAccountsPending(true);
setAccountsError(null);
const endpoint = sourceType === 'owned' ? '/creator/accounts' : '/creator/competitors';
creatorGet(endpoint)
.then((result: AccountOption[]) => {
if (active) setAccounts(result);
})
.catch((error) => {
console.error('读取作品分析账号失败', { sourceType, endpoint, error });
if (active) {
setAccountsError(error);
message.error(conflictMessage(error, '账号读取失败'));
}
})
.finally(() => {
if (active) setAccountsPending(false);
});
return () => {
active = false;
};
}, [sourceType, retry, message]);
// 序列化为后端 workFilter 的 query 参数;日期取本地日历日的 00:00/23:59(UTC 边界)。
const extraFilters = useMemo(
() => ({
growth_hours: 24,
sort_by: filters.sortBy ?? 'published_at',
sort_order: filters.sortOrder ?? 'desc',
published_after: filters.dates?.[0].startOf('day').toISOString(),
published_before: filters.dates?.[1].endOf('day').toISOString(),
published_at_status: filters.publishedAtStatus,
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,
min_collect_count: filters.minCollectCount,
min_play_count: sourceType === 'owned' ? filters.minPlayCount : undefined,
min_likes_growth: filters.minLikesGrowth,
}),
[filters],
[filters, sourceType],
);
const metrics = sourceType === 'owned' ? [...metricFilters, { key: 'minPlayCount' as const, label: '最低观看' }] : metricFilters;
const sortOptions = sourceType === 'owned' ? [...commonSortOptions, { value: 'play_count', label: '观看' }] : commonSortOptions;
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 title="筛选与排序" extra={<Button onClick={() => setFilters({})}>重置</Button>}>
<Flex vertical gap={16}>
<Form layout="inline">
<Form.Item label="所属账号">
<Select
aria-label="所属账号"
placeholder="全部账号"
allowClear
showSearch={{ optionFilterProp: 'label' }}
loading={accountsPending}
disabled={accountsPending || Boolean(accountsError)}
style={{ width: 180 }}
value={filters.sourceId}
options={accounts.map((account) => ({
value: account.id,
label: (sourceType === 'owned' ? account.name : account.nickname) || account.platform_account_key,
}))}
onChange={(value) => setFilters((current) => ({ ...current, sourceId: value }))}
/>
</Form.Item>
<Form.Item label="发布时间">
<DatePicker.RangePicker
aria-label="发布时间"
value={filters.dates}
onChange={(dates) =>
setFilters((current) => ({
...current,
dates: dates?.[0] && dates[1] ? [dates[0], dates[1]] : undefined,
}))
}
/>
</Form.Item>
<Form.Item label="时间核验状态">
<Select
aria-label="时间核验状态"
placeholder="全部状态"
allowClear
style={{ width: 140 }}
value={filters.publishedAtStatus}
options={publishedAtStatusOptions}
onChange={(value) =>
setFilters((current) => ({
...current,
publishedAtStatus: value,
}))
}
/>
</Form.Item>
</Form>
<Form layout="inline">
{metrics.map(({ key, label }) => (
<Form.Item key={key} label={label}>
<InputNumber
aria-label={label}
min={0}
precision={0}
style={{ width: 120 }}
value={filters[key] as number | undefined}
onChange={(value) =>
setFilters((current) => ({
...current,
[key]: value ?? undefined,
}))
}
/>
</Form.Item>
))}
<Form.Item label="排序指标">
<Select
aria-label="排序指标"
style={{ width: 160 }}
value={filters.sortBy ?? 'published_at'}
options={sortOptions}
onChange={(value) => setFilters((current) => ({ ...current, sortBy: value }))}
/>
</Form.Item>
<Form.Item label="排序方向">
<Select
aria-label="排序方向"
style={{ width: 100 }}
value={filters.sortOrder ?? 'desc'}
options={[
{ value: 'desc', label: '降序' },
{ value: 'asc', label: '升序' },
]}
onChange={(value) => setFilters((current) => ({ ...current, sortOrder: value }))}
/>
</Form.Item>
</Form>
<Typography.Text type="secondary">调整条件后自动刷新;缺失指标显示“—”,不计为 0。</Typography.Text>
{accountsError ? (
<Alert
type="error"
title="账号读取失败"
description="暂时无法按账号筛选,其他筛选仍可使用。"
action={
<Button size="small" onClick={() => setRetry((value) => value + 1)}>
重试
</Button>
}
/>
) : null}
</Flex>
</Card>
<WorksGallery sourceType="competitor" emptyHint="暂无符合条件的作品。" extraFilters={extraFilters} />
{sourceType === 'owned' ? (
<Typography.Text type="secondary">仅展示已采集作品,未采集作品不参与筛选和排序;观看量未返回时显示“—”。</Typography.Text>
) : null}
<WorksGallery sourceType={sourceType} sourceId={filters.sourceId} extraFilters={extraFilters} showGrowthFilter={false} emptyHint="暂无符合条件的作品。" />
</Flex>
);
}
export default function Page() {
return (
<Tabs
defaultActiveKey="competitor"
destroyOnHidden={false}
items={[
{
key: 'competitor',
label: '竞品账号',
children: <WorkAnalysisTab sourceType="competitor" />,
},
{
key: 'owned',
label: '我的账号',
children: <WorkAnalysisTab sourceType="owned" />,
},
]}
/>
);
}