feat: use linked covers and account names in comment aggregation
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
? "评论读取失败,请点击刷新重试"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
Reference in New Issue
Block a user