From 41a42bca2507fd12fcb3a47cabd634aba89518a9 Mon Sep 17 00:00:00 2001 From: renzhiye Date: Tue, 7 Apr 2026 19:58:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=85=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E9=9B=86=E5=90=88=E4=B8=8E=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/src/report.review-collections.test.ts | 268 ++++++ apps/api/src/server.test.ts | 76 +- apps/api/src/store.ts | 246 ++++-- apps/web/src/App.tsx | 401 ++++++++- apps/web/src/ReportPage.test.tsx | 273 ++++++ apps/web/src/styles.css | 785 ++++++++++-------- packages/report-schema/src/index.ts | 50 ++ .../report-schema/test/report-schema.test.ts | 70 ++ 8 files changed, 1712 insertions(+), 457 deletions(-) create mode 100644 apps/api/src/report.review-collections.test.ts create mode 100644 apps/web/src/ReportPage.test.tsx diff --git a/apps/api/src/report.review-collections.test.ts b/apps/api/src/report.review-collections.test.ts new file mode 100644 index 0000000..ec66340 --- /dev/null +++ b/apps/api/src/report.review-collections.test.ts @@ -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, 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 { + 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(); + }); +}); diff --git a/apps/api/src/server.test.ts b/apps/api/src/server.test.ts index 5666999..22022d9 100644 --- a/apps/api/src/server.test.ts +++ b/apps/api/src/server.test.ts @@ -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(); diff --git a/apps/api/src/store.ts b/apps/api/src/store.ts index 824e0ec..5c28172 100644 --- a/apps/api/src/store.ts +++ b/apps/api/src/store.ts @@ -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[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 { + 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 = []; const evidenceIdsByPlatform = new Map(); 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 ) => { 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((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((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) { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fae04ec..eb9af61 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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; +type ReportReviewCollection = NonNullable[number]; +type ReportReviewCollectionComment = ReportReviewCollection["comments"][number]; +type ReviewDetailLike = EvidenceReviewDetail | ReportReviewCollectionComment; + function Layout(props: { children: React.ReactNode }) { return (
@@ -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() {

{platformRun?.reason ?? "当前没有候选结果。"}

{platformRun?.status === "SearchBlocked" ? ( isOpsManagedPlatform(platform) ? ( - <> - - 京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。 - -
- - 去运维恢复 - - -
- + + 京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。 + ) : ( ) ) : null} + {platformRun?.status === "SearchBlocked" && isOpsManagedPlatform(platform) ? ( +
+ + 去运维恢复 + + +
+ ) : null} {platformRun?.status === "Failed" ? (
+ ); + } + return ( -
+
{props.label} {props.value} + {props.hint ? {props.hint} : null} +
+ ); +} + +function ReviewDetailPanel(props: { + detail: ReviewDetailLike; + reviewRef?: string | null; + bucketLabel?: string | null; +}) { + const { detail, reviewRef, bucketLabel } = props; + const mediaSummary = getReviewMediaSummary(detail); + + return ( +
+
+ {bucketLabel ? {bucketLabel} : null} + {reviewRef ? #{reviewRef} : null} + {detail.author_label ? ( + {detail.author_label} + ) : null} + {formatReviewScore(detail.score)} + {detail.created_at ? ( + {detail.created_at} + ) : null} + {detail.sku_labels && detail.sku_labels.length > 0 ? ( + + {detail.sku_labels.join(" / ")} + + ) : null} + {detail.like_count ? ( + 点赞 {detail.like_count} + ) : null} + {mediaSummary ? ( + {mediaSummary} + ) : null} +
+
+ 评论正文 +

{detail.content}

+
+ {detail.append_content ? ( +
+ 追评 +

{detail.append_content}

+
+ ) : null} + {detail.reply ? ( +
+ 商家回复 +

{detail.reply}

+
+ ) : null} +
+ ); +} + +function ReviewCollectionComments(props: { collection: ReportReviewCollection }) { + const { collection } = props; + + if (collection.comments.length === 0) { + return

当前链接没有保留抓取评论。

; + } + + return ( +
+ {collection.comments.map((comment) => ( + + ))}
); } @@ -881,6 +1024,11 @@ function ReportPage() { const navigate = useNavigate(); const { taskId = "" } = useParams(); const [searchParams] = useSearchParams(); + const [expandedEvidenceId, setExpandedEvidenceId] = useState(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 ( @@ -926,6 +1089,14 @@ function ReportPage() { 0 + ? { + ariaLabel: showAllReviewCollections ? "收起全部抓取评论" : "查看全部抓取评论", + expanded: showAllReviewCollections, + hint: showAllReviewCollections ? "收起全部抓取评论" : "点击查看全部抓取评论", + onClick: () => setShowAllReviewCollections((current) => !current) + } + : {})} />
@@ -982,6 +1153,81 @@ function ReportPage() { + {reviewCollections.length > 0 ? ( +
+
+
+

Review Collections

+

按链接查看全部抓取评论

+
+
+ + 共保留 {totalCapturedReviewCount} 条抓取评论 + + +
+
+ {showAllReviewCollections ? ( +
+ {reviewCollections.map((collection) => ( +
+
+ {collection.title} + + 已抓取 {collection.review_count} 条评论 + +
+
+ + {platformCatalogMap[collection.platform].label} + + {collection.store_name ? ( + + 店铺 {collection.store_name} + + ) : null} + {collection.price_label ? ( + + 价格 {collection.price_label} + + ) : null} + + {formatTimestamp(collection.captured_at)} + +
+
+ {collection.sampled_review_refs.length > 0 ? ( + + 已抽样 {collection.sampled_review_refs.length} 条进入报告样本 + + ) : null} + + 查看来源 + +
+ +
+ ))} +
+ ) : ( +

+ 点击上方“评论样本”卡或右侧按钮,可展开全部抓取评论。 +

+ )} +
+ ) : null} +

Platform Insights

@@ -1016,20 +1262,97 @@ function ReportPage() {

Evidence Index

- {report.evidence_index.map((evidence) => ( -
- {evidence.evidence_id} -

{evidence.snippet}

- - 查看来源 - -
- ))} + {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 ( +
+
+ {evidence.evidence_id} + + {formatTimestamp(evidence.captured_at)} + +
+
+ + {platformCatalogMap[evidence.platform].label} + + + {getEvidenceSourceLabel(evidence.source_type)} + + {evidence.review_ref ? ( + #{evidence.review_ref} + ) : null} +
+

{evidence.snippet}

+
+ {linkedReviewCollection ? ( + + ) : null} + {reviewDetail ? ( + + ) : evidence.review_ref ? ( + 旧版报告未保留评论正文 + ) : null} + + 查看来源 + +
+ {isCollectionExpanded && linkedReviewCollection ? ( +
+
+ + 该链接共抓取 {linkedReviewCollection.review_count} 条评论 + + {linkedReviewCollection.sampled_review_refs.length > 0 ? ( + + 已抽样 {linkedReviewCollection.sampled_review_refs.length} 条进入报告样本 + + ) : null} +
+ +
+ ) : null} + {isExpanded && reviewDetail ? ( + + ) : null} +
+ ); + })}
diff --git a/apps/web/src/ReportPage.test.tsx b/apps/web/src/ReportPage.test.tsx new file mode 100644 index 0000000..78d1db7 --- /dev/null +++ b/apps/web/src/ReportPage.test.tsx @@ -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( + + {node} + + ); +} + +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(, [`/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(, [`/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); + }); +}); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 70c67f8..e460bfd 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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; } diff --git a/packages/report-schema/src/index.ts b/packages/report-schema/src/index.ts index 3071c77..6c50cb1 100644 --- a/packages/report-schema/src/index.ts +++ b/packages/report-schema/src/index.ts @@ -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(), diff --git a/packages/report-schema/test/report-schema.test.ts b/packages/report-schema/test/report-schema.test.ts index 80eaad7..2b326cb 100644 --- a/packages/report-schema/test/report-schema.test.ts +++ b/packages/report-schema/test/report-schema.test.ts @@ -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", () => {