feat: add source-separated comment aggregation page
This commit is contained in:
@@ -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" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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'],
|
||||
};
|
||||
|
||||
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user