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({
|
||||
platform: "jd",
|
||||
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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
|
||||
@ -374,6 +374,73 @@ function getExecutionCommentUserLabel(comment: ExecutionReviewComment): string |
|
||||
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 {
|
||||
return {
|
||||
id: comment.id,
|
||||
@ -1721,7 +1788,7 @@ export class InMemoryTaskStore {
|
||||
try {
|
||||
await this.retryPlatform(taskId, platform);
|
||||
} catch {
|
||||
// retryPlatform already records recoverable failures on the task.
|
||||
// Keep the current task state; retryPlatform already records recoverable failures.
|
||||
} finally {
|
||||
this.pendingManagedSessionRetries.delete(retryKey);
|
||||
}
|
||||
@ -1787,6 +1854,7 @@ export class InMemoryTaskStore {
|
||||
const sourcePlatforms: PlatformId[] =
|
||||
insightPlatforms.length > 0 ? insightPlatforms : ["tmall"];
|
||||
const evidenceIndex: ReportSnapshot["evidence_index"] = [];
|
||||
const reviewCollections: NonNullable<ReportSnapshot["review_collections"]> = [];
|
||||
const evidenceIdsByPlatform = new Map<PlatformId, string[]>();
|
||||
let evidenceCounter = 0;
|
||||
|
||||
@ -1798,7 +1866,8 @@ export class InMemoryTaskStore {
|
||||
sourceType: "product" | "review",
|
||||
sourceUrl: string,
|
||||
snippet: string,
|
||||
reviewRef: string | null
|
||||
reviewRef: string | null,
|
||||
reviewDetail?: NonNullable<ReportSnapshot["evidence_index"][number]["review_detail"]>
|
||||
) => {
|
||||
const evidenceId = `evidence-${task.taskId}-${++evidenceCounter}`;
|
||||
evidenceIndex.push({
|
||||
@ -1807,6 +1876,7 @@ export class InMemoryTaskStore {
|
||||
source_type: sourceType,
|
||||
source_url: sourceUrl,
|
||||
review_ref: reviewRef,
|
||||
...(reviewDetail ? { review_detail: reviewDetail } : {}),
|
||||
snippet,
|
||||
captured_at: nowIso()
|
||||
});
|
||||
@ -1839,7 +1909,39 @@ export class InMemoryTaskStore {
|
||||
.filter(Boolean)
|
||||
.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)) {
|
||||
const commentSummary = [
|
||||
@ -1856,7 +1958,8 @@ export class InMemoryTaskStore {
|
||||
"review",
|
||||
candidate.productUrl,
|
||||
commentSummary,
|
||||
comment.id
|
||||
comment.id,
|
||||
toReportEvidenceReviewDetail(comment)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -2097,6 +2200,7 @@ export class InMemoryTaskStore {
|
||||
)
|
||||
],
|
||||
evidence_index: evidenceIndex,
|
||||
review_collections: reviewCollections,
|
||||
quality_flags: {
|
||||
sample_insufficient: sampleInsufficient,
|
||||
partial_platform_failure:
|
||||
@ -2785,6 +2889,73 @@ export class InMemoryTaskStore {
|
||||
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 {
|
||||
const selectedLinkCount = task.platformRuns.reduce(
|
||||
(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 {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task) {
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
type TaskRecord,
|
||||
type TaskStatus
|
||||
} from "@cross-ai/domain";
|
||||
import type { ReportSnapshot } from "@cross-ai/report-schema";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
@ -52,6 +53,12 @@ import {
|
||||
updateJdSessionManagerConfig
|
||||
} 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 }) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
@ -188,6 +195,42 @@ function formatTimestamp(timestamp?: string) {
|
||||
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(
|
||||
searchRequirement: SessionReadinessRecord["searchRequirement"]
|
||||
) {
|
||||
@ -595,9 +638,9 @@ function CandidateCard(props: {
|
||||
}
|
||||
|
||||
function ConfirmPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { taskId = "" } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
useTaskLiveSync(taskId, { includeCandidates: true });
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["task", taskId],
|
||||
@ -682,27 +725,9 @@ function ConfirmPage() {
|
||||
<p>{platformRun?.reason ?? "当前没有候选结果。"}</p>
|
||||
{platformRun?.status === "SearchBlocked" ? (
|
||||
isOpsManagedPlatform(platform) ? (
|
||||
<>
|
||||
<span className="inline-note inline-note--subtle">
|
||||
京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。
|
||||
</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>
|
||||
</>
|
||||
<span className="inline-note inline-note--subtle">
|
||||
京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
className="text-link"
|
||||
@ -712,6 +737,24 @@ function ConfirmPage() {
|
||||
</a>
|
||||
)
|
||||
) : 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" ? (
|
||||
<div className="panel-actions">
|
||||
<button
|
||||
@ -768,13 +811,14 @@ function ConfirmPage() {
|
||||
}
|
||||
|
||||
function RunPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { taskId = "" } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
useTaskLiveSync(taskId);
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["task", taskId],
|
||||
queryFn: () => getTask(taskId)
|
||||
});
|
||||
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: (platform: PlatformId) => retryTaskPlatform(taskId, platform),
|
||||
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 (
|
||||
<div className="metric-card">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -881,6 +1024,11 @@ function ReportPage() {
|
||||
const navigate = useNavigate();
|
||||
const { taskId = "" } = useParams();
|
||||
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 taskQuery = useQuery({
|
||||
queryKey: ["task", taskId],
|
||||
@ -890,6 +1038,13 @@ function ReportPage() {
|
||||
queryKey: ["report", taskId, version],
|
||||
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) {
|
||||
return (
|
||||
@ -905,6 +1060,14 @@ function ReportPage() {
|
||||
version && Number.isFinite(Number(version))
|
||||
? Number(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 (
|
||||
<Layout>
|
||||
@ -926,6 +1089,14 @@ function ReportPage() {
|
||||
<MetricCard
|
||||
label="评论样本"
|
||||
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}`} />
|
||||
</div>
|
||||
@ -982,6 +1153,81 @@ function ReportPage() {
|
||||
</article>
|
||||
</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">
|
||||
<article className="page-panel">
|
||||
<p className="eyebrow">Platform Insights</p>
|
||||
@ -1016,20 +1262,97 @@ function ReportPage() {
|
||||
<article className="page-panel">
|
||||
<p className="eyebrow">Evidence Index</p>
|
||||
<div className="stack stack--dense">
|
||||
{report.evidence_index.map((evidence) => (
|
||||
<div key={evidence.evidence_id} className="evidence-card">
|
||||
<strong>{evidence.evidence_id}</strong>
|
||||
<p>{evidence.snippet}</p>
|
||||
<a
|
||||
className="text-link"
|
||||
href={evidence.source_url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
查看来源
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
{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 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>
|
||||
</article>
|
||||
</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);
|
||||
});
|
||||
});
|
||||
@ -1,39 +1,39 @@
|
||||
:root {
|
||||
--bg-canvas: #f2eee6;
|
||||
--bg-surface: #fbf8f2;
|
||||
--bg-elevated: #ffffff;
|
||||
--text-primary: #1f2a30;
|
||||
--text-secondary: #5b6971;
|
||||
--line-subtle: #d7d0c4;
|
||||
--brand-primary: #146c6e;
|
||||
--brand-primary-soft: #d9efeb;
|
||||
--accent-amber: #8c5a16;
|
||||
--success: #2e7d5b;
|
||||
--warning: #9a4b24;
|
||||
--blocked: #9e3f22;
|
||||
--danger: #b63e2f;
|
||||
--info: #2f6d8a;
|
||||
color: var(--text-primary);
|
||||
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(20, 108, 110, 0.08), transparent 28%),
|
||||
linear-gradient(180deg, #f6f1e8 0%, var(--bg-canvas) 100%);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-canvas: #f2eee6;
|
||||
--bg-surface: #fbf8f2;
|
||||
--bg-elevated: #ffffff;
|
||||
--text-primary: #1f2a30;
|
||||
--text-secondary: #5b6971;
|
||||
--line-subtle: #d7d0c4;
|
||||
--brand-primary: #146c6e;
|
||||
--brand-primary-soft: #d9efeb;
|
||||
--accent-amber: #8c5a16;
|
||||
--success: #2e7d5b;
|
||||
--warning: #9a4b24;
|
||||
--blocked: #9e3f22;
|
||||
--danger: #b63e2f;
|
||||
--info: #2f6d8a;
|
||||
color: var(--text-primary);
|
||||
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(20, 108, 110, 0.08), transparent 28%),
|
||||
linear-gradient(180deg, #f6f1e8 0%, var(--bg-canvas) 100%);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
@ -56,85 +56,85 @@ a {
|
||||
background: rgba(251, 248, 242, 0.72);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.sidebar__title {
|
||||
margin: 8px 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sidebar__copy {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sidebar__nav {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar__nav a {
|
||||
padding: 12px 16px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.64);
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
|
||||
.sidebar__title {
|
||||
margin: 8px 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sidebar__copy {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sidebar__nav {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar__nav a {
|
||||
padding: 12px 16px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.64);
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
min-height: 100vh;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.page-panel {
|
||||
padding: 24px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
box-shadow: 0 18px 40px rgba(31, 42, 48, 0.05);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.hero-panel__copy h2 {
|
||||
margin: 6px 0 14px;
|
||||
font-size: 36px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--accent-amber);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.task-form,
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stack--dense {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
.page-panel {
|
||||
padding: 24px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
box-shadow: 0 18px 40px rgba(31, 42, 48, 0.05);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.hero-panel__copy h2 {
|
||||
margin: 6px 0 14px;
|
||||
font-size: 36px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--accent-amber);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.task-form,
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stack--dense {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field textarea,
|
||||
.field input,
|
||||
.field select {
|
||||
@ -151,39 +151,39 @@ a {
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-grid,
|
||||
.page-grid,
|
||||
.report-grid,
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field-grid,
|
||||
.page-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.report-grid {
|
||||
grid-template-columns: 1.4fr 0.8fr;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
|
||||
.field-grid,
|
||||
.page-grid,
|
||||
.report-grid,
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field-grid,
|
||||
.page-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.report-grid {
|
||||
grid-template-columns: 1.4fr 0.8fr;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 46px;
|
||||
padding: 0 18px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-primary);
|
||||
color: white;
|
||||
font: inherit;
|
||||
min-height: 46px;
|
||||
padding: 0 18px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-primary);
|
||||
color: white;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
@ -216,12 +216,12 @@ a {
|
||||
.primary-button--link {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
color: var(--brand-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
.text-link {
|
||||
color: var(--brand-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.readiness-card,
|
||||
.platform-run-panel,
|
||||
.history-card,
|
||||
@ -244,18 +244,18 @@ a {
|
||||
.mini-task-link {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
|
||||
.readiness-card__header,
|
||||
.platform-run-panel__header,
|
||||
.history-card__topline,
|
||||
.candidate-section__header,
|
||||
.report-hero__topline,
|
||||
.task-context-header,
|
||||
.sticky-actions,
|
||||
.history-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.task-context-header,
|
||||
.sticky-actions,
|
||||
.history-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@ -285,6 +285,39 @@ a {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
@ -296,169 +329,169 @@ a {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-context-header {
|
||||
padding: 20px 0 0;
|
||||
}
|
||||
|
||||
.task-context-header h1 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.task-context-header__meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-spine {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-spine__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.64);
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.task-spine__item--active {
|
||||
background: var(--brand-primary-soft);
|
||||
border-color: rgba(20, 108, 110, 0.28);
|
||||
}
|
||||
|
||||
.task-spine__index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-pill--neutral {
|
||||
background: rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.status-pill--info {
|
||||
background: rgba(47, 109, 138, 0.12);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.status-pill--progress {
|
||||
background: rgba(20, 108, 110, 0.12);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.status-pill--success {
|
||||
background: rgba(46, 125, 91, 0.12);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-pill--warning {
|
||||
background: rgba(154, 75, 36, 0.12);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-pill--blocked {
|
||||
background: rgba(158, 63, 34, 0.12);
|
||||
color: var(--blocked);
|
||||
}
|
||||
|
||||
.status-pill--danger {
|
||||
background: rgba(182, 62, 47, 0.12);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-pill--empty {
|
||||
background: rgba(140, 90, 22, 0.12);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.platform-identity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.platform-identity__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-primary-soft);
|
||||
color: var(--brand-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.candidate-card {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.candidate-card img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.candidate-card__content {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.candidate-card__topline {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.candidate-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-feed {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.event-row {
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
.task-context-header {
|
||||
padding: 20px 0 0;
|
||||
}
|
||||
|
||||
.task-context-header h1 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.task-context-header__meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-spine {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-spine__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.64);
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.task-spine__item--active {
|
||||
background: var(--brand-primary-soft);
|
||||
border-color: rgba(20, 108, 110, 0.28);
|
||||
}
|
||||
|
||||
.task-spine__index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-pill--neutral {
|
||||
background: rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.status-pill--info {
|
||||
background: rgba(47, 109, 138, 0.12);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.status-pill--progress {
|
||||
background: rgba(20, 108, 110, 0.12);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.status-pill--success {
|
||||
background: rgba(46, 125, 91, 0.12);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-pill--warning {
|
||||
background: rgba(154, 75, 36, 0.12);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-pill--blocked {
|
||||
background: rgba(158, 63, 34, 0.12);
|
||||
color: var(--blocked);
|
||||
}
|
||||
|
||||
.status-pill--danger {
|
||||
background: rgba(182, 62, 47, 0.12);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-pill--empty {
|
||||
background: rgba(140, 90, 22, 0.12);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.platform-identity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.platform-identity__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-primary-soft);
|
||||
color: var(--brand-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.candidate-card {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.candidate-card img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.candidate-card__content {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.candidate-card__topline {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.candidate-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-feed {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.event-row {
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.inline-note {
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
@ -470,12 +503,24 @@ a {
|
||||
background: rgba(31, 42, 48, 0.06);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
.metric-card {
|
||||
display: grid;
|
||||
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 {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
@ -485,6 +530,17 @@ a {
|
||||
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,
|
||||
.sticky-actions {
|
||||
margin-top: 16px;
|
||||
@ -583,32 +639,32 @@ a {
|
||||
.sticky-actions {
|
||||
position: sticky;
|
||||
bottom: 24px;
|
||||
padding: 16px 20px;
|
||||
border-radius: 20px;
|
||||
background: rgba(31, 42, 48, 0.92);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.session-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.session-placeholder {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 0.7fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
padding: 16px 20px;
|
||||
border-radius: 20px;
|
||||
background: rgba(31, 42, 48, 0.92);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.session-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.session-placeholder {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 0.7fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.session-placeholder__viewport,
|
||||
.session-placeholder__sidebar {
|
||||
min-height: 320px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px dashed rgba(31, 42, 48, 0.18);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
border-radius: 18px;
|
||||
border: 1px dashed rgba(31, 42, 48, 0.18);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.session-placeholder__viewport {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@ -621,6 +677,39 @@ a {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@ -725,15 +814,15 @@ a {
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(140, 90, 22, 0.08);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(140, 90, 22, 0.08);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.app-shell,
|
||||
.hero-panel,
|
||||
@ -748,6 +837,10 @@ a {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.qr-login-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@ -40,10 +40,59 @@ export const EvidenceSchema = z.object({
|
||||
source_type: z.enum(evidenceSourceTypes),
|
||||
source_url: z.string().url(),
|
||||
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),
|
||||
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({
|
||||
platform: z.enum(platforms),
|
||||
execution_status: z.enum(executionStatuses),
|
||||
@ -86,6 +135,7 @@ export const ReportSchema = z.object({
|
||||
cross_platform_insights: z.array(InsightCardSchema),
|
||||
recommendations: z.array(InsightCardSchema),
|
||||
evidence_index: z.array(EvidenceSchema),
|
||||
review_collections: z.array(ReviewCollectionSchema).optional(),
|
||||
quality_flags: z.object({
|
||||
sample_insufficient: z.boolean(),
|
||||
partial_platform_failure: z.boolean(),
|
||||
|
||||
@ -101,6 +101,75 @@ describe("ReportSchema", () => {
|
||||
review_ref: null,
|
||||
snippet: "详情页强调 5 倍长焦与钛金属材质。",
|
||||
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: {
|
||||
@ -113,6 +182,7 @@ describe("ReportSchema", () => {
|
||||
|
||||
expect(report.report_version).toBe(1);
|
||||
expect(report.platform_insights).toHaveLength(2);
|
||||
expect(report.review_collections).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects a strong insight without evidence ids", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user