feat: unify owned and competitor work analysis
This commit is contained in:
@@ -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" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user