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
+188 -96
View File
@@ -5,13 +5,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Line } from '@ant-design/charts';
import { Alert, App, Button, Card, Col, Drawer, Empty, Flex, InputNumber, Pagination, Row, Space, Spin, Table, Typography } from 'antd';
import {
CommentOutlined,
EyeOutlined,
FireOutlined,
LikeOutlined,
ReloadOutlined,
ShareAltOutlined } from '@ant-design/icons';
import { CommentOutlined, EyeOutlined, StarOutlined, FireOutlined, LikeOutlined, ReloadOutlined, ShareAltOutlined } from '@ant-design/icons';
import { creatorAction, creatorGet, fetchCreatorBinary, getList } from '@/services/api';
import { conflictMessage, dateTime } from '@/utils/helpers';
@@ -39,21 +33,30 @@ export function WorkCover({ workID, title }: { workID: string; title?: string })
}
return (
<Flex align="center" justify="center" style={{ aspectRatio: '3 / 4', background: '#f5f5f5' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{missing ? '封面未缓存' : '封面加载中'}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{missing ? '封面未缓存' : '封面加载中'}
</Typography.Text>
</Flex>
);
}
// 观看/点赞/评论/分享全宽四列 GRID,紧贴封面(卡片 body 顶部,本行自带上下边框)。
function WorkStatsGrid({ work }: { work: any }) {
// 竞品突出互动指标;自有账号额外展示观看量。缺失值与 0 保持区分。
function WorkStatsGrid({ work, showViews }: { work: any; showViews: boolean }) {
const items = [
{ key: 'play_count', label: '观看', icon: EyeOutlined },
...(showViews ? [{ key: 'play_count', label: '观看', icon: EyeOutlined }] : []),
{ key: 'likes', label: '点赞', icon: LikeOutlined },
{ key: 'comments_count', label: '评论', icon: CommentOutlined },
{ key: 'shares', label: '分享', icon: ShareAltOutlined },
{ key: 'collect_count', label: '收藏', icon: StarOutlined },
];
return (
<Flex style={{ padding: '6px 0', borderTop: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0' }}>
<Flex
style={{
padding: '6px 0',
borderTop: '1px solid #f0f0f0',
borderBottom: '1px solid #f0f0f0',
}}
>
{items.map(({ key, label, icon: Icon }) => (
<Flex key={key} vertical align="center" gap={0} style={{ flex: 1, minWidth: 0 }}>
<Typography.Text strong style={{ fontSize: 14, lineHeight: '20px' }}>
@@ -86,7 +89,8 @@ function GrowthChart({ metrics }: { metrics: any[] }) {
.map((series) => ({
time: dateTime(point.collected_at),
metric: series.label,
value: point[series.key] })),
value: point[series.key],
})),
),
[metrics],
);
@@ -98,7 +102,9 @@ function GrowthChart({ metrics }: { metrics: any[] }) {
yField="value"
colorField="metric"
height={240}
yAxis={{ labelFormatter: (v: number) => (v >= 10000 ? `${(v / 10000).toFixed(1)}w` : `${v}`) }}
yAxis={{
labelFormatter: (v: number) => (v >= 10000 ? `${(v / 10000).toFixed(1)}w` : `${v}`),
}}
legend={{ position: 'top' }}
/>
);
@@ -115,19 +121,25 @@ interface WorksGalleryProps {
sourceId?: string;
sourceType?: 'owned' | 'competitor';
emptyHint?: string;
/** 页面自有筛选(如竞品分析页的互动量/发布时间条件)透传到作品列表查询。 */
/** 页面自有筛选与排序条件透传到作品列表查询。 */
extraFilters?: Record<string, string | number | undefined>;
/** 分析页在外部筛选卡中统一管理增量筛选,避免重复入口。 */
showGrowthFilter?: boolean;
}
export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFilters }: WorksGalleryProps) {
export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFilters, showGrowthFilter = true }: WorksGalleryProps) {
const [works, setWorks] = useState<any[]>([]);
const [workPage, setWorkPage] = useState(1);
const [pageState, setPageState] = useState({ filterKey: '', page: 1 });
const [worksRefresh, setWorksRefresh] = useState(0);
const [workPageInfo, setWorkPageInfo] = useState({ total: 0 });
const [collectionProgress, setCollectionProgress] = useState<CollectionProgress | null>(null);
const [progressError, setProgressError] = useState<any>(null);
const [worksPending, setWorksPending] = useState(true);
const [worksError, setWorksError] = useState<any>(null);
const [minGrowth, setMinGrowth] = useState<number | null>(null);
const filterKey = JSON.stringify([sourceId, sourceType, extraFilters, minGrowth]);
const workPage = pageState.filterKey === filterKey ? pageState.page : 1;
const setWorkPage = (page: number) => setPageState({ filterKey, page });
const [detailWork, setDetailWork] = useState<any>(null);
const [workMetrics, setWorkMetrics] = useState<any[]>([]);
const [metricsPending, setMetricsPending] = useState(false);
@@ -138,48 +150,80 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
const [commentsPending, setCommentsPending] = useState(false);
const { message: messageApi } = App.useApp();
const loadWorks = useCallback(async () => {
setWorksPending(true);
setWorksError(null);
setProgressError(null);
setCollectionProgress(null);
const progressRequest = sourceType === 'owned' && sourceId
? creatorGet(`/creator/accounts/${encodeURIComponent(sourceId)}/collection-status`)
.then((progress) => setCollectionProgress(progress))
.catch((error) => {
console.error('读取账号作品采集进度失败', { sourceId, sourceType, error });
setProgressError(error);
})
: Promise.resolve();
try {
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 设下限。
filters.growth_hours = 24;
filters.min_likes_growth = minGrowth;
}
const result = await getList({ resource: 'creator-works', page: workPage, pageSize: 25, filters });
setWorks(result.data);
setWorkPageInfo({ total: result.total });
} catch (loadError) {
console.error('读取作品列表失败', { sourceId, sourceType, page: workPage, minGrowth, extraFilters, error: loadError });
setWorksError(loadError);
} finally {
await progressRequest;
setWorksPending(false);
}
}, [sourceId, sourceType, workPage, minGrowth, extraFilters]);
const loadWorks = () => setWorksRefresh((value) => value + 1);
useEffect(() => {
loadWorks();
}, [loadWorks]);
let active = true;
async function fetchWorks() {
setWorksPending(true);
setWorksError(null);
setProgressError(null);
setCollectionProgress(null);
const progressRequest =
sourceType === 'owned' && sourceId
? creatorGet(`/creator/accounts/${encodeURIComponent(sourceId)}/collection-status`)
.then((progress) => {
if (active) setCollectionProgress(progress);
})
.catch((error) => {
console.error('读取账号作品采集进度失败', {
sourceId,
sourceType,
error,
});
if (active) setProgressError(error);
})
: Promise.resolve();
try {
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 设下限。
filters.growth_hours = 24;
filters.min_likes_growth = minGrowth;
}
const result = await getList({
resource: 'creator-works',
page: workPage,
pageSize: 25,
filters,
});
if (active) {
setWorks(result.data);
setWorkPageInfo({ total: result.total });
}
} catch (loadError) {
console.error('读取作品列表失败', {
sourceId,
sourceType,
page: workPage,
minGrowth,
extraFilters,
error: loadError,
});
if (active) setWorksError(loadError);
} finally {
await progressRequest;
if (active) setWorksPending(false);
}
}
fetchWorks();
return () => {
active = false;
};
}, [sourceId, sourceType, workPage, minGrowth, extraFilters, worksRefresh]);
const loadComments = useCallback(async (workID: string, page: number, pageSize = 10) => {
setCommentsPending(true);
try {
const result = await getList({ resource: 'creator-comments', page, pageSize, filters: { work_id: workID } });
const result = await getList({
resource: 'creator-comments',
page,
pageSize,
filters: { work_id: workID },
});
setComments(result.data);
setCommentTotal(result.total);
} catch {
@@ -222,37 +266,47 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
return (
<>
<Card
title="作品列表"
extra={
<Button icon={<ReloadOutlined />} size="small" onClick={loadWorks} disabled={worksPending} aria-label="刷新作品" />
}
>
<Card title="作品列表" extra={<Button icon={<ReloadOutlined />} size="small" onClick={loadWorks} disabled={worksPending} aria-label="刷新作品" />}>
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Flex vertical gap={4}>
{sourceType === 'owned' ? <Typography.Text>{progressHint}</Typography.Text> : null}
{sourceType === 'owned' && sourceId ? <Typography.Text>{progressHint}</Typography.Text> : null}
<Typography.Text type="secondary">
{worksError ? '作品列表暂不可用' : `${minGrowth !== null || extraFilters ? '符合筛选' : sourceType === 'owned' ? '当前列表' : '共'} ${workPageInfo.total} 个作品`};作品由系统按采集间隔自动同步。
{worksError
? '作品列表暂不可用'
: `${minGrowth !== null || extraFilters ? '符合筛选' : sourceType === 'owned' ? '当前列表' : '共'} ${workPageInfo.total} 个作品`}
;作品由系统按采集间隔自动同步。
</Typography.Text>
</Flex>
<Flex align="center" gap={8}>
<FireOutlined style={{ color: 'rgba(0,0,0,0.45)' }} />
<Typography.Text type="secondary">24h 点赞增量 ≥</Typography.Text>
<InputNumber
size="small"
min={0}
placeholder="不限"
style={{ width: 96 }}
value={minGrowth}
onChange={(value) => {
setMinGrowth(value ?? null);
setWorkPage(1);
}}
/>
</Flex>
{showGrowthFilter ? (
<Flex align="center" gap={8}>
<FireOutlined style={{ color: 'rgba(0,0,0,0.45)' }} />
<Typography.Text type="secondary">24h 点赞增量 ≥</Typography.Text>
<InputNumber
size="small"
min={0}
placeholder="不限"
style={{ width: 96 }}
value={minGrowth}
onChange={(value) => {
setMinGrowth(value ?? null);
setWorkPage(1);
}}
/>
</Flex>
) : null}
</Flex>
{progressError ? (
<Alert type="error" showIcon title={progressError.message} style={{ marginBottom: 16 }} action={<Button size="small" onClick={loadWorks}>重试</Button>} />
<Alert
type="error"
showIcon
title={progressError.message}
style={{ marginBottom: 16 }}
action={
<Button size="small" onClick={loadWorks}>
重试
</Button>
}
/>
) : null}
{worksError ? (
<Alert
@@ -260,7 +314,11 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
showIcon
style={{ marginBottom: 16 }}
title={worksError.message}
action={<Button size="small" onClick={loadWorks}>重试</Button>}
action={
<Button size="small" onClick={loadWorks}>
重试
</Button>
}
/>
) : null}
<Spin spinning={worksPending}>
@@ -275,7 +333,7 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
onClick={() => openWorkDetail(work)}
cover={<WorkCover workID={work.id} title={work.title} />}
>
<WorkStatsGrid work={work} />
<WorkStatsGrid work={work} showViews={sourceType !== 'competitor'} />
<div style={{ padding: '10px 16px 0' }}>
<Flex vertical gap={8} style={{ minWidth: 0 }}>
{/* 无标题作品不渲染标题行,避免占位文案增加卡片噪音。 */}
@@ -294,9 +352,17 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
</Typography.Text>
</Flex>
</div>
{work.likes_growth != null && work.likes_growth > 0 ? (
<Typography.Text type="warning" style={{ display: 'block', padding: '8px 16px 0', fontSize: 12 }}>
<FireOutlined /> 24h +{work.likes_growth}
{work.likes_growth != null || extraFilters?.growth_hours ? (
<Typography.Text
type="secondary"
style={{
display: 'block',
padding: '8px 16px 0',
fontSize: 12,
}}
>
<FireOutlined /> 24h 点赞增量 {work.likes_growth == null ? '—' : `+${work.likes_growth}`}
{work.likes_growth_coverage === 'partial' ? '(样本不足)' : ''}
</Typography.Text>
) : null}
</Card>
@@ -324,17 +390,24 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
<Drawer open={Boolean(detailWork)} onClose={() => setDetailWork(null)} size="large" title="作品统计">
{detailWork ? (
<Flex vertical gap={12}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>ID:{detailWork.work_key || detailWork.id}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
ID:{detailWork.work_key || detailWork.id}
</Typography.Text>
{detailWork.title ? (
<Typography.Paragraph strong style={{ marginBottom: 0 }}>{detailWork.title}</Typography.Paragraph>
<Typography.Paragraph strong style={{ marginBottom: 0 }}>
{detailWork.title}
</Typography.Paragraph>
) : null}
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>
{detailWork.body || '暂无正文'}
</Typography.Paragraph>
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{detailWork.body || '暂无正文'}</Typography.Paragraph>
<Space wrap>
<Typography.Text type="secondary">发布:{detailWork.published_at ? dateTime(detailWork.published_at) : '—'}</Typography.Text>
<Typography.Text type="secondary">
发布:
{detailWork.published_at ? dateTime(detailWork.published_at) : '—'}
</Typography.Text>
{detailWork.original_url ? (
<Typography.Link href={detailWork.original_url} target="_blank">打开原文</Typography.Link>
<Typography.Link href={detailWork.original_url} target="_blank">
打开原文
</Typography.Link>
) : null}
</Space>
{workMetrics.length >= 2 ? <GrowthChart metrics={workMetrics} /> : null}
@@ -345,16 +418,34 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
pagination={false}
dataSource={workMetrics}
columns={[
{ title: '采集时间', dataIndex: 'collected_at', render: (value: string) => dateTime(value) },
{ title: '播放', dataIndex: 'play_count', render: (v) => v ?? '—' },
{
title: '采集时间',
dataIndex: 'collected_at',
render: (value: string) => dateTime(value),
},
{
title: '播放',
dataIndex: 'play_count',
render: (v) => v ?? '—',
},
{ title: '点赞', dataIndex: 'likes', render: (v) => v ?? '—' },
{ title: '收藏', dataIndex: 'collect_count', render: (v) => v ?? '—' },
{ title: '评论', dataIndex: 'comments_count', render: (v) => v ?? '—' },
{
title: '收藏',
dataIndex: 'collect_count',
render: (v) => v ?? '—',
},
{
title: '评论',
dataIndex: 'comments_count',
render: (v) => v ?? '—',
},
{ title: '转发', dataIndex: 'shares', render: (v) => v ?? '—' },
]}
locale={{ emptyText: '暂无指标记录。' }}
/>
<Typography.Title level={5} style={{ marginBottom: 0 }}>评论</Typography.Title>
<Typography.Title level={5} style={{ marginBottom: 0 }}>
评论
</Typography.Title>
<Table
rowKey="id"
size="small"
@@ -370,7 +461,8 @@ export default function WorksGallery({ sourceId, sourceType, emptyHint, extraFil
setCommentPageSize(pageSize);
loadComments(detailWork.id, page, pageSize);
},
hideOnSinglePage: true }}
hideOnSinglePage: true,
}}
columns={[
{
title: '作者 / 时间',
+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" />,
},
]}
/>
);
}
+1 -1
View File
@@ -21,7 +21,7 @@ export type Resource = keyof typeof resourcePaths;
const filterKeys: Partial<Record<Resource, string[]>> = {
'creator-competitors': ['platform'],
'creator-competitor-share-jobs': ['platform', 'status'],
'creator-works': ['platform', 'source_id', 'source_type', 'published_at_status', 'published_after', 'published_before', 'min_likes', 'min_comments', 'min_shares', 'growth_hours', 'min_likes_growth'],
'creator-works': ['platform', 'source_id', 'source_type', 'published_at_status', 'published_after', 'published_before', 'min_likes', 'min_comments', 'min_shares', 'min_collect_count', 'min_play_count', 'sort_by', 'sort_order', 'growth_hours', 'min_likes_growth'],
'creator-comments': ['platform', 'work_id'],
'creator-leads': ['platform'],
};
+2 -2
View File
@@ -21,7 +21,7 @@ const rules: Array<{ test: (path: string) => boolean; title: string; subtitle: s
{ test: (p) => /^\/accounts\/monitoring\/.+/.test(p), title: '监控账号详情', subtitle: '查看监控账号画像与作品列表,点击作品可追溯指标统计。' },
{ test: (p) => /\/edit$/.test(p) && p.startsWith('/accounts/'), title: '编辑社媒账号', subtitle: '维护账号资料、登录核验与自动响应策略。' },
{ test: (p) => p.startsWith('/accounts/'), title: '账号详情', subtitle: '查看账号状态、登录身份与运行环境绑定。' },
{ test: (p) => p === '/creator/competitors', title: '竞品分析', subtitle: '查看竞品作品与指标,分享链接导入请前往账号导入。' },
{ test: (p) => p === '/creator/competitors', title: '作品分析', subtitle: '分别查看竞品与自有账号作品,按账号、发布时间和表现筛选排序。' },
{ test: (p) => p === '/creator/settings', title: '采集设置', subtitle: '统一配置采集窗口、指标采集和已批准的服务。' },
{ test: (p) => p.startsWith('/creator/competitors/'), title: '竞品账号详情', subtitle: '查看竞品画像与作品列表,点击作品可追溯指标统计。' },
{ test: (p) => p === '/network-exits', title: '网络出口', subtitle: '维护固定出口、健康观测与账号绑定。' },
@@ -39,7 +39,7 @@ export const menu: MenuDataItem[] = [
{ path: '/accounts/import', name: '账号导入', icon: <CloudDownloadOutlined /> },
{ path: '/accounts', name: '我的账号', icon: <UserOutlined /> },
{ path: '/accounts/monitoring', name: '监控账号', icon: <EyeOutlined /> },
{ path: '/creator/competitors', name: '竞品分析', icon: <LineChartOutlined /> },
{ path: '/creator/competitors', name: '作品分析', icon: <LineChartOutlined /> },
{ path: '/creator/settings', name: '采集设置', icon: <SettingOutlined /> },
],
},