feat: 补充报告评论集合与评论详情证据
This commit is contained in:
parent
4ea9eaf4a6
commit
41a42bca25
268
apps/api/src/report.review-collections.test.ts
Normal file
268
apps/api/src/report.review-collections.test.ts
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
JdDetailPreviewResult,
|
||||||
|
JdLiveService,
|
||||||
|
JdLiveSessionSummary,
|
||||||
|
JdProductPreviewResult,
|
||||||
|
JdReviewsPreviewOptions,
|
||||||
|
JdReviewsPreviewResult,
|
||||||
|
JdSearchPreviewResult
|
||||||
|
} from "./platforms/jd/types";
|
||||||
|
import { createServer } from "./server";
|
||||||
|
|
||||||
|
async function createTask(app: ReturnType<typeof createServer>, query: string) {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/tasks",
|
||||||
|
payload: {
|
||||||
|
query,
|
||||||
|
perLinkLimit: 5,
|
||||||
|
taskTotalLimit: 10
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.json().task;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createJdLiveServiceStub(
|
||||||
|
overrides: Partial<JdLiveService> = {}
|
||||||
|
): JdLiveService {
|
||||||
|
let summary: JdLiveSessionSummary = {
|
||||||
|
configured: false,
|
||||||
|
hasCookie: false,
|
||||||
|
searchApiTemplate: { available: false },
|
||||||
|
detailTemplate: { available: false },
|
||||||
|
reviewsTemplate: { available: false }
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getSessionSummary() {
|
||||||
|
return overrides.getSessionSummary?.() ?? summary;
|
||||||
|
},
|
||||||
|
importSession(input) {
|
||||||
|
if (overrides.importSession) {
|
||||||
|
return overrides.importSession(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
configured: true,
|
||||||
|
importedAt: "2026-04-07T10:00:00.000Z",
|
||||||
|
hasCookie: true,
|
||||||
|
userAgent: input.userAgent ?? "stub-user-agent",
|
||||||
|
searchApiTemplate: { available: Boolean(input.searchApiTemplateUrl) },
|
||||||
|
detailTemplate: { available: Boolean(input.detailTemplateUrl) },
|
||||||
|
reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl) }
|
||||||
|
};
|
||||||
|
return summary;
|
||||||
|
},
|
||||||
|
clearSession() {
|
||||||
|
if (overrides.clearSession) {
|
||||||
|
overrides.clearSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
configured: false,
|
||||||
|
hasCookie: false,
|
||||||
|
searchApiTemplate: { available: false },
|
||||||
|
detailTemplate: { available: false },
|
||||||
|
reviewsTemplate: { available: false }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async previewSearch(query) {
|
||||||
|
if (overrides.previewSearch) {
|
||||||
|
return overrides.previewSearch(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview: JdSearchPreviewResult = {
|
||||||
|
query,
|
||||||
|
source: "api",
|
||||||
|
candidateCount: 1,
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
candidateId: "jd-100068388533",
|
||||||
|
platform: "jd",
|
||||||
|
title: "Nintendo Switch 2",
|
||||||
|
price: 2999,
|
||||||
|
priceLabel: "¥2999",
|
||||||
|
storeName: "京东自营",
|
||||||
|
productUrl: "https://item.jd.com/100068388533.html",
|
||||||
|
imageUrl: "https://img14.360buyimg.com/n2/jfs/t1/example.jpg",
|
||||||
|
salesHint: "已售 1000+",
|
||||||
|
specLabel: "标准版",
|
||||||
|
highlights: ["掌机", "续航"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return preview;
|
||||||
|
},
|
||||||
|
async previewDetail(skuId) {
|
||||||
|
if (overrides.previewDetail) {
|
||||||
|
return overrides.previewDetail(skuId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview: JdDetailPreviewResult = {
|
||||||
|
skuId,
|
||||||
|
source: "api",
|
||||||
|
detail: {
|
||||||
|
skuId,
|
||||||
|
title: "Nintendo Switch 2",
|
||||||
|
price: "2999.00",
|
||||||
|
originalPrice: "3299.00",
|
||||||
|
estimatedPrice: "2999.00",
|
||||||
|
shopName: "京东自营",
|
||||||
|
vendorId: null,
|
||||||
|
categoryPath: ["游戏设备", "掌机"],
|
||||||
|
stockState: "有货",
|
||||||
|
mainImage: "https://img14.360buyimg.com/n2/jfs/t1/example.jpg",
|
||||||
|
averageScore: "4.9"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return preview;
|
||||||
|
},
|
||||||
|
async previewReviews(skuId, options) {
|
||||||
|
if (overrides.previewReviews) {
|
||||||
|
return overrides.previewReviews(skuId, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedCommentCount =
|
||||||
|
typeof options === "number" ? options : (options?.commentCount ?? 5);
|
||||||
|
const preview: JdReviewsPreviewResult = {
|
||||||
|
skuId,
|
||||||
|
source: "api",
|
||||||
|
pagination: {
|
||||||
|
requestedPage: typeof options === "object" ? (options?.page ?? 1) : 1,
|
||||||
|
requestedCommentCount,
|
||||||
|
maxPages: typeof options === "object" ? (options?.maxPages ?? 1) : 1,
|
||||||
|
pagesFetched: 1
|
||||||
|
},
|
||||||
|
reviews: {
|
||||||
|
skuId,
|
||||||
|
total: "2",
|
||||||
|
goodRate: "95%",
|
||||||
|
pictureCount: "1",
|
||||||
|
tags: [{ tagId: "tag-1", name: "续航稳定", count: "2" }],
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
id: "comment-1",
|
||||||
|
content: "第一条抓取评论,重点提到运行流畅。",
|
||||||
|
score: "5",
|
||||||
|
creationTime: "2026-04-07 10:00:00",
|
||||||
|
userLevelName: "PLUS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "comment-2",
|
||||||
|
content: "第二条抓取评论,重点提到续航稳定。",
|
||||||
|
score: "4",
|
||||||
|
creationTime: "2026-04-06 18:00:00",
|
||||||
|
userLevelName: "会员"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return preview;
|
||||||
|
},
|
||||||
|
async previewProduct(skuId, options?: number | JdReviewsPreviewOptions) {
|
||||||
|
if (overrides.previewProduct) {
|
||||||
|
return overrides.previewProduct(skuId, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = await this.previewDetail(skuId);
|
||||||
|
const reviews = await this.previewReviews(skuId, options);
|
||||||
|
const preview: JdProductPreviewResult = {
|
||||||
|
skuId,
|
||||||
|
source: "api",
|
||||||
|
detail: detail.detail,
|
||||||
|
pagination: reviews.pagination,
|
||||||
|
reviews: reviews.reviews
|
||||||
|
};
|
||||||
|
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("report review collections", () => {
|
||||||
|
it("publishes per-link review collections for report pages", async () => {
|
||||||
|
const app = createServer({ jdLiveService: createJdLiveServiceStub() });
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const importResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/platforms/jd/live-session",
|
||||||
|
payload: {
|
||||||
|
cookieHeader: "thor=masked; pin=masked;",
|
||||||
|
searchApiTemplateUrl:
|
||||||
|
"https://api.m.jd.com/?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22switch%22%7D",
|
||||||
|
detailTemplateUrl:
|
||||||
|
"https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness&body=%7B%22skuId%22:%22100068388533%22%7D",
|
||||||
|
reviewsTemplateUrl:
|
||||||
|
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22sku%22:100068388533%7D"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(importResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const prepareResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/platforms/jd/prepare"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prepareResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const task = await createTask(app, "Nintendo Switch 2");
|
||||||
|
const candidatesResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/tasks/${task.taskId}/candidates`
|
||||||
|
});
|
||||||
|
const firstCandidateId = candidatesResponse.json().candidates.jd[0].candidateId;
|
||||||
|
|
||||||
|
const confirmResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/tasks/${task.taskId}/confirm`,
|
||||||
|
payload: {
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
platform: "jd",
|
||||||
|
candidateIds: [firstCandidateId]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(confirmResponse.statusCode).toBe(200);
|
||||||
|
expect(confirmResponse.json().task.taskStatus).toBe("Completed");
|
||||||
|
|
||||||
|
const reportResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/tasks/${task.taskId}/report`
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reportResponse.statusCode).toBe(200);
|
||||||
|
expect(reportResponse.json().report.review_collections).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
platform: "jd",
|
||||||
|
title: "Nintendo Switch 2",
|
||||||
|
review_count: 2,
|
||||||
|
product_evidence_id: expect.any(String),
|
||||||
|
sampled_review_refs: expect.arrayContaining(["comment-1"]),
|
||||||
|
comments: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
review_ref: "comment-1",
|
||||||
|
content: "第一条抓取评论,重点提到运行流畅。"
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
review_ref: "comment-2",
|
||||||
|
content: "第二条抓取评论,重点提到续航稳定。"
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1074,7 +1074,26 @@ describe("API server", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
platform: "jd",
|
platform: "jd",
|
||||||
source_type: "review",
|
source_type: "review",
|
||||||
review_ref: "comment-1"
|
review_ref: "comment-1",
|
||||||
|
review_detail: expect.objectContaining({
|
||||||
|
content: expect.any(String)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(reportResponse.json().report.review_collections).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
platform: "jd",
|
||||||
|
source_url: "https://item.jd.com/100068388533.html",
|
||||||
|
review_count: 1,
|
||||||
|
product_evidence_id: expect.any(String),
|
||||||
|
comments: [
|
||||||
|
expect.objectContaining({
|
||||||
|
review_ref: "comment-1",
|
||||||
|
content: expect.any(String)
|
||||||
|
})
|
||||||
|
]
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
@ -1421,6 +1440,61 @@ describe("API server", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("auto-resumes JD SearchBlocked tasks after the managed session passes health check", async () => {
|
||||||
|
const app = createServer({
|
||||||
|
jdLiveService: createJdLiveServiceStub()
|
||||||
|
});
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const createdTask = await createTask(app, "iPhone 15 Pro");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
createdTask.platformRuns.find((run: { platform: string }) => run.platform === "jd")
|
||||||
|
).toMatchObject({
|
||||||
|
platform: "jd",
|
||||||
|
status: "SearchBlocked"
|
||||||
|
});
|
||||||
|
|
||||||
|
const importResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/ops/jd/session-manager/session",
|
||||||
|
payload: {
|
||||||
|
cookieHeader: "thor=masked; pin=masked;",
|
||||||
|
detailTemplateUrl:
|
||||||
|
"https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness&body=%7B%22skuId%22:%22100068388533%22%7D",
|
||||||
|
reviewsTemplateUrl:
|
||||||
|
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22sku%22:100068388533%7D"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(importResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const recoveredTask = await waitForTask(
|
||||||
|
app,
|
||||||
|
createdTask.taskId,
|
||||||
|
(task) =>
|
||||||
|
task.platformRuns.some(
|
||||||
|
(run: { platform: string; status: string }) =>
|
||||||
|
run.platform === "jd" && run.status === "AwaitingSelection"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
recoveredTask.platformRuns.find((run: { platform: string }) => run.platform === "jd")
|
||||||
|
).toMatchObject({
|
||||||
|
platform: "jd",
|
||||||
|
status: "AwaitingSelection"
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidatesResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/tasks/${createdTask.taskId}/candidates`
|
||||||
|
});
|
||||||
|
expect(candidatesResponse.json().candidates.jd).toHaveLength(1);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("records recovery audit entries and retry metrics for recovered platforms", async () => {
|
it("records recovery audit entries and retry metrics for recovered platforms", async () => {
|
||||||
const app = createServer();
|
const app = createServer();
|
||||||
await app.ready();
|
await app.ready();
|
||||||
|
|||||||
@ -374,6 +374,73 @@ function getExecutionCommentUserLabel(comment: ExecutionReviewComment): string |
|
|||||||
return "userLevelName" in comment ? comment.userLevelName : comment.userNick;
|
return "userLevelName" in comment ? comment.userLevelName : comment.userNick;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getExecutionCommentSkuLabels(comment: ExecutionReviewComment): string[] {
|
||||||
|
return "skuText" in comment ? comment.skuText.filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExecutionCommentLikeCount(comment: ExecutionReviewComment): string | null {
|
||||||
|
return "likeCount" in comment ? comment.likeCount : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExecutionCommentReply(comment: ExecutionReviewComment): string | null {
|
||||||
|
return "reply" in comment ? comment.reply : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExecutionCommentAppendContent(comment: ExecutionReviewComment): string | null {
|
||||||
|
return "appendContent" in comment ? comment.appendContent : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExecutionCommentPictureUrls(comment: ExecutionReviewComment): string[] {
|
||||||
|
return "pictureUrls" in comment ? comment.pictureUrls.filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExecutionCommentVideoUrls(comment: ExecutionReviewComment): string[] {
|
||||||
|
return "videoUrls" in comment ? comment.videoUrls.filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExecutionCommentAppendPictureUrls(comment: ExecutionReviewComment): string[] {
|
||||||
|
return "appendPictureUrls" in comment ? comment.appendPictureUrls.filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toReportReviewCollectionComment(
|
||||||
|
comment: ExecutionReviewComment,
|
||||||
|
sampleBucket: ReviewSamplingBucket | null
|
||||||
|
): NonNullable<ReportSnapshot["review_collections"]>[number]["comments"][number] {
|
||||||
|
return {
|
||||||
|
review_ref: comment.id,
|
||||||
|
sample_bucket: sampleBucket,
|
||||||
|
content: comment.content,
|
||||||
|
score: getExecutionCommentScore(comment),
|
||||||
|
created_at: getExecutionCommentDate(comment),
|
||||||
|
author_label: getExecutionCommentUserLabel(comment),
|
||||||
|
sku_labels: getExecutionCommentSkuLabels(comment),
|
||||||
|
like_count: getExecutionCommentLikeCount(comment),
|
||||||
|
reply: getExecutionCommentReply(comment),
|
||||||
|
append_content: getExecutionCommentAppendContent(comment),
|
||||||
|
picture_urls: getExecutionCommentPictureUrls(comment),
|
||||||
|
video_urls: getExecutionCommentVideoUrls(comment),
|
||||||
|
append_picture_urls: getExecutionCommentAppendPictureUrls(comment)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toReportEvidenceReviewDetail(
|
||||||
|
comment: ExecutionReviewComment
|
||||||
|
): NonNullable<ReportSnapshot["evidence_index"][number]["review_detail"]> {
|
||||||
|
return {
|
||||||
|
content: comment.content,
|
||||||
|
score: getExecutionCommentScore(comment),
|
||||||
|
created_at: getExecutionCommentDate(comment),
|
||||||
|
author_label: getExecutionCommentUserLabel(comment),
|
||||||
|
sku_labels: getExecutionCommentSkuLabels(comment),
|
||||||
|
like_count: getExecutionCommentLikeCount(comment),
|
||||||
|
reply: getExecutionCommentReply(comment),
|
||||||
|
append_content: getExecutionCommentAppendContent(comment),
|
||||||
|
picture_urls: getExecutionCommentPictureUrls(comment),
|
||||||
|
video_urls: getExecutionCommentVideoUrls(comment),
|
||||||
|
append_picture_urls: getExecutionCommentAppendPictureUrls(comment)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function toReviewSamplingComment(comment: ExecutionReviewComment): ReviewSamplingComment {
|
function toReviewSamplingComment(comment: ExecutionReviewComment): ReviewSamplingComment {
|
||||||
return {
|
return {
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
@ -1721,7 +1788,7 @@ export class InMemoryTaskStore {
|
|||||||
try {
|
try {
|
||||||
await this.retryPlatform(taskId, platform);
|
await this.retryPlatform(taskId, platform);
|
||||||
} catch {
|
} catch {
|
||||||
// retryPlatform already records recoverable failures on the task.
|
// Keep the current task state; retryPlatform already records recoverable failures.
|
||||||
} finally {
|
} finally {
|
||||||
this.pendingManagedSessionRetries.delete(retryKey);
|
this.pendingManagedSessionRetries.delete(retryKey);
|
||||||
}
|
}
|
||||||
@ -1787,6 +1854,7 @@ export class InMemoryTaskStore {
|
|||||||
const sourcePlatforms: PlatformId[] =
|
const sourcePlatforms: PlatformId[] =
|
||||||
insightPlatforms.length > 0 ? insightPlatforms : ["tmall"];
|
insightPlatforms.length > 0 ? insightPlatforms : ["tmall"];
|
||||||
const evidenceIndex: ReportSnapshot["evidence_index"] = [];
|
const evidenceIndex: ReportSnapshot["evidence_index"] = [];
|
||||||
|
const reviewCollections: NonNullable<ReportSnapshot["review_collections"]> = [];
|
||||||
const evidenceIdsByPlatform = new Map<PlatformId, string[]>();
|
const evidenceIdsByPlatform = new Map<PlatformId, string[]>();
|
||||||
let evidenceCounter = 0;
|
let evidenceCounter = 0;
|
||||||
|
|
||||||
@ -1798,7 +1866,8 @@ export class InMemoryTaskStore {
|
|||||||
sourceType: "product" | "review",
|
sourceType: "product" | "review",
|
||||||
sourceUrl: string,
|
sourceUrl: string,
|
||||||
snippet: string,
|
snippet: string,
|
||||||
reviewRef: string | null
|
reviewRef: string | null,
|
||||||
|
reviewDetail?: NonNullable<ReportSnapshot["evidence_index"][number]["review_detail"]>
|
||||||
) => {
|
) => {
|
||||||
const evidenceId = `evidence-${task.taskId}-${++evidenceCounter}`;
|
const evidenceId = `evidence-${task.taskId}-${++evidenceCounter}`;
|
||||||
evidenceIndex.push({
|
evidenceIndex.push({
|
||||||
@ -1807,6 +1876,7 @@ export class InMemoryTaskStore {
|
|||||||
source_type: sourceType,
|
source_type: sourceType,
|
||||||
source_url: sourceUrl,
|
source_url: sourceUrl,
|
||||||
review_ref: reviewRef,
|
review_ref: reviewRef,
|
||||||
|
...(reviewDetail ? { review_detail: reviewDetail } : {}),
|
||||||
snippet,
|
snippet,
|
||||||
captured_at: nowIso()
|
captured_at: nowIso()
|
||||||
});
|
});
|
||||||
@ -1839,7 +1909,39 @@ export class InMemoryTaskStore {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" | ");
|
.join(" | ");
|
||||||
|
|
||||||
addEvidence(candidate.platform, "product", candidate.productUrl, detailSummary, null);
|
const productEvidenceId = addEvidence(
|
||||||
|
candidate.platform,
|
||||||
|
"product",
|
||||||
|
candidate.productUrl,
|
||||||
|
detailSummary,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const sampledBucketsByCommentId = new Map(
|
||||||
|
sampledComments.map(({ bucket, comment }) => [comment.id, bucket] as const)
|
||||||
|
);
|
||||||
|
const executionComments = artifact?.reviews.comments ?? [];
|
||||||
|
|
||||||
|
if (artifact) {
|
||||||
|
reviewCollections.push({
|
||||||
|
collection_id: `review-collection-${candidate.candidateId}`,
|
||||||
|
candidate_id: candidate.candidateId,
|
||||||
|
product_evidence_id: productEvidenceId,
|
||||||
|
platform: candidate.platform,
|
||||||
|
source_url: candidate.productUrl,
|
||||||
|
title: headline,
|
||||||
|
store_name: storeName,
|
||||||
|
price_label: priceLabel,
|
||||||
|
captured_at: artifact.capturedAt,
|
||||||
|
review_count: executionComments.length,
|
||||||
|
sampled_review_refs: sampledComments.map(({ comment }) => comment.id),
|
||||||
|
comments: executionComments.map((comment) =>
|
||||||
|
toReportReviewCollectionComment(
|
||||||
|
comment,
|
||||||
|
sampledBucketsByCommentId.get(comment.id) ?? null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const { bucket, comment } of sampledComments.slice(0, 2)) {
|
for (const { bucket, comment } of sampledComments.slice(0, 2)) {
|
||||||
const commentSummary = [
|
const commentSummary = [
|
||||||
@ -1856,7 +1958,8 @@ export class InMemoryTaskStore {
|
|||||||
"review",
|
"review",
|
||||||
candidate.productUrl,
|
candidate.productUrl,
|
||||||
commentSummary,
|
commentSummary,
|
||||||
comment.id
|
comment.id,
|
||||||
|
toReportEvidenceReviewDetail(comment)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2097,6 +2200,7 @@ export class InMemoryTaskStore {
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
evidence_index: evidenceIndex,
|
evidence_index: evidenceIndex,
|
||||||
|
review_collections: reviewCollections,
|
||||||
quality_flags: {
|
quality_flags: {
|
||||||
sample_insufficient: sampleInsufficient,
|
sample_insufficient: sampleInsufficient,
|
||||||
partial_platform_failure:
|
partial_platform_failure:
|
||||||
@ -2785,6 +2889,73 @@ export class InMemoryTaskStore {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleTaskExecution(taskId: string): void {
|
||||||
|
if (this.pendingExecutions.has(taskId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const execution = new Promise<void>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
const task = this.requireTask(taskId);
|
||||||
|
const selectedRuns = task.platformRuns.filter((run) => run.status === "Selected");
|
||||||
|
|
||||||
|
if (selectedRuns.length > 0) {
|
||||||
|
await this.executeSelectedPlatforms(task, selectedRuns);
|
||||||
|
}
|
||||||
|
|
||||||
|
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
|
||||||
|
task.updatedAt = nowIso();
|
||||||
|
const published = this.publishReportIfNeeded(task);
|
||||||
|
this.persistState();
|
||||||
|
|
||||||
|
if (!published) {
|
||||||
|
this.emitTaskSnapshot(task);
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.catch((error) => {
|
||||||
|
this.failBackgroundTaskExecution(taskId, error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.pendingExecutions.delete(taskId);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pendingExecutions.set(taskId, execution);
|
||||||
|
}
|
||||||
|
|
||||||
|
private failBackgroundTaskExecution(taskId: string, error: unknown): void {
|
||||||
|
const task = this.tasks.get(taskId);
|
||||||
|
if (!task) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const run of task.platformRuns) {
|
||||||
|
if (run.selectedCandidateIds.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.status === "Selected" || run.status === "Running") {
|
||||||
|
run.status = "Failed";
|
||||||
|
run.reason = "后台执行异常中断,请稍后重试。";
|
||||||
|
run.lastUpdatedAt = nowIso();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task.taskStage = "publish";
|
||||||
|
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
|
||||||
|
this.pushEvent(
|
||||||
|
task,
|
||||||
|
"task.execution_failed",
|
||||||
|
error instanceof Error
|
||||||
|
? `后台执行异常中断:${error.message}`
|
||||||
|
: "后台执行异常中断,请稍后重试。"
|
||||||
|
);
|
||||||
|
this.persistState();
|
||||||
|
}
|
||||||
|
|
||||||
private buildReportFingerprint(task: TaskRecord): string {
|
private buildReportFingerprint(task: TaskRecord): string {
|
||||||
const selectedLinkCount = task.platformRuns.reduce(
|
const selectedLinkCount = task.platformRuns.reduce(
|
||||||
(sum, run) => sum + run.selectedCandidateIds.length,
|
(sum, run) => sum + run.selectedCandidateIds.length,
|
||||||
@ -3062,73 +3233,6 @@ export class InMemoryTaskStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleTaskExecution(taskId: string): void {
|
|
||||||
if (this.pendingExecutions.has(taskId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const execution = new Promise<void>((resolve) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
void (async () => {
|
|
||||||
const task = this.requireTask(taskId);
|
|
||||||
const selectedRuns = task.platformRuns.filter((run) => run.status === "Selected");
|
|
||||||
|
|
||||||
if (selectedRuns.length > 0) {
|
|
||||||
await this.executeSelectedPlatforms(task, selectedRuns);
|
|
||||||
}
|
|
||||||
|
|
||||||
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
|
|
||||||
task.updatedAt = nowIso();
|
|
||||||
const published = this.publishReportIfNeeded(task);
|
|
||||||
this.persistState();
|
|
||||||
|
|
||||||
if (!published) {
|
|
||||||
this.emitTaskSnapshot(task);
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
.catch((error) => {
|
|
||||||
this.failBackgroundTaskExecution(taskId, error);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.pendingExecutions.delete(taskId);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
}, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.pendingExecutions.set(taskId, execution);
|
|
||||||
}
|
|
||||||
|
|
||||||
private failBackgroundTaskExecution(taskId: string, error: unknown): void {
|
|
||||||
const task = this.tasks.get(taskId);
|
|
||||||
if (!task) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const run of task.platformRuns) {
|
|
||||||
if (run.selectedCandidateIds.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (run.status === "Selected" || run.status === "Running") {
|
|
||||||
run.status = "Failed";
|
|
||||||
run.reason = "后台执行异常中断,请稍后重试。";
|
|
||||||
run.lastUpdatedAt = nowIso();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
task.taskStage = "publish";
|
|
||||||
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
|
|
||||||
this.pushEvent(
|
|
||||||
task,
|
|
||||||
"task.execution_failed",
|
|
||||||
error instanceof Error
|
|
||||||
? `后台执行异常中断:${error.message}`
|
|
||||||
: "后台执行异常中断,请稍后重试。"
|
|
||||||
);
|
|
||||||
this.persistState();
|
|
||||||
}
|
|
||||||
|
|
||||||
private requireTask(taskId: string): TaskRecord {
|
private requireTask(taskId: string): TaskRecord {
|
||||||
const task = this.tasks.get(taskId);
|
const task = this.tasks.get(taskId);
|
||||||
if (!task) {
|
if (!task) {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
type TaskRecord,
|
type TaskRecord,
|
||||||
type TaskStatus
|
type TaskStatus
|
||||||
} from "@cross-ai/domain";
|
} from "@cross-ai/domain";
|
||||||
|
import type { ReportSnapshot } from "@cross-ai/report-schema";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@ -52,6 +53,12 @@ import {
|
|||||||
updateJdSessionManagerConfig
|
updateJdSessionManagerConfig
|
||||||
} from "./lib/api";
|
} from "./lib/api";
|
||||||
|
|
||||||
|
type ReportEvidence = ReportSnapshot["evidence_index"][number];
|
||||||
|
type EvidenceReviewDetail = NonNullable<ReportEvidence["review_detail"]>;
|
||||||
|
type ReportReviewCollection = NonNullable<ReportSnapshot["review_collections"]>[number];
|
||||||
|
type ReportReviewCollectionComment = ReportReviewCollection["comments"][number];
|
||||||
|
type ReviewDetailLike = EvidenceReviewDetail | ReportReviewCollectionComment;
|
||||||
|
|
||||||
function Layout(props: { children: React.ReactNode }) {
|
function Layout(props: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
@ -188,6 +195,42 @@ function formatTimestamp(timestamp?: string) {
|
|||||||
return new Date(timestamp).toLocaleString("zh-CN");
|
return new Date(timestamp).toLocaleString("zh-CN");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEvidenceSourceLabel(sourceType: ReportEvidence["source_type"]) {
|
||||||
|
return sourceType === "review" ? "评论证据" : "商品证据";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReviewScore(score?: string | null) {
|
||||||
|
return score ? `${score} 分` : "未评分";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReviewMediaSummary(reviewDetail: ReviewDetailLike) {
|
||||||
|
const pictureCount = reviewDetail.picture_urls?.length ?? 0;
|
||||||
|
const videoCount = reviewDetail.video_urls?.length ?? 0;
|
||||||
|
const appendPictureCount = reviewDetail.append_picture_urls?.length ?? 0;
|
||||||
|
const parts = [
|
||||||
|
pictureCount > 0 ? `图片 ${pictureCount}` : null,
|
||||||
|
videoCount > 0 ? `视频 ${videoCount}` : null,
|
||||||
|
appendPictureCount > 0 ? `追评图片 ${appendPictureCount}` : null
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join(" · ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReviewSampleBucketLabel(
|
||||||
|
bucket?: ReportReviewCollectionComment["sample_bucket"] | null
|
||||||
|
) {
|
||||||
|
switch (bucket) {
|
||||||
|
case "latest":
|
||||||
|
return "最新样本";
|
||||||
|
case "hot":
|
||||||
|
return "热门样本";
|
||||||
|
case "negative":
|
||||||
|
return "负向样本";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getSearchRequirementLabel(
|
function getSearchRequirementLabel(
|
||||||
searchRequirement: SessionReadinessRecord["searchRequirement"]
|
searchRequirement: SessionReadinessRecord["searchRequirement"]
|
||||||
) {
|
) {
|
||||||
@ -595,9 +638,9 @@ function CandidateCard(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ConfirmPage() {
|
function ConfirmPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { taskId = "" } = useParams();
|
const { taskId = "" } = useParams();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
useTaskLiveSync(taskId, { includeCandidates: true });
|
useTaskLiveSync(taskId, { includeCandidates: true });
|
||||||
const taskQuery = useQuery({
|
const taskQuery = useQuery({
|
||||||
queryKey: ["task", taskId],
|
queryKey: ["task", taskId],
|
||||||
@ -682,10 +725,19 @@ function ConfirmPage() {
|
|||||||
<p>{platformRun?.reason ?? "当前没有候选结果。"}</p>
|
<p>{platformRun?.reason ?? "当前没有候选结果。"}</p>
|
||||||
{platformRun?.status === "SearchBlocked" ? (
|
{platformRun?.status === "SearchBlocked" ? (
|
||||||
isOpsManagedPlatform(platform) ? (
|
isOpsManagedPlatform(platform) ? (
|
||||||
<>
|
|
||||||
<span className="inline-note inline-note--subtle">
|
<span className="inline-note inline-note--subtle">
|
||||||
京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。
|
京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
className="text-link"
|
||||||
|
href={`/ops/tasks/${taskId}/recovery/${platform}?from=/tasks/${taskId}/confirm`}
|
||||||
|
>
|
||||||
|
处理阻塞并重试
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{platformRun?.status === "SearchBlocked" && isOpsManagedPlatform(platform) ? (
|
||||||
<div className="panel-actions">
|
<div className="panel-actions">
|
||||||
<a
|
<a
|
||||||
className="text-link"
|
className="text-link"
|
||||||
@ -702,15 +754,6 @@ function ConfirmPage() {
|
|||||||
恢复后重试候选
|
恢复后重试候选
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<a
|
|
||||||
className="text-link"
|
|
||||||
href={`/ops/tasks/${taskId}/recovery/${platform}?from=/tasks/${taskId}/confirm`}
|
|
||||||
>
|
|
||||||
处理阻塞并重试
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
) : null}
|
) : null}
|
||||||
{platformRun?.status === "Failed" ? (
|
{platformRun?.status === "Failed" ? (
|
||||||
<div className="panel-actions">
|
<div className="panel-actions">
|
||||||
@ -768,13 +811,14 @@ function ConfirmPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RunPage() {
|
function RunPage() {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { taskId = "" } = useParams();
|
const { taskId = "" } = useParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
useTaskLiveSync(taskId);
|
useTaskLiveSync(taskId);
|
||||||
const taskQuery = useQuery({
|
const taskQuery = useQuery({
|
||||||
queryKey: ["task", taskId],
|
queryKey: ["task", taskId],
|
||||||
queryFn: () => getTask(taskId)
|
queryFn: () => getTask(taskId)
|
||||||
});
|
});
|
||||||
|
|
||||||
const retryMutation = useMutation({
|
const retryMutation = useMutation({
|
||||||
mutationFn: (platform: PlatformId) => retryTaskPlatform(taskId, platform),
|
mutationFn: (platform: PlatformId) => retryTaskPlatform(taskId, platform),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@ -868,11 +912,110 @@ function RunPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricCard(props: { label: string; value: string }) {
|
function MetricCard(props: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
expanded?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
const className = props.onClick ? "metric-card metric-card--button" : "metric-card";
|
||||||
|
|
||||||
|
if (props.onClick) {
|
||||||
return (
|
return (
|
||||||
<div className="metric-card">
|
<button
|
||||||
|
aria-expanded={props.expanded}
|
||||||
|
aria-label={props.ariaLabel}
|
||||||
|
className={className}
|
||||||
|
onClick={props.onClick}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<span>{props.label}</span>
|
<span>{props.label}</span>
|
||||||
<strong>{props.value}</strong>
|
<strong>{props.value}</strong>
|
||||||
|
{props.hint ? <small>{props.hint}</small> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<span>{props.label}</span>
|
||||||
|
<strong>{props.value}</strong>
|
||||||
|
{props.hint ? <small>{props.hint}</small> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewDetailPanel(props: {
|
||||||
|
detail: ReviewDetailLike;
|
||||||
|
reviewRef?: string | null;
|
||||||
|
bucketLabel?: string | null;
|
||||||
|
}) {
|
||||||
|
const { detail, reviewRef, bucketLabel } = props;
|
||||||
|
const mediaSummary = getReviewMediaSummary(detail);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="evidence-review-detail">
|
||||||
|
<div className="evidence-review-detail__meta">
|
||||||
|
{bucketLabel ? <span className="inline-note">{bucketLabel}</span> : null}
|
||||||
|
{reviewRef ? <span className="inline-note inline-note--subtle">#{reviewRef}</span> : null}
|
||||||
|
{detail.author_label ? (
|
||||||
|
<span className="inline-note inline-note--subtle">{detail.author_label}</span>
|
||||||
|
) : null}
|
||||||
|
<span className="inline-note inline-note--subtle">{formatReviewScore(detail.score)}</span>
|
||||||
|
{detail.created_at ? (
|
||||||
|
<span className="inline-note inline-note--subtle">{detail.created_at}</span>
|
||||||
|
) : null}
|
||||||
|
{detail.sku_labels && detail.sku_labels.length > 0 ? (
|
||||||
|
<span className="inline-note inline-note--subtle">
|
||||||
|
{detail.sku_labels.join(" / ")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{detail.like_count ? (
|
||||||
|
<span className="inline-note inline-note--subtle">点赞 {detail.like_count}</span>
|
||||||
|
) : null}
|
||||||
|
{mediaSummary ? (
|
||||||
|
<span className="inline-note inline-note--subtle">{mediaSummary}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="stack stack--dense">
|
||||||
|
<strong>评论正文</strong>
|
||||||
|
<p>{detail.content}</p>
|
||||||
|
</div>
|
||||||
|
{detail.append_content ? (
|
||||||
|
<div className="stack stack--dense">
|
||||||
|
<strong>追评</strong>
|
||||||
|
<p>{detail.append_content}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{detail.reply ? (
|
||||||
|
<div className="stack stack--dense">
|
||||||
|
<strong>商家回复</strong>
|
||||||
|
<p>{detail.reply}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewCollectionComments(props: { collection: ReportReviewCollection }) {
|
||||||
|
const { collection } = props;
|
||||||
|
|
||||||
|
if (collection.comments.length === 0) {
|
||||||
|
return <p className="inline-note inline-note--subtle">当前链接没有保留抓取评论。</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="review-comment-list">
|
||||||
|
{collection.comments.map((comment) => (
|
||||||
|
<ReviewDetailPanel
|
||||||
|
key={comment.review_ref}
|
||||||
|
bucketLabel={getReviewSampleBucketLabel(comment.sample_bucket)}
|
||||||
|
detail={comment}
|
||||||
|
reviewRef={comment.review_ref}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -881,6 +1024,11 @@ function ReportPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { taskId = "" } = useParams();
|
const { taskId = "" } = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const [expandedEvidenceId, setExpandedEvidenceId] = useState<string | null>(null);
|
||||||
|
const [expandedCollectionEvidenceId, setExpandedCollectionEvidenceId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [showAllReviewCollections, setShowAllReviewCollections] = useState(false);
|
||||||
const version = searchParams.get("version");
|
const version = searchParams.get("version");
|
||||||
const taskQuery = useQuery({
|
const taskQuery = useQuery({
|
||||||
queryKey: ["task", taskId],
|
queryKey: ["task", taskId],
|
||||||
@ -890,6 +1038,13 @@ function ReportPage() {
|
|||||||
queryKey: ["report", taskId, version],
|
queryKey: ["report", taskId, version],
|
||||||
queryFn: () => getTaskReport(taskId, version ? Number(version) : undefined)
|
queryFn: () => getTaskReport(taskId, version ? Number(version) : undefined)
|
||||||
});
|
});
|
||||||
|
const reportId = reportQuery.data?.report.report_id;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExpandedEvidenceId(null);
|
||||||
|
setExpandedCollectionEvidenceId(null);
|
||||||
|
setShowAllReviewCollections(false);
|
||||||
|
}, [reportId]);
|
||||||
|
|
||||||
if (!taskQuery.data || !reportQuery.data) {
|
if (!taskQuery.data || !reportQuery.data) {
|
||||||
return (
|
return (
|
||||||
@ -905,6 +1060,14 @@ function ReportPage() {
|
|||||||
version && Number.isFinite(Number(version))
|
version && Number.isFinite(Number(version))
|
||||||
? Number(version)
|
? Number(version)
|
||||||
: task.defaultReportVersion ?? report.report_version;
|
: task.defaultReportVersion ?? report.report_version;
|
||||||
|
const reviewCollections = report.review_collections ?? [];
|
||||||
|
const reviewCollectionsByProductEvidenceId = new Map(
|
||||||
|
reviewCollections.map((collection) => [collection.product_evidence_id, collection] as const)
|
||||||
|
);
|
||||||
|
const totalCapturedReviewCount = reviewCollections.reduce(
|
||||||
|
(sum, collection) => sum + collection.review_count,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
@ -926,6 +1089,14 @@ function ReportPage() {
|
|||||||
<MetricCard
|
<MetricCard
|
||||||
label="评论样本"
|
label="评论样本"
|
||||||
value={String(report.product_snapshot.review_sample_count)}
|
value={String(report.product_snapshot.review_sample_count)}
|
||||||
|
{...(reviewCollections.length > 0
|
||||||
|
? {
|
||||||
|
ariaLabel: showAllReviewCollections ? "收起全部抓取评论" : "查看全部抓取评论",
|
||||||
|
expanded: showAllReviewCollections,
|
||||||
|
hint: showAllReviewCollections ? "收起全部抓取评论" : "点击查看全部抓取评论",
|
||||||
|
onClick: () => setShowAllReviewCollections((current) => !current)
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
/>
|
/>
|
||||||
<MetricCard label="报告版本" value={`v${report.report_version}`} />
|
<MetricCard label="报告版本" value={`v${report.report_version}`} />
|
||||||
</div>
|
</div>
|
||||||
@ -982,6 +1153,81 @@ function ReportPage() {
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{reviewCollections.length > 0 ? (
|
||||||
|
<section className="page-panel">
|
||||||
|
<div className="candidate-section__header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Review Collections</p>
|
||||||
|
<h3>按链接查看全部抓取评论</h3>
|
||||||
|
</div>
|
||||||
|
<div className="panel-actions">
|
||||||
|
<span className="inline-note inline-note--subtle">
|
||||||
|
共保留 {totalCapturedReviewCount} 条抓取评论
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => setShowAllReviewCollections((current) => !current)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{showAllReviewCollections ? "收起全部抓取评论" : "展开全部抓取评论"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showAllReviewCollections ? (
|
||||||
|
<div className="stack stack--dense">
|
||||||
|
{reviewCollections.map((collection) => (
|
||||||
|
<div key={collection.collection_id} className="evidence-card">
|
||||||
|
<div className="evidence-card__header">
|
||||||
|
<strong>{collection.title}</strong>
|
||||||
|
<span className="inline-note inline-note--subtle">
|
||||||
|
已抓取 {collection.review_count} 条评论
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="evidence-card__meta">
|
||||||
|
<span className="inline-note inline-note--subtle">
|
||||||
|
{platformCatalogMap[collection.platform].label}
|
||||||
|
</span>
|
||||||
|
{collection.store_name ? (
|
||||||
|
<span className="inline-note inline-note--subtle">
|
||||||
|
店铺 {collection.store_name}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{collection.price_label ? (
|
||||||
|
<span className="inline-note inline-note--subtle">
|
||||||
|
价格 {collection.price_label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="inline-note inline-note--subtle">
|
||||||
|
{formatTimestamp(collection.captured_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="evidence-card__actions">
|
||||||
|
{collection.sampled_review_refs.length > 0 ? (
|
||||||
|
<span className="inline-note inline-note--subtle">
|
||||||
|
已抽样 {collection.sampled_review_refs.length} 条进入报告样本
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<a
|
||||||
|
className="text-link"
|
||||||
|
href={collection.source_url}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
查看来源
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<ReviewCollectionComments collection={collection} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="inline-note inline-note--subtle">
|
||||||
|
点击上方“评论样本”卡或右侧按钮,可展开全部抓取评论。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="page-grid">
|
<section className="page-grid">
|
||||||
<article className="page-panel">
|
<article className="page-panel">
|
||||||
<p className="eyebrow">Platform Insights</p>
|
<p className="eyebrow">Platform Insights</p>
|
||||||
@ -1016,10 +1262,67 @@ function ReportPage() {
|
|||||||
<article className="page-panel">
|
<article className="page-panel">
|
||||||
<p className="eyebrow">Evidence Index</p>
|
<p className="eyebrow">Evidence Index</p>
|
||||||
<div className="stack stack--dense">
|
<div className="stack stack--dense">
|
||||||
{report.evidence_index.map((evidence) => (
|
{report.evidence_index.map((evidence) => {
|
||||||
|
const reviewDetail = evidence.review_detail;
|
||||||
|
const linkedReviewCollection =
|
||||||
|
evidence.source_type === "product"
|
||||||
|
? reviewCollectionsByProductEvidenceId.get(evidence.evidence_id) ?? null
|
||||||
|
: null;
|
||||||
|
const isCollectionExpanded =
|
||||||
|
expandedCollectionEvidenceId === evidence.evidence_id;
|
||||||
|
const isExpanded = expandedEvidenceId === evidence.evidence_id;
|
||||||
|
|
||||||
|
return (
|
||||||
<div key={evidence.evidence_id} className="evidence-card">
|
<div key={evidence.evidence_id} className="evidence-card">
|
||||||
|
<div className="evidence-card__header">
|
||||||
<strong>{evidence.evidence_id}</strong>
|
<strong>{evidence.evidence_id}</strong>
|
||||||
|
<span className="inline-note inline-note--subtle">
|
||||||
|
{formatTimestamp(evidence.captured_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="evidence-card__meta">
|
||||||
|
<span className="inline-note inline-note--subtle">
|
||||||
|
{platformCatalogMap[evidence.platform].label}
|
||||||
|
</span>
|
||||||
|
<span className="inline-note inline-note--subtle">
|
||||||
|
{getEvidenceSourceLabel(evidence.source_type)}
|
||||||
|
</span>
|
||||||
|
{evidence.review_ref ? (
|
||||||
|
<span className="inline-note inline-note--subtle">#{evidence.review_ref}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<p>{evidence.snippet}</p>
|
<p>{evidence.snippet}</p>
|
||||||
|
<div className="evidence-card__actions">
|
||||||
|
{linkedReviewCollection ? (
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedCollectionEvidenceId((current) =>
|
||||||
|
current === evidence.evidence_id ? null : evidence.evidence_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isCollectionExpanded
|
||||||
|
? "收起该链接评论"
|
||||||
|
: `查看该链接评论(${linkedReviewCollection.review_count})`}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{reviewDetail ? (
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedEvidenceId((current) =>
|
||||||
|
current === evidence.evidence_id ? null : evidence.evidence_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isExpanded ? "收起样本评论" : "查看样本评论"}
|
||||||
|
</button>
|
||||||
|
) : evidence.review_ref ? (
|
||||||
|
<span className="inline-note inline-note--subtle">旧版报告未保留评论正文</span>
|
||||||
|
) : null}
|
||||||
<a
|
<a
|
||||||
className="text-link"
|
className="text-link"
|
||||||
href={evidence.source_url}
|
href={evidence.source_url}
|
||||||
@ -1029,7 +1332,27 @@ function ReportPage() {
|
|||||||
查看来源
|
查看来源
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
))}
|
{isCollectionExpanded && linkedReviewCollection ? (
|
||||||
|
<div className="stack stack--dense">
|
||||||
|
<div className="evidence-card__meta">
|
||||||
|
<span className="inline-note inline-note--subtle">
|
||||||
|
该链接共抓取 {linkedReviewCollection.review_count} 条评论
|
||||||
|
</span>
|
||||||
|
{linkedReviewCollection.sampled_review_refs.length > 0 ? (
|
||||||
|
<span className="inline-note inline-note--subtle">
|
||||||
|
已抽样 {linkedReviewCollection.sampled_review_refs.length} 条进入报告样本
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ReviewCollectionComments collection={linkedReviewCollection} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isExpanded && reviewDetail ? (
|
||||||
|
<ReviewDetailPanel detail={reviewDetail} reviewRef={evidence.review_ref} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
273
apps/web/src/ReportPage.test.tsx
Normal file
273
apps/web/src/ReportPage.test.tsx
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("./lib/api", () => ({
|
||||||
|
cancelJdQrLogin: vi.fn(),
|
||||||
|
cancelTmallQrLogin: vi.fn(),
|
||||||
|
clearJdManagedSession: vi.fn(),
|
||||||
|
clearJdSessionManagerConfig: vi.fn(),
|
||||||
|
clearPlatformSession: vi.fn(),
|
||||||
|
clearTmallManagedSession: vi.fn(),
|
||||||
|
clearTmallSessionManagerConfig: vi.fn(),
|
||||||
|
confirmTask: vi.fn(),
|
||||||
|
createTask: vi.fn(),
|
||||||
|
createTaskEventsSource: vi.fn(() => ({
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
removeEventListener: vi.fn()
|
||||||
|
})),
|
||||||
|
deleteTask: vi.fn(),
|
||||||
|
getHistoryTasks: vi.fn(),
|
||||||
|
getJdKeywordPreview: vi.fn(),
|
||||||
|
getJdLiveSession: vi.fn(),
|
||||||
|
getJdQrLoginState: vi.fn(),
|
||||||
|
getJdSessionManager: vi.fn(),
|
||||||
|
getPlatformReadiness: vi.fn(),
|
||||||
|
getPlatformSession: vi.fn(),
|
||||||
|
getTask: vi.fn(),
|
||||||
|
getTaskCandidates: vi.fn(),
|
||||||
|
getTaskReport: vi.fn(),
|
||||||
|
getTmallLiveSession: vi.fn(),
|
||||||
|
getTmallQrLoginState: vi.fn(),
|
||||||
|
getTmallSessionManager: vi.fn(),
|
||||||
|
importJdManagedSession: vi.fn(),
|
||||||
|
importTmallManagedSession: vi.fn(),
|
||||||
|
preparePlatform: vi.fn(),
|
||||||
|
resumeJdQrLoginManualRecovery: vi.fn(),
|
||||||
|
retryTaskPlatform: vi.fn(),
|
||||||
|
runJdSessionManagerHealthCheck: vi.fn(),
|
||||||
|
runJdSessionManagerRecovery: vi.fn(),
|
||||||
|
runTmallSessionManagerHealthCheck: vi.fn(),
|
||||||
|
startJdQrLogin: vi.fn(),
|
||||||
|
startTmallQrLogin: vi.fn(),
|
||||||
|
updateJdSessionManagerConfig: vi.fn(),
|
||||||
|
updateTmallSessionManagerConfig: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { App } from "./App";
|
||||||
|
import { getTask, getTaskReport } from "./lib/api";
|
||||||
|
|
||||||
|
function renderWithProviders(node: ReactNode, initialEntries: string[]) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={initialEntries}>{node}</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ReportPage", () => {
|
||||||
|
const taskId = "task-report";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getTask).mockResolvedValue({
|
||||||
|
task: {
|
||||||
|
taskId,
|
||||||
|
query: "Nintendo Switch 2",
|
||||||
|
createdAt: "2026-04-02T11:40:00.000Z",
|
||||||
|
updatedAt: "2026-04-02T12:00:00.000Z",
|
||||||
|
perLinkLimit: 100,
|
||||||
|
taskTotalLimit: 500,
|
||||||
|
taskStatus: "Completed",
|
||||||
|
taskStage: "publish",
|
||||||
|
platformRuns: [
|
||||||
|
{
|
||||||
|
platform: "jd",
|
||||||
|
searchRequirement: "required",
|
||||||
|
status: "Completed",
|
||||||
|
candidateCount: 1,
|
||||||
|
selectedCandidateIds: ["jd-1"],
|
||||||
|
lastUpdatedAt: "2026-04-02T11:58:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
platformCandidates: {
|
||||||
|
tmall: [],
|
||||||
|
jd: []
|
||||||
|
},
|
||||||
|
events: [],
|
||||||
|
reportVersions: [1],
|
||||||
|
defaultReportVersion: 1,
|
||||||
|
latestSuccessfulReportVersion: 1
|
||||||
|
}
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(getTaskReport).mockResolvedValue({
|
||||||
|
report: {
|
||||||
|
report_id: "report-1",
|
||||||
|
report_version: 1,
|
||||||
|
task_id: taskId,
|
||||||
|
generated_at: "2026-04-02T12:00:00.000Z",
|
||||||
|
task_status: "Completed",
|
||||||
|
summary: {
|
||||||
|
headline: "报告已包含真实评论证据",
|
||||||
|
key_points: ["评论样本已写入报告证据,可在站内查看。"],
|
||||||
|
limitations: []
|
||||||
|
},
|
||||||
|
product_snapshot: {
|
||||||
|
query: "Nintendo Switch 2",
|
||||||
|
normalized_product_name: "Nintendo Switch 2",
|
||||||
|
platform_count: 1,
|
||||||
|
selected_link_count: 1,
|
||||||
|
review_sample_count: 5,
|
||||||
|
analysis_time_range: {
|
||||||
|
start: "2026-04-02T11:40:00.000Z",
|
||||||
|
end: "2026-04-02T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
platform_insights: [
|
||||||
|
{
|
||||||
|
platform: "jd",
|
||||||
|
execution_status: "completed",
|
||||||
|
selected_link_count: 1,
|
||||||
|
price_range: null,
|
||||||
|
selling_points: [],
|
||||||
|
positive_themes: [],
|
||||||
|
negative_themes: [],
|
||||||
|
store_diff_notes: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
cross_platform_insights: [],
|
||||||
|
recommendations: [],
|
||||||
|
evidence_index: [
|
||||||
|
{
|
||||||
|
evidence_id: "evidence-product-1",
|
||||||
|
platform: "jd",
|
||||||
|
source_type: "product",
|
||||||
|
source_url: "https://item.jd.com/100068388533.html",
|
||||||
|
review_ref: null,
|
||||||
|
snippet: "Nintendo Switch 2 | 店铺 京东自营 | 价格 ¥2999 | 库存 有货",
|
||||||
|
captured_at: "2026-04-02T11:58:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
evidence_id: "evidence-review-1",
|
||||||
|
platform: "jd",
|
||||||
|
source_type: "review",
|
||||||
|
source_url: "https://item.jd.com/100068388533.html",
|
||||||
|
review_ref: "comment-1",
|
||||||
|
review_detail: {
|
||||||
|
content: "这是一条抓取到的完整评论内容。",
|
||||||
|
score: "5",
|
||||||
|
created_at: "2026-04-02",
|
||||||
|
author_label: "PLUS会员",
|
||||||
|
sku_labels: ["标准版"],
|
||||||
|
like_count: "9",
|
||||||
|
reply: "感谢支持。",
|
||||||
|
append_content: "追评:一周后依然满意。",
|
||||||
|
picture_urls: [],
|
||||||
|
video_urls: [],
|
||||||
|
append_picture_urls: []
|
||||||
|
},
|
||||||
|
snippet: "样本 latest | 评分 5 | PLUS会员 | 这是一条抓取到的完整评论内容。",
|
||||||
|
captured_at: "2026-04-02T11:59:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
review_collections: [
|
||||||
|
{
|
||||||
|
collection_id: "review-collection-jd-1",
|
||||||
|
candidate_id: "jd-1",
|
||||||
|
product_evidence_id: "evidence-product-1",
|
||||||
|
platform: "jd",
|
||||||
|
source_url: "https://item.jd.com/100068388533.html",
|
||||||
|
title: "Nintendo Switch 2",
|
||||||
|
store_name: "京东自营",
|
||||||
|
price_label: "¥2999",
|
||||||
|
captured_at: "2026-04-02T11:59:00.000Z",
|
||||||
|
review_count: 2,
|
||||||
|
sampled_review_refs: ["comment-1"],
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
review_ref: "comment-1",
|
||||||
|
sample_bucket: "latest",
|
||||||
|
content: "这是一条抓取到的完整评论内容。",
|
||||||
|
score: "5",
|
||||||
|
created_at: "2026-04-02",
|
||||||
|
author_label: "PLUS会员",
|
||||||
|
sku_labels: ["标准版"],
|
||||||
|
like_count: "9",
|
||||||
|
reply: "感谢支持。",
|
||||||
|
append_content: "追评:一周后依然满意。",
|
||||||
|
picture_urls: [],
|
||||||
|
video_urls: [],
|
||||||
|
append_picture_urls: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
review_ref: "comment-2",
|
||||||
|
sample_bucket: null,
|
||||||
|
content: "第二条抓取评论,主要提到续航稳定。",
|
||||||
|
score: "4",
|
||||||
|
created_at: "2026-04-01",
|
||||||
|
author_label: "普通会员",
|
||||||
|
sku_labels: ["标准版"],
|
||||||
|
like_count: "2",
|
||||||
|
reply: null,
|
||||||
|
append_content: null,
|
||||||
|
picture_urls: [],
|
||||||
|
video_urls: [],
|
||||||
|
append_picture_urls: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
quality_flags: {
|
||||||
|
sample_insufficient: false,
|
||||||
|
partial_platform_failure: false,
|
||||||
|
blocked_platforms: [],
|
||||||
|
failed_platforms: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands all captured comments from the review sample metric", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
renderWithProviders(<App />, [`/tasks/${taskId}/report`]);
|
||||||
|
|
||||||
|
expect(await screen.findByText("评论样本已写入报告证据,可在站内查看。")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("第二条抓取评论,主要提到续航稳定。")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "查看全部抓取评论" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("第二条抓取评论,主要提到续航稳定。")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("共保留 2 条抓取评论")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("已抽样 1 条进入报告样本")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands per-link comments and sampled review details inside the evidence index", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
renderWithProviders(<App />, [`/tasks/${taskId}/report`]);
|
||||||
|
|
||||||
|
expect(await screen.findByText("评论样本已写入报告证据,可在站内查看。")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("第二条抓取评论,主要提到续航稳定。")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "查看该链接评论(2)" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("第二条抓取评论,主要提到续航稳定。")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("该链接共抓取 2 条评论")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("评论正文")).toHaveLength(2);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "查看样本评论" }));
|
||||||
|
|
||||||
|
expect(await screen.findAllByText("评论正文")).toHaveLength(3);
|
||||||
|
expect(screen.getAllByText("追评")).toHaveLength(2);
|
||||||
|
expect(screen.getAllByText("商家回复")).toHaveLength(2);
|
||||||
|
expect(screen.getAllByText("点赞 9")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -285,6 +285,39 @@ a {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.evidence-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-card p,
|
||||||
|
.evidence-review-detail p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-card__header,
|
||||||
|
.evidence-card__meta,
|
||||||
|
.evidence-card__actions,
|
||||||
|
.evidence-review-detail__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-card__header {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-review-detail {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(20, 108, 110, 0.16);
|
||||||
|
background: rgba(20, 108, 110, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
.mini-task-link__topline {
|
.mini-task-link__topline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
@ -476,6 +509,18 @@ a {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric-card--button {
|
||||||
|
width: 100%;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card--button:hover {
|
||||||
|
border-color: rgba(20, 108, 110, 0.24);
|
||||||
|
box-shadow: 0 12px 24px rgba(20, 108, 110, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.metric-card span {
|
.metric-card span {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@ -485,6 +530,17 @@ a {
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric-card small {
|
||||||
|
color: var(--brand-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-comment-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.history-card__actions,
|
.history-card__actions,
|
||||||
.sticky-actions {
|
.sticky-actions {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
@ -621,6 +677,39 @@ a {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qr-login-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-login-card__preview {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 248px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(20, 108, 110, 0.08) 0%, rgba(255, 255, 255, 0.88) 100%);
|
||||||
|
border: 1px solid rgba(20, 108, 110, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-login-card__preview img {
|
||||||
|
display: block;
|
||||||
|
width: min(100%, 240px);
|
||||||
|
max-height: 240px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-login-card__empty {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
.session-details {
|
.session-details {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@ -748,6 +837,10 @@ a {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qr-login-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,10 +40,59 @@ export const EvidenceSchema = z.object({
|
|||||||
source_type: z.enum(evidenceSourceTypes),
|
source_type: z.enum(evidenceSourceTypes),
|
||||||
source_url: z.string().url(),
|
source_url: z.string().url(),
|
||||||
review_ref: z.string().nullable(),
|
review_ref: z.string().nullable(),
|
||||||
|
review_detail: z
|
||||||
|
.object({
|
||||||
|
content: z.string().min(1),
|
||||||
|
score: z.string().nullable(),
|
||||||
|
created_at: z.string().nullable(),
|
||||||
|
author_label: z.string().nullable(),
|
||||||
|
sku_labels: z.array(z.string().min(1)).optional(),
|
||||||
|
like_count: z.string().nullable().optional(),
|
||||||
|
reply: z.string().nullable().optional(),
|
||||||
|
append_content: z.string().nullable().optional(),
|
||||||
|
picture_urls: z.array(z.string().min(1)).optional(),
|
||||||
|
video_urls: z.array(z.string().min(1)).optional(),
|
||||||
|
append_picture_urls: z.array(z.string().min(1)).optional()
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
snippet: z.string().min(1),
|
snippet: z.string().min(1),
|
||||||
captured_at: z.string().datetime()
|
captured_at: z.string().datetime()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ReviewDetailSchema = z.object({
|
||||||
|
content: z.string().min(1),
|
||||||
|
score: z.string().nullable(),
|
||||||
|
created_at: z.string().nullable(),
|
||||||
|
author_label: z.string().nullable(),
|
||||||
|
sku_labels: z.array(z.string().min(1)).optional(),
|
||||||
|
like_count: z.string().nullable().optional(),
|
||||||
|
reply: z.string().nullable().optional(),
|
||||||
|
append_content: z.string().nullable().optional(),
|
||||||
|
picture_urls: z.array(z.string().min(1)).optional(),
|
||||||
|
video_urls: z.array(z.string().min(1)).optional(),
|
||||||
|
append_picture_urls: z.array(z.string().min(1)).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const ReviewCollectionCommentSchema = ReviewDetailSchema.extend({
|
||||||
|
review_ref: z.string().min(1),
|
||||||
|
sample_bucket: z.enum(["latest", "hot", "negative"]).nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const ReviewCollectionSchema = z.object({
|
||||||
|
collection_id: z.string().min(1),
|
||||||
|
candidate_id: z.string().min(1),
|
||||||
|
product_evidence_id: z.string().min(1),
|
||||||
|
platform: z.enum(platforms),
|
||||||
|
source_url: z.string().url(),
|
||||||
|
title: z.string().min(1),
|
||||||
|
store_name: z.string().nullable(),
|
||||||
|
price_label: z.string().nullable(),
|
||||||
|
captured_at: z.string().datetime(),
|
||||||
|
review_count: z.number().int().nonnegative(),
|
||||||
|
sampled_review_refs: z.array(z.string().min(1)),
|
||||||
|
comments: z.array(ReviewCollectionCommentSchema)
|
||||||
|
});
|
||||||
|
|
||||||
export const PlatformInsightSchema = z.object({
|
export const PlatformInsightSchema = z.object({
|
||||||
platform: z.enum(platforms),
|
platform: z.enum(platforms),
|
||||||
execution_status: z.enum(executionStatuses),
|
execution_status: z.enum(executionStatuses),
|
||||||
@ -86,6 +135,7 @@ export const ReportSchema = z.object({
|
|||||||
cross_platform_insights: z.array(InsightCardSchema),
|
cross_platform_insights: z.array(InsightCardSchema),
|
||||||
recommendations: z.array(InsightCardSchema),
|
recommendations: z.array(InsightCardSchema),
|
||||||
evidence_index: z.array(EvidenceSchema),
|
evidence_index: z.array(EvidenceSchema),
|
||||||
|
review_collections: z.array(ReviewCollectionSchema).optional(),
|
||||||
quality_flags: z.object({
|
quality_flags: z.object({
|
||||||
sample_insufficient: z.boolean(),
|
sample_insufficient: z.boolean(),
|
||||||
partial_platform_failure: z.boolean(),
|
partial_platform_failure: z.boolean(),
|
||||||
|
|||||||
@ -101,6 +101,75 @@ describe("ReportSchema", () => {
|
|||||||
review_ref: null,
|
review_ref: null,
|
||||||
snippet: "详情页强调 5 倍长焦与钛金属材质。",
|
snippet: "详情页强调 5 倍长焦与钛金属材质。",
|
||||||
captured_at: "2026-04-02T11:58:00.000Z"
|
captured_at: "2026-04-02T11:58:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
evidence_id: "evidence-2",
|
||||||
|
platform: "tmall",
|
||||||
|
source_type: "review",
|
||||||
|
source_url: "https://example.com/tmall/iphone-15-pro",
|
||||||
|
review_ref: "comment-1",
|
||||||
|
review_detail: {
|
||||||
|
content: "拍照效果稳定,夜景比预期更干净。",
|
||||||
|
score: "5",
|
||||||
|
created_at: "2026-04-02",
|
||||||
|
author_label: "88VIP",
|
||||||
|
sku_labels: ["远峰蓝 / 256G"],
|
||||||
|
like_count: "12",
|
||||||
|
reply: "感谢支持",
|
||||||
|
append_content: "追评:一周后续航依然稳定。",
|
||||||
|
picture_urls: ["https://example.com/review/pic-1.jpg"],
|
||||||
|
video_urls: [],
|
||||||
|
append_picture_urls: []
|
||||||
|
},
|
||||||
|
snippet: "样本 latest | 评分 5 | 88VIP | 拍照效果稳定,夜景更干净。",
|
||||||
|
captured_at: "2026-04-02T11:59:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
review_collections: [
|
||||||
|
{
|
||||||
|
collection_id: "review-collection-candidate-1",
|
||||||
|
candidate_id: "candidate-1",
|
||||||
|
product_evidence_id: "evidence-1",
|
||||||
|
platform: "tmall",
|
||||||
|
source_url: "https://example.com/tmall/iphone-15-pro",
|
||||||
|
title: "iPhone 15 Pro",
|
||||||
|
store_name: "Apple 官方旗舰店",
|
||||||
|
price_label: "¥7999",
|
||||||
|
captured_at: "2026-04-02T11:59:00.000Z",
|
||||||
|
review_count: 2,
|
||||||
|
sampled_review_refs: ["comment-1"],
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
review_ref: "comment-1",
|
||||||
|
sample_bucket: "latest",
|
||||||
|
content: "拍照效果稳定,夜景比预期更干净。",
|
||||||
|
score: "5",
|
||||||
|
created_at: "2026-04-02",
|
||||||
|
author_label: "88VIP",
|
||||||
|
sku_labels: ["远峰蓝 / 256G"],
|
||||||
|
like_count: "12",
|
||||||
|
reply: "感谢支持",
|
||||||
|
append_content: "追评:一周后续航依然稳定。",
|
||||||
|
picture_urls: ["https://example.com/review/pic-1.jpg"],
|
||||||
|
video_urls: [],
|
||||||
|
append_picture_urls: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
review_ref: "comment-2",
|
||||||
|
sample_bucket: null,
|
||||||
|
content: "手感不错,续航也正常。",
|
||||||
|
score: "4",
|
||||||
|
created_at: "2026-04-01",
|
||||||
|
author_label: null,
|
||||||
|
sku_labels: [],
|
||||||
|
like_count: null,
|
||||||
|
reply: null,
|
||||||
|
append_content: null,
|
||||||
|
picture_urls: [],
|
||||||
|
video_urls: [],
|
||||||
|
append_picture_urls: []
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
quality_flags: {
|
quality_flags: {
|
||||||
@ -113,6 +182,7 @@ describe("ReportSchema", () => {
|
|||||||
|
|
||||||
expect(report.report_version).toBe(1);
|
expect(report.report_version).toBe(1);
|
||||||
expect(report.platform_insights).toHaveLength(2);
|
expect(report.platform_insights).toHaveLength(2);
|
||||||
|
expect(report.review_collections).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a strong insight without evidence ids", () => {
|
it("rejects a strong insight without evidence ids", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user