diff --git a/AGENTS.md b/AGENTS.md index c1c802b..6cd3772 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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; diff --git a/web/src/pages/creator/comments/index.tsx b/web/src/pages/creator/comments/index.tsx index e6f61d5..d84b64b 100644 --- a/web/src/pages/creator/comments/index.tsx +++ b/web/src/pages/creator/comments/index.tsx @@ -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 = [ { title: "评论内容", dataIndex: "content", width: 320 }, { title: "所属账号", - width: 200, + width: 180, render: (_, item) => ( - - - {item.source_name || item.source_account_key || item.source_id} - - {item.source_account_key && ( - {`UID:${item.source_account_key}`} - )} - + {item.source_name || "未命名账号"} ), }, { title: "对应作品", - width: 240, - render: (_, item) => - item.work_url ? ( - - {item.work_title || item.work_key} - - ) : ( - {item.work_title || item.work_key} - ), + width: 100, + render: (_, item) => ( + + + + ), }, ]; @@ -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 ? "评论读取失败,请点击刷新重试" diff --git a/web/tests/comment-aggregation.test.cjs b/web/tests/comment-aggregation.test.cjs index 7a9b252..0902552 100644 --- a/web/tests/comment-aggregation.test.cjs +++ b/web/tests/comment-aggregation.test.cjs @@ -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(); }); diff --git a/web/tests/comment-work-cover.test.cjs b/web/tests/comment-work-cover.test.cjs new file mode 100644 index 0000000..72080fd --- /dev/null +++ b/web/tests/comment-work-cover.test.cjs @@ -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, []); +});