feat: add source-separated comment aggregation page

This commit is contained in:
2026-10-06 14:55:02 +08:00
parent 5691fd1a93
commit b984202ebb
10 changed files with 1184 additions and 0 deletions
+310
View File
@@ -0,0 +1,310 @@
import { ReloadOutlined } from "@ant-design/icons";
import {
Alert,
Button,
Card,
Flex,
Form,
Select,
Table,
Tabs,
Typography,
} from "antd";
import type { TableColumnsType } from "antd";
import { useEffect, useState } from "react";
import { creatorGet, getList } from "@/services/api";
import { conflictMessage } from "@/utils/helpers";
type SourceType = "owned" | "competitor";
type Account = {
id: string;
name?: string;
nickname?: string;
platform_account_key?: string;
};
type AggregatedComment = {
id: string;
author_uid: string;
author_name: string;
content: string;
published_at: string;
source_id: string;
source_name: string;
source_account_key: string;
work_key: string;
work_title: string;
work_url: string;
};
const timeOptions = [
{ value: 1, label: "最近 1 小时" },
{ value: 6, label: "最近 6 小时" },
{ value: 12, label: "最近 12 小时" },
{ value: 24, label: "最近 1 天" },
{ value: 72, label: "最近 3 天" },
{ value: 120, label: "最近 5 天" },
{ value: 168, label: "最近 7 天" },
];
const columns: TableColumnsType<AggregatedComment> = [
{
title: "评论发布时间",
dataIndex: "published_at",
width: 180,
render: (value: string) =>
new Date(value).toLocaleString("zh-CN", { hour12: false }),
},
{
title: "评论者",
width: 180,
render: (_, item) => (
<Flex vertical>
<Typography.Text>
{item.author_name || item.author_uid || "—"}
</Typography.Text>
{item.author_uid && (
<Typography.Text type="secondary">{`UID:${item.author_uid}`}</Typography.Text>
)}
</Flex>
),
},
{ title: "评论内容", dataIndex: "content", width: 320 },
{
title: "所属账号",
width: 200,
render: (_, item) => (
<Flex vertical>
<Typography.Text>
{item.source_name || item.source_account_key || item.source_id}
</Typography.Text>
{item.source_account_key && (
<Typography.Text type="secondary">{`UID:${item.source_account_key}`}</Typography.Text>
)}
</Flex>
),
},
{
title: "对应作品",
width: 240,
render: (_, item) =>
item.work_url ? (
<Typography.Link href={item.work_url} target="_blank" rel="noreferrer">
{item.work_title || item.work_key}
</Typography.Link>
) : (
<Typography.Text>{item.work_title || item.work_key}</Typography.Text>
),
},
];
export function CommentAggregationTab({
sourceType,
}: {
sourceType: SourceType;
}) {
const [filters, setFilters] = useState<{ sourceId?: string; hours: number }>({
hours: 24,
});
const [pagination, setPagination] = useState({ page: 1, pageSize: 25 });
const [comments, setComments] = useState<AggregatedComment[]>([]);
const [total, setTotal] = useState(0);
const [pending, setPending] = useState(true);
const [error, setError] = useState<unknown>(null);
const [accounts, setAccounts] = useState<Account[]>([]);
const [accountsPending, setAccountsPending] = useState(true);
const [accountsError, setAccountsError] = useState<unknown>(null);
const [revision, setRevision] = useState(0);
useEffect(() => {
let active = true;
const endpoint =
sourceType === "owned" ? "/creator/accounts" : "/creator/competitors";
setAccountsPending(true);
setAccountsError(null);
creatorGet(endpoint)
.then((result: Account[]) => {
if (active) setAccounts(result);
})
.catch((cause) => {
console.error("读取评论聚合账号失败", {
sourceType,
endpoint,
error: cause,
});
if (active) setAccountsError(cause);
})
.finally(() => {
if (active) setAccountsPending(false);
});
return () => {
active = false;
};
}, [sourceType, revision]);
useEffect(() => {
let active = true;
const query = {
page: pagination.page,
pageSize: pagination.pageSize,
filters: {
source_type: sourceType,
source_id: filters.sourceId,
hours: filters.hours,
},
};
setPending(true);
setError(null);
setComments([]);
setTotal(0);
getList<AggregatedComment>({
resource: "creator-comment-aggregation",
...query,
})
.then((result) => {
if (active) {
setComments(result.data);
setTotal(result.total);
}
})
.catch((cause) => {
console.error("读取评论聚合列表失败", { ...query, error: cause });
if (active) setError(cause);
})
.finally(() => {
if (active) setPending(false);
});
return () => {
active = false;
};
}, [
sourceType,
filters.sourceId,
filters.hours,
pagination.page,
pagination.pageSize,
revision,
]);
const changeFilters = (next: { sourceId?: string; hours: number }) => {
setFilters(next);
setPagination((current) => ({ ...current, page: 1 }));
};
return (
<Card>
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Typography.Text>
{pending ? "正在读取评论…" : `共 ${total} 条评论`}
</Typography.Text>
<Button
icon={<ReloadOutlined />}
loading={pending || accountsPending}
onClick={() => setRevision((current) => current + 1)}
>
刷新
</Button>
</Flex>
<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: 220 }}
value={filters.sourceId}
options={accounts.map((account) => ({
value: account.id,
label: `${(sourceType === "owned" ? account.name : account.nickname) || account.platform_account_key || account.id}${account.platform_account_key ? `(${account.platform_account_key})` : ""}`,
}))}
onChange={(sourceId) => changeFilters({ ...filters, sourceId })}
/>
</Form.Item>
<Form.Item label="评论发布时间">
<Select
aria-label="评论发布时间"
style={{ width: 160 }}
value={filters.hours}
options={timeOptions}
onChange={(hours) => changeFilters({ ...filters, hours })}
/>
</Form.Item>
<Form.Item>
<Button onClick={() => changeFilters({ hours: 24 })}>重置</Button>
</Form.Item>
</Form>
<Typography.Text type="secondary">
按评论发布时间筛选,仅展示已采集评论;未记录发布时间的评论不在结果中。最新评论在前。
</Typography.Text>
{Boolean(accountsError) && (
<Alert
type="error"
showIcon
title="账号读取失败"
description={conflictMessage(
accountsError,
"无法读取账号,请点击刷新重试。",
)}
/>
)}
{Boolean(error) && (
<Alert
type="error"
showIcon
title="评论读取失败"
description={conflictMessage(
error,
"无法读取评论,请点击刷新重试。",
)}
/>
)}
<Table<AggregatedComment>
rowKey="id"
columns={columns}
dataSource={comments}
loading={pending}
scroll={{ x: 1120 }}
locale={{
emptyText: error
? "评论读取失败,请点击刷新重试"
: "所选时间内暂无已采集评论",
}}
pagination={{
current: pagination.page,
pageSize: pagination.pageSize,
total,
showSizeChanger: true,
pageSizeOptions: [25, 50, 100],
}}
onChange={(next) =>
setPagination((current) => ({
page:
next.pageSize !== current.pageSize ? 1 : (next.current ?? 1),
pageSize: next.pageSize ?? current.pageSize,
}))
}
/>
</Flex>
</Card>
);
}
export default function CommentAggregationPage() {
return (
<Tabs
destroyOnHidden={false}
items={[
{
key: "owned",
label: "我的作品评论",
children: <CommentAggregationTab sourceType="owned" />,
},
{
key: "competitor",
label: "竞品作品评论",
children: <CommentAggregationTab sourceType="competitor" />,
},
]}
/>
);
}
+2
View File
@@ -11,6 +11,7 @@ export const resourcePaths = {
'creator-competitor-share-jobs': '/creator/competitor-share-jobs',
'creator-works': '/creator/works',
'creator-comments': '/creator/comments',
'creator-comment-aggregation': '/creator/comments/aggregation',
'creator-leads': '/creator/leads',
'creator-rules': '/creator/rules',
} as const;
@@ -23,6 +24,7 @@ const filterKeys: Partial<Record<Resource, string[]>> = {
'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', 'min_collect_count', 'min_play_count', 'sort_by', 'sort_order', 'growth_hours', 'min_likes_growth'],
'creator-comments': ['platform', 'work_id'],
'creator-comment-aggregation': ['source_type', 'source_id', 'hours'],
'creator-leads': ['platform'],
};
+2
View File
@@ -22,6 +22,7 @@ const rules: Array<{ test: (path: string) => boolean; title: string; subtitle: s
{ 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/comments', title: '评论聚合', subtitle: '分别查看我的作品与竞品作品的已采集评论,按所属账号和评论发布时间筛选。' },
{ test: (p) => p === '/creator/settings', title: '采集设置', subtitle: '统一配置采集窗口、指标采集和已批准的服务。' },
{ test: (p) => p.startsWith('/creator/competitors/'), title: '竞品账号详情', subtitle: '查看竞品画像与作品列表,点击作品可追溯指标统计。' },
{ test: (p) => p === '/network-exits', title: '网络出口', subtitle: '维护固定出口、健康观测与账号绑定。' },
@@ -40,6 +41,7 @@ export const menu: MenuDataItem[] = [
{ path: '/accounts', name: '我的账号', icon: <UserOutlined /> },
{ path: '/accounts/monitoring', name: '监控账号', icon: <EyeOutlined /> },
{ path: '/creator/competitors', name: '作品分析', icon: <LineChartOutlined /> },
{ path: '/creator/comments', name: '评论聚合', icon: <CommentOutlined /> },
{ path: '/creator/settings', name: '采集设置', icon: <SettingOutlined /> },
],
},