feat: 补充报告评论集合与评论详情证据

This commit is contained in:
renzhiye 2026-04-07 19:58:27 +08:00
parent 4ea9eaf4a6
commit 41a42bca25
8 changed files with 1712 additions and 457 deletions

View 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();
});
});

View File

@ -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();

View File

@ -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) {

View File

@ -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,27 +725,9 @@ 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>
<div className="panel-actions">
<a
className="text-link"
href={buildOpsSessionManagerHref(platform, `/tasks/${taskId}/confirm`)}
>
</a>
<button
className="ghost-button"
disabled={retryMutation.isPending && retryMutation.variables === platform}
onClick={() => retryMutation.mutate(platform)}
type="button"
>
</button>
</div>
</>
) : ( ) : (
<a <a
className="text-link" className="text-link"
@ -712,6 +737,24 @@ function ConfirmPage() {
</a> </a>
) )
) : null} ) : null}
{platformRun?.status === "SearchBlocked" && isOpsManagedPlatform(platform) ? (
<div className="panel-actions">
<a
className="text-link"
href={buildOpsSessionManagerHref(platform, `/tasks/${taskId}/confirm`)}
>
</a>
<button
className="ghost-button"
disabled={retryMutation.isPending && retryMutation.variables === platform}
onClick={() => retryMutation.mutate(platform)}
type="button"
>
</button>
</div>
) : null}
{platformRun?.status === "Failed" ? ( {platformRun?.status === "Failed" ? (
<div className="panel-actions"> <div className="panel-actions">
<button <button
@ -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 (
<button
aria-expanded={props.expanded}
aria-label={props.ariaLabel}
className={className}
onClick={props.onClick}
type="button"
>
<span>{props.label}</span>
<strong>{props.value}</strong>
{props.hint ? <small>{props.hint}</small> : null}
</button>
);
}
return ( return (
<div className="metric-card"> <div className={className}>
<span>{props.label}</span> <span>{props.label}</span>
<strong>{props.value}</strong> <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,20 +1262,97 @@ 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) => {
<div key={evidence.evidence_id} className="evidence-card"> const reviewDetail = evidence.review_detail;
<strong>{evidence.evidence_id}</strong> const linkedReviewCollection =
<p>{evidence.snippet}</p> evidence.source_type === "product"
<a ? reviewCollectionsByProductEvidenceId.get(evidence.evidence_id) ?? null
className="text-link" : null;
href={evidence.source_url} const isCollectionExpanded =
rel="noreferrer" expandedCollectionEvidenceId === evidence.evidence_id;
target="_blank" const isExpanded = expandedEvidenceId === evidence.evidence_id;
>
return (
</a> <div key={evidence.evidence_id} className="evidence-card">
</div> <div className="evidence-card__header">
))} <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>
<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
className="text-link"
href={evidence.source_url}
rel="noreferrer"
target="_blank"
>
</a>
</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>

View 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);
});
});

View File

@ -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;
} }

View File

@ -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(),

View File

@ -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", () => {