feat: use linked covers and account names in comment aggregation

This commit is contained in:
2026-10-06 15:11:14 +08:00
parent b984202ebb
commit 911bde3454
4 changed files with 218 additions and 37 deletions
+1 -1
View File
@@ -50,7 +50,7 @@
作品分析:左侧原“竞品分析”统一为“作品分析”,页面通过“竞品账号”“我的账号”两个 TAB 严格区分作品来源;各 TAB 独立保留筛选与分页。支持所属账号、发布时间和互动阈值筛选,以及发布时间、点赞、评论、分享、收藏、24h 点赞增量升降序;自有作品额外支持观看量,不展示“时间核验状态”筛选。默认最新发布在前,条件变化回到第一页,筛选与排序作用于全部已采集作品后再分页;缺失指标显示“—”,排序放末尾、阈值筛选不将其当作 0。未采集作品不参与分析。
评论聚合:独立页面通过“我的作品评论”“竞品作品评论”两个 TAB 严格区分来源,各 TAB 保留筛选与页码。列表展示评论发布时间、评论者、内容、所属账号(昵称与 UID)及对应作品;支持所属账号筛选,以及最近 1/6/12 小时、1/3/5/7 天筛选,默认最近 1 天。按评论发布时间计算范围,最新评论在前,同时间按评论 ID 倒序;未记录发布时间、未来时间及未采集评论不参与。筛选先作用于全部已采集评论再分页,条件或每页数量变化回到第一页;只读展示,不新增采集机制。
评论聚合:独立页面通过“我的作品评论”“竞品作品评论”两个 TAB 严格区分来源,各 TAB 保留筛选与页码。列表展示评论发布时间、评论者、内容、所属账号名称及对应作品的小封面;所属账号列与筛选下拉均不展示 UID,缺名仅显示前端“未命名账号”,评论者显示不变;作品使用本地封面,点击在新标签页打开抖音作品页面,缺图明确提示,不展示作品标题、不使用远程图片兜底。支持所属账号筛选,以及最近 1/6/12 小时、1/3/5/7 天筛选,默认最近 1 天。按评论发布时间计算范围,最新评论在前,同时间按评论 ID 倒序;未记录发布时间、未来时间及未采集评论不参与。筛选先作用于全部已采集评论再分页,条件或每页数量变化回到第一页;只读展示,不新增采集机制。
前端框架:Umi Max 4.7 + React 19;
+23 -24
View File
@@ -14,13 +14,13 @@ import type { TableColumnsType } from "antd";
import { useEffect, useState } from "react";
import { creatorGet, getList } from "@/services/api";
import { conflictMessage } from "@/utils/helpers";
import { WorkCover } from "@/components/creator/WorksGallery";
type SourceType = "owned" | "competitor";
type Account = {
id: string;
name?: string;
nickname?: string;
platform_account_key?: string;
};
type AggregatedComment = {
id: string;
@@ -28,12 +28,10 @@ type AggregatedComment = {
author_name: string;
content: string;
published_at: string;
source_id: string;
source_name: string;
source_account_key: string;
work_id: string;
work_key: string;
work_title: string;
work_url: string;
};
const timeOptions = [
{ value: 1, label: "最近 1 小时" },
@@ -69,29 +67,28 @@ const columns: TableColumnsType<AggregatedComment> = [
{ title: "评论内容", dataIndex: "content", width: 320 },
{
title: "所属账号",
width: 200,
width: 180,
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>
<Typography.Text>{item.source_name || "未命名账号"}</Typography.Text>
),
},
{
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>
),
width: 100,
render: (_, item) => (
<Typography.Link
href={`https://www.douyin.com/video/${encodeURIComponent(item.work_key)}`}
target="_blank"
rel="noreferrer"
aria-label={`查看作品:${item.work_title || item.work_key}`}
style={{ display: "inline-block", width: 48 }}
>
<WorkCover
workID={item.work_id}
title={item.work_title || "作品封面"}
/>
</Typography.Link>
),
},
];
@@ -215,7 +212,9 @@ export function CommentAggregationTab({
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})` : ""}`,
label:
(sourceType === "owned" ? account.name : account.nickname) ||
"未命名账号",
}))}
onChange={(sourceId) => changeFilters({ ...filters, sourceId })}
/>
@@ -263,7 +262,7 @@ export function CommentAggregationTab({
columns={columns}
dataSource={comments}
loading={pending}
scroll={{ x: 1120 }}
scroll={{ x: 960 }}
locale={{
emptyText: error
? "评论读取失败,请点击刷新重试"
+62 -12
View File
@@ -46,6 +46,8 @@ Module._load = function (request, parent, isMain) {
harness.listResource(resource, query),
creatorGet: (...args) => harness.creatorGet(...args),
};
if (request === "@/components/creator/WorksGallery")
return { WorkCover: "WorkCover" };
if (request === "@/utils/helpers")
return {
conflictMessage: (error, fallback) => error?.message || fallback,
@@ -191,11 +193,46 @@ test("two independently retained tabs and seven relative publication windows", a
(node) => node.props["aria-label"] === "所属账号",
);
assert.deepEqual(accounts.props.options, [
{ value: "account-1", label: "我的账号(uid-1)" },
{ value: "account-1", label: "我的账号" },
]);
h.cleanup();
});
test("account dropdown never uses UID or internal IDs as names in either tab", async () => {
for (const sourceType of ["owned", "competitor"]) {
const h = makeHarness();
const page = load();
h.creatorGet = async () => [
{
id: "account-1",
name: "我的账号",
nickname: "竞品账号",
platform_account_key: "uid-1",
},
{
id: "account-2",
name: "",
nickname: "",
platform_account_key: "uid-2",
},
];
h.render(page.CommentAggregationTab, { sourceType });
await tick();
const tree = h.render(page.CommentAggregationTab, { sourceType });
const select = nodes(tree, "Select").find(
(node) => node.props["aria-label"] === "所属账号",
);
assert.deepEqual(select.props.options, [
{
value: "account-1",
label: sourceType === "owned" ? "我的账号" : "竞品账号",
},
{ value: "account-2", label: "未命名账号" },
]);
h.cleanup();
}
});
test("account and time changes reset pagination and preserve page size", async () => {
const h = makeHarness();
const page = load();
@@ -262,6 +299,7 @@ test("renders commenter, account and work metadata with server pagination", asyn
source_account_key: "uid-1",
source_id: "account-1",
work_title: "作品标题",
work_id: "work-1",
work_key: "work-key",
work_url: "https://www.douyin.com/video/work-key",
};
@@ -281,12 +319,21 @@ test("renders commenter, account and work metadata with server pagination", asyn
.find((column) => column.title === "所属账号")
.render(undefined, item);
assert.equal(nodes(account, "Text")[0].props.children, "账号");
assert.equal(nodes(account, "Text")[1].props.children, "UID:uid-1");
assert.equal(nodes(account, "Text").length, 1);
const work = table.props.columns
.find((column) => column.title === "对应作品")
.render(undefined, item);
assert.equal(nodes(work, "Link")[0].props.href, item.work_url);
assert.equal(nodes(work, "Link")[0].props.children, item.work_title);
const link = nodes(work, "Link")[0];
assert.equal(link.props.href, item.work_url);
assert.equal(link.props.target, "_blank");
assert.equal(link.props["aria-label"], "查看作品:作品标题");
assert.equal(link.props.style.width, 48);
assert.equal(nodes(work, "Text").length, 0);
assert.deepEqual(nodes(work, "WorkCover")[0].props, {
workID: "work-1",
title: "作品标题",
children: [],
});
const author = table.props.columns
.find((column) => column.title === "评论者")
.render(undefined, item);
@@ -307,17 +354,20 @@ test("renders commenter, account and work metadata with server pagination", asyn
.render(undefined, empty),
"Text",
)[0].props.children,
"account-1",
"未命名账号",
);
const untitledWork = table.props.columns
.find((column) => column.title === "对应作品")
.render(undefined, empty);
assert.equal(
nodes(untitledWork, "Link")[0].props.href,
"https://www.douyin.com/video/work-key",
);
assert.equal(
nodes(
table.props.columns
.find((column) => column.title === "对应作品")
.render(undefined, empty),
"Text",
)[0].props.children,
"work-key",
nodes(untitledWork, "Link")[0].props["aria-label"],
"查看作品:work-key",
);
assert.equal(nodes(untitledWork, "WorkCover")[0].props.title, "作品封面");
h.cleanup();
});
+132
View File
@@ -0,0 +1,132 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const ts = require("../node_modules/typescript");
const filename = path.resolve(
__dirname,
"../src/components/creator/WorksGallery.tsx",
);
const file = ts.createSourceFile(
filename,
fs.readFileSync(filename, "utf8"),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const cover = file.statements.find(
(node) => ts.isFunctionDeclaration(node) && node.name?.text === "WorkCover",
);
const compiled = ts.transpileModule(cover.getText(file), {
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.React },
}).outputText;
function harness(fetch) {
const state = [];
const effects = [];
const calls = [];
const revoked = [];
let cursor = 0;
let effect;
const react = {
createElement: (type, props, ...children) => ({
type,
props: { ...props, children },
}),
};
const useState = (initial) => {
const index = cursor++;
if (!(index in state)) state[index] = initial;
return [
state[index],
(value) => {
state[index] = value;
},
];
};
const useEffect = (fn, deps) => {
const index = cursor++;
if (!effects[index]) {
effect = fn;
effects[index] = { deps };
}
};
const url = {
createObjectURL: () => "blob:local-cover",
revokeObjectURL: (value) => revoked.push(value),
};
const Component = new Function(
"exports",
"React",
"useState",
"useEffect",
"fetchCreatorBinary",
"Flex",
"Typography",
"URL",
compiled + "\nreturn exports.WorkCover;",
)(
{},
react,
useState,
useEffect,
(endpoint) => {
calls.push(endpoint);
return fetch();
},
"Flex",
{ Text: "Text" },
url,
);
return {
calls,
revoked,
render(props) {
cursor = 0;
const tree = Component(props);
if (effect) {
effects[2].cleanup = effect();
effect = null;
}
return tree;
},
cleanup() {
effects[2]?.cleanup?.();
},
};
}
const tick = async () => {
for (let i = 0; i < 6; i++) await Promise.resolve();
};
test("comment thumbnails load only the cached local cover and release its image URL", async () => {
const h = harness(async () => ({ cover: true }));
const props = { workID: "work/id", title: "作品标题" };
const loading = h.render(props);
assert.equal(loading.type, "Flex");
assert.equal(loading.props.children[0].props.children[0], "封面加载中");
await tick();
const image = h.render(props);
assert.equal(image.type, "img");
assert.equal(image.props.src, "blob:local-cover");
assert.equal(image.props.alt, "作品标题");
assert.deepEqual(h.calls, ["/creator/works/work%2Fid/cover"]);
h.cleanup();
assert.deepEqual(h.revoked, ["blob:local-cover"]);
});
test("missing cached cover is explicit and never replaced by a remote image", async () => {
const h = harness(async () => {
throw new Error("作品封面尚未缓存");
});
const props = { workID: "missing-cover", title: "作品封面" };
h.render(props);
await tick();
const missing = h.render(props);
assert.equal(missing.type, "Flex");
assert.equal(missing.props.children[0].props.children[0], "封面未缓存");
assert.deepEqual(h.calls, ["/creator/works/missing-cover/cover"]);
h.cleanup();
assert.deepEqual(h.revoked, []);
});