feat: 搭建阶段 0 与阶段 1 基础工程
This commit is contained in:
parent
02ec7c3a03
commit
a5b079a673
2
.gitignore
vendored
2
.gitignore
vendored
@ -171,4 +171,6 @@ cython_debug/
|
||||
# Local tooling artifacts
|
||||
.playwright-mcp/
|
||||
.codex-config.staged.toml
|
||||
.turbo/
|
||||
node_modules/
|
||||
|
||||
|
||||
19
apps/api/package.json
Normal file
19
apps/api/package.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@cross-ai/api",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsup src/index.ts --format esm --target node22 --out-dir dist --clean",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cross-ai/domain": "file:../../packages/domain",
|
||||
"@cross-ai/report-schema": "file:../../packages/report-schema",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"fastify": "^5.8.4"
|
||||
}
|
||||
}
|
||||
16
apps/api/src/index.ts
Normal file
16
apps/api/src/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { createServer } from "./server";
|
||||
|
||||
const port = Number(process.env.PORT ?? 3001);
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
|
||||
const server = createServer();
|
||||
|
||||
server
|
||||
.listen({ port, host })
|
||||
.then(() => {
|
||||
console.log(`API server listening on http://${host}:${port}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
42
apps/api/src/mock-data.ts
Normal file
42
apps/api/src/mock-data.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import type { CandidateRecord, PlatformId } from "@cross-ai/domain";
|
||||
|
||||
function slugify(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function createMockCandidates(
|
||||
query: string,
|
||||
platform: PlatformId
|
||||
): CandidateRecord[] {
|
||||
if (query.includes("无结果") || query.toLowerCase().includes("none")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const platformName = platform === "tmall" ? "tmall" : "jd";
|
||||
const storeName = platform === "tmall" ? "官方旗舰店" : "京东自营";
|
||||
const basePrice = platform === "tmall" ? 7999 : 8099;
|
||||
const slug = slugify(query);
|
||||
|
||||
return Array.from({ length: 3 }, (_, index) => ({
|
||||
candidateId: `${platform}-${slug}-${index + 1}`,
|
||||
platform,
|
||||
title: `${query} ${platform === "tmall" ? "旗舰版" : "自营精选"} ${index + 1}`,
|
||||
price: basePrice + index * 120,
|
||||
priceLabel: `¥${basePrice + index * 120}`,
|
||||
storeName,
|
||||
productUrl: `https://example.com/${platformName}/${slug}-${index + 1}`,
|
||||
imageUrl: `https://placehold.co/640x480?text=${platformName.toUpperCase()}+${index + 1}`,
|
||||
salesHint:
|
||||
platform === "tmall"
|
||||
? `近 30 天 ${1200 + index * 180} 人付款`
|
||||
: `近 30 天 ${900 + index * 160} 人下单`,
|
||||
specLabel: ["标准版", "大容量", "礼盒套装"][index] ?? "标准版",
|
||||
highlights: [
|
||||
platform === "tmall" ? "强调详情页卖点叙事" : "强调自营与履约体验",
|
||||
"支持价格与评论样本模拟"
|
||||
]
|
||||
}));
|
||||
}
|
||||
149
apps/api/src/server.test.ts
Normal file
149
apps/api/src/server.test.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createServer } from "./server";
|
||||
|
||||
describe("API server", () => {
|
||||
it("returns platform readiness with jd blocked by default", async () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/platforms/readiness"
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const payload = response.json();
|
||||
expect(payload.platforms).toHaveLength(2);
|
||||
expect(
|
||||
payload.platforms.find(
|
||||
(platform: { platform: string }) => platform.platform === "jd"
|
||||
)
|
||||
).toMatchObject({
|
||||
platform: "jd",
|
||||
ready: false,
|
||||
searchRequirement: "required"
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("creates a task and lands in AwaitingConfirmation with mock candidates", async () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tasks",
|
||||
payload: {
|
||||
query: "iPhone 15 Pro",
|
||||
perLinkLimit: 100,
|
||||
taskTotalLimit: 500
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const payload = response.json();
|
||||
expect(payload.task.taskStatus).toBe("AwaitingConfirmation");
|
||||
expect(payload.task.platformRuns).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
platform: "tmall",
|
||||
status: "AwaitingSelection"
|
||||
}),
|
||||
expect.objectContaining({
|
||||
platform: "jd",
|
||||
status: "SearchBlocked"
|
||||
})
|
||||
])
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("supports NoSelection terminal state when user confirms nothing", async () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tasks",
|
||||
payload: {
|
||||
query: "DJI Pocket 3",
|
||||
perLinkLimit: 100,
|
||||
taskTotalLimit: 500
|
||||
}
|
||||
});
|
||||
const taskId = createResponse.json().task.taskId;
|
||||
|
||||
const confirmResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/tasks/${taskId}/confirm`,
|
||||
payload: {
|
||||
selections: []
|
||||
}
|
||||
});
|
||||
|
||||
expect(confirmResponse.statusCode).toBe(200);
|
||||
expect(confirmResponse.json().task.taskStatus).toBe("NoSelection");
|
||||
|
||||
const reportResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tasks/${taskId}/report`
|
||||
});
|
||||
expect(reportResponse.statusCode).toBe(404);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("generates a completed report after confirming at least one candidate", async () => {
|
||||
const app = createServer();
|
||||
await app.ready();
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tasks",
|
||||
payload: {
|
||||
query: "Nintendo Switch 2",
|
||||
perLinkLimit: 80,
|
||||
taskTotalLimit: 320
|
||||
}
|
||||
});
|
||||
const taskId = createResponse.json().task.taskId;
|
||||
|
||||
const candidatesResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tasks/${taskId}/candidates`
|
||||
});
|
||||
const firstCandidateId =
|
||||
candidatesResponse.json().candidates.tmall[0].candidateId;
|
||||
|
||||
const confirmResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/tasks/${taskId}/confirm`,
|
||||
payload: {
|
||||
selections: [
|
||||
{
|
||||
platform: "tmall",
|
||||
candidateIds: [firstCandidateId]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
expect(confirmResponse.statusCode).toBe(200);
|
||||
expect(confirmResponse.json().task.taskStatus).toBe("Completed");
|
||||
expect(confirmResponse.json().task.defaultReportVersion).toBe(1);
|
||||
|
||||
const reportResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tasks/${taskId}/report`
|
||||
});
|
||||
|
||||
expect(reportResponse.statusCode).toBe(200);
|
||||
expect(reportResponse.json().report.task_status).toBe("Completed");
|
||||
expect(reportResponse.json().report.platform_insights).toHaveLength(2);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
119
apps/api/src/server.ts
Normal file
119
apps/api/src/server.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import type {
|
||||
ConfirmTaskPayload,
|
||||
CreateTaskInput,
|
||||
PlatformId
|
||||
} from "@cross-ai/domain";
|
||||
import cors from "@fastify/cors";
|
||||
import Fastify from "fastify";
|
||||
|
||||
import { InMemoryTaskStore } from "./store";
|
||||
|
||||
export function createServer() {
|
||||
const app = Fastify({ logger: false });
|
||||
const store = new InMemoryTaskStore();
|
||||
|
||||
app.register(cors, { origin: true });
|
||||
|
||||
app.get("/api/health", async () => ({
|
||||
status: "ok",
|
||||
service: "cross-platform-products-ai-analyst-api"
|
||||
}));
|
||||
|
||||
app.get("/api/platforms/readiness", async () => ({
|
||||
platforms: store.getPlatformReadiness()
|
||||
}));
|
||||
|
||||
app.post<{
|
||||
Params: { platform: PlatformId };
|
||||
}>("/api/platforms/:platform/prepare", async (request, reply) => {
|
||||
const readiness = store.preparePlatform(request.params.platform);
|
||||
reply.code(200);
|
||||
return {
|
||||
platform: readiness.platform,
|
||||
session_ready: readiness.ready,
|
||||
last_prepared_at: readiness.lastPreparedAt
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Body: CreateTaskInput;
|
||||
}>("/api/tasks", async (request, reply) => {
|
||||
const task = store.createTask(request.body);
|
||||
reply.code(201);
|
||||
return { task };
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Params: { taskId: string };
|
||||
}>("/api/tasks/:taskId", async (request, reply) => {
|
||||
const task = store.getTask(request.params.taskId);
|
||||
if (!task) {
|
||||
reply.code(404);
|
||||
return { message: "Task not found." };
|
||||
}
|
||||
return { task };
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Params: { taskId: string };
|
||||
}>("/api/tasks/:taskId/candidates", async (request, reply) => {
|
||||
const candidates = store.getCandidates(request.params.taskId);
|
||||
if (!candidates) {
|
||||
reply.code(404);
|
||||
return { message: "Task not found." };
|
||||
}
|
||||
return { candidates };
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Params: { taskId: string };
|
||||
Body: ConfirmTaskPayload;
|
||||
}>("/api/tasks/:taskId/confirm", async (request, reply) => {
|
||||
try {
|
||||
const task = store.confirmTask(request.params.taskId, request.body);
|
||||
return { task };
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return { message: "Task not found." };
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{
|
||||
Params: { taskId: string };
|
||||
Querystring: { version?: string };
|
||||
}>("/api/tasks/:taskId/report", async (request, reply) => {
|
||||
const version = request.query.version ? Number(request.query.version) : undefined;
|
||||
const report = store.getReport(request.params.taskId, version);
|
||||
if (!report) {
|
||||
reply.code(404);
|
||||
return { message: "Report not found." };
|
||||
}
|
||||
return { report };
|
||||
});
|
||||
|
||||
app.get("/api/history", async () => ({
|
||||
tasks: store.listHistory()
|
||||
}));
|
||||
|
||||
app.get<{
|
||||
Params: { taskId: string };
|
||||
}>("/api/tasks/:taskId/events", async (request, reply) => {
|
||||
const task = store.getTask(request.params.taskId);
|
||||
if (!task) {
|
||||
reply.code(404);
|
||||
return { message: "Task not found." };
|
||||
}
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive"
|
||||
});
|
||||
reply.raw.write(`event: task.snapshot\n`);
|
||||
reply.raw.write(`data: ${JSON.stringify(task)}\n\n`);
|
||||
reply.raw.end();
|
||||
return reply;
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
459
apps/api/src/store.ts
Normal file
459
apps/api/src/store.ts
Normal file
@ -0,0 +1,459 @@
|
||||
import {
|
||||
deriveTaskStatusFromConfirmedPlatforms,
|
||||
mapPlatformStatusToExecutionStatus,
|
||||
platformCatalog,
|
||||
platformCatalogMap,
|
||||
platforms,
|
||||
type CandidateRecord,
|
||||
type ConfirmTaskPayload,
|
||||
type CreateTaskInput,
|
||||
type HistoryTaskRecord,
|
||||
type PlatformId,
|
||||
type PlatformRunRecord,
|
||||
type SessionReadinessRecord,
|
||||
type TaskEventRecord,
|
||||
type TaskRecord
|
||||
} from "@cross-ai/domain";
|
||||
import { type ReportSnapshot, parseReportSnapshot } from "@cross-ai/report-schema";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { createMockCandidates } from "./mock-data";
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function createPlatformCandidatesRecord(): Record<PlatformId, CandidateRecord[]> {
|
||||
return {
|
||||
tmall: [],
|
||||
jd: []
|
||||
};
|
||||
}
|
||||
|
||||
export class InMemoryTaskStore {
|
||||
private readonly tasks = new Map<string, TaskRecord>();
|
||||
private readonly reports = new Map<string, ReportSnapshot[]>();
|
||||
private readonly readiness = new Map<PlatformId, SessionReadinessRecord>();
|
||||
|
||||
constructor() {
|
||||
this.readiness.set("tmall", {
|
||||
platform: "tmall",
|
||||
ready: true,
|
||||
searchRequirement: "recommended",
|
||||
lastPreparedAt: nowIso()
|
||||
});
|
||||
this.readiness.set("jd", {
|
||||
platform: "jd",
|
||||
ready: false,
|
||||
searchRequirement: "required"
|
||||
});
|
||||
}
|
||||
|
||||
getPlatformReadiness(): SessionReadinessRecord[] {
|
||||
return platforms.map((platform) => this.requireReadiness(platform));
|
||||
}
|
||||
|
||||
preparePlatform(platform: PlatformId): SessionReadinessRecord {
|
||||
const readiness = this.requireReadiness(platform);
|
||||
const next: SessionReadinessRecord = {
|
||||
...readiness,
|
||||
ready: true,
|
||||
lastPreparedAt: nowIso()
|
||||
};
|
||||
this.readiness.set(platform, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
createTask(input: CreateTaskInput): TaskRecord {
|
||||
const timestamp = nowIso();
|
||||
const taskId = randomUUID();
|
||||
const task: TaskRecord = {
|
||||
taskId,
|
||||
query: input.query.trim(),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
perLinkLimit: input.perLinkLimit,
|
||||
taskTotalLimit: input.taskTotalLimit,
|
||||
taskStatus: "Draft",
|
||||
taskStage: "precheck",
|
||||
platformRuns: platformCatalog.map(
|
||||
(platform): PlatformRunRecord => ({
|
||||
platform: platform.id,
|
||||
searchRequirement: platform.searchRequirement,
|
||||
status: "Pending",
|
||||
candidateCount: 0,
|
||||
selectedCandidateIds: [],
|
||||
lastUpdatedAt: timestamp
|
||||
})
|
||||
),
|
||||
platformCandidates: createPlatformCandidatesRecord(),
|
||||
events: [],
|
||||
reportVersions: []
|
||||
};
|
||||
|
||||
this.pushEvent(task, "task.created", "任务已创建,准备进入平台预检查。");
|
||||
task.taskStatus = "Searching";
|
||||
task.taskStage = "precheck";
|
||||
this.pushEvent(task, "task.searching", "系统已开始执行平台预检查。");
|
||||
|
||||
this.runSearch(task);
|
||||
this.tasks.set(task.taskId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
listHistory(): HistoryTaskRecord[] {
|
||||
return Array.from(this.tasks.values())
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
||||
.map((task) => ({
|
||||
taskId: task.taskId,
|
||||
query: task.query,
|
||||
taskStatus: task.taskStatus,
|
||||
updatedAt: task.updatedAt,
|
||||
hasReport: (this.reports.get(task.taskId)?.length ?? 0) > 0,
|
||||
defaultReportVersion: task.defaultReportVersion,
|
||||
failedPlatforms: task.platformRuns
|
||||
.filter((run) => run.status === "Failed")
|
||||
.map((run) => run.platform),
|
||||
blockedPlatforms: task.platformRuns
|
||||
.filter(
|
||||
(run) => run.status === "Blocked" || run.status === "SearchBlocked"
|
||||
)
|
||||
.map((run) => run.platform)
|
||||
}));
|
||||
}
|
||||
|
||||
getTask(taskId: string): TaskRecord | undefined {
|
||||
return this.tasks.get(taskId);
|
||||
}
|
||||
|
||||
getCandidates(taskId: string): Record<PlatformId, CandidateRecord[]> | undefined {
|
||||
return this.tasks.get(taskId)?.platformCandidates;
|
||||
}
|
||||
|
||||
confirmTask(taskId: string, payload: ConfirmTaskPayload): TaskRecord {
|
||||
const task = this.requireTask(taskId);
|
||||
const selectionMap = new Map(
|
||||
payload.selections.map((selection) => [selection.platform, selection.candidateIds])
|
||||
);
|
||||
|
||||
for (const run of task.platformRuns) {
|
||||
const selectedCandidateIds = selectionMap.get(run.platform) ?? [];
|
||||
|
||||
if (selectedCandidateIds.length > 0) {
|
||||
run.status = "Selected";
|
||||
run.selectedCandidateIds = selectedCandidateIds;
|
||||
run.lastUpdatedAt = nowIso();
|
||||
} else if (run.status === "AwaitingSelection") {
|
||||
run.status = "Skipped";
|
||||
run.selectedCandidateIds = [];
|
||||
run.lastUpdatedAt = nowIso();
|
||||
}
|
||||
}
|
||||
|
||||
task.taskStage = "confirmation";
|
||||
task.updatedAt = nowIso();
|
||||
this.pushEvent(task, "task.confirmed", "候选确认已提交。");
|
||||
|
||||
const selectedRuns = task.platformRuns.filter(
|
||||
(run) => run.selectedCandidateIds.length > 0
|
||||
);
|
||||
|
||||
if (selectedRuns.length === 0) {
|
||||
task.taskStatus = "NoSelection";
|
||||
this.pushEvent(task, "task.no_selection", "用户未确认任何商品链接,任务结束。");
|
||||
return task;
|
||||
}
|
||||
|
||||
task.taskStatus = "Running";
|
||||
task.taskStage = "session_check";
|
||||
this.pushEvent(task, "task.running", "系统开始执行抓取前校验。");
|
||||
|
||||
task.taskStage = "crawl";
|
||||
for (const run of selectedRuns) {
|
||||
run.status = "Running";
|
||||
run.lastUpdatedAt = nowIso();
|
||||
this.pushEvent(
|
||||
task,
|
||||
`platform.${run.platform}.running`,
|
||||
`${platformCatalogMap[run.platform].label} 开始抓取商品与评论。`
|
||||
);
|
||||
run.status = "Completed";
|
||||
run.lastUpdatedAt = nowIso();
|
||||
}
|
||||
|
||||
task.taskStage = "normalize";
|
||||
this.pushEvent(task, "task.normalize", "系统正在标准化商品与评论数据。");
|
||||
|
||||
task.taskStage = "analyze";
|
||||
this.pushEvent(task, "task.analyze", "系统正在生成结构化报告。");
|
||||
|
||||
task.taskStage = "publish";
|
||||
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
|
||||
task.updatedAt = nowIso();
|
||||
const report = this.buildReport(task);
|
||||
const previousReports = this.reports.get(task.taskId) ?? [];
|
||||
this.reports.set(task.taskId, [...previousReports, report]);
|
||||
task.reportVersions = [...task.reportVersions, report.report_version];
|
||||
task.defaultReportVersion = report.report_version;
|
||||
task.latestSuccessfulReportVersion = report.report_version;
|
||||
this.pushEvent(task, "task.report_published", `报告 v${report.report_version} 已生成。`);
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
getReport(taskId: string, version?: number): ReportSnapshot | undefined {
|
||||
const reports = this.reports.get(taskId);
|
||||
if (!reports || reports.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof version === "number") {
|
||||
return reports.find((report) => report.report_version === version);
|
||||
}
|
||||
|
||||
return reports[reports.length - 1];
|
||||
}
|
||||
|
||||
private runSearch(task: TaskRecord): void {
|
||||
task.taskStage = "search";
|
||||
|
||||
for (const run of task.platformRuns) {
|
||||
const readiness = this.requireReadiness(run.platform);
|
||||
if (readiness.searchRequirement === "required" && !readiness.ready) {
|
||||
run.status = "SearchBlocked";
|
||||
run.reason = platformCatalogMap[run.platform].recoveryHint;
|
||||
run.candidateCount = 0;
|
||||
run.lastUpdatedAt = nowIso();
|
||||
this.pushEvent(
|
||||
task,
|
||||
`platform.${run.platform}.search_blocked`,
|
||||
`${platformCatalogMap[run.platform].label} 缺少有效会话,搜索被阻塞。`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
run.status = "Searching";
|
||||
run.lastUpdatedAt = nowIso();
|
||||
const candidates = createMockCandidates(task.query, run.platform);
|
||||
task.platformCandidates[run.platform] = candidates;
|
||||
run.candidateCount = candidates.length;
|
||||
run.status = candidates.length > 0 ? "AwaitingSelection" : "NoResult";
|
||||
run.reason =
|
||||
candidates.length > 0
|
||||
? undefined
|
||||
: `${platformCatalogMap[run.platform].label} 当前未找到可供确认的候选。`;
|
||||
run.lastUpdatedAt = nowIso();
|
||||
this.pushEvent(
|
||||
task,
|
||||
`platform.${run.platform}.searched`,
|
||||
candidates.length > 0
|
||||
? `${platformCatalogMap[run.platform].label} 返回 ${candidates.length} 个候选。`
|
||||
: `${platformCatalogMap[run.platform].label} 未返回候选结果。`
|
||||
);
|
||||
}
|
||||
|
||||
task.taskStage = "confirmation";
|
||||
task.taskStatus = "AwaitingConfirmation";
|
||||
task.updatedAt = nowIso();
|
||||
this.pushEvent(task, "task.awaiting_confirmation", "候选召回完成,等待人工确认。");
|
||||
}
|
||||
|
||||
private buildReport(task: TaskRecord): ReportSnapshot {
|
||||
const reportVersion = (this.reports.get(task.taskId)?.length ?? 0) + 1;
|
||||
const completedRuns = task.platformRuns.filter((run) => run.status === "Completed");
|
||||
const blockedPlatforms = task.platformRuns
|
||||
.filter((run) => run.status === "Blocked" || run.status === "SearchBlocked")
|
||||
.map((run) => run.platform);
|
||||
const failedPlatforms = task.platformRuns
|
||||
.filter((run) => run.status === "Failed")
|
||||
.map((run) => run.platform);
|
||||
const selectedCandidates = task.platformRuns.flatMap((run) =>
|
||||
task.platformCandidates[run.platform].filter((candidate) =>
|
||||
run.selectedCandidateIds.includes(candidate.candidateId)
|
||||
)
|
||||
);
|
||||
const reviewSampleCount = Math.min(
|
||||
selectedCandidates.length * task.perLinkLimit,
|
||||
task.taskTotalLimit
|
||||
);
|
||||
const evidenceIndex = selectedCandidates.map((candidate, index) => ({
|
||||
evidence_id: `evidence-${task.taskId}-${index + 1}`,
|
||||
platform: candidate.platform,
|
||||
source_type: "product" as const,
|
||||
source_url: candidate.productUrl,
|
||||
review_ref: null,
|
||||
snippet: `${candidate.title} 在 ${candidate.storeName} 下展示 ${candidate.specLabel},当前价格 ${candidate.priceLabel}。`,
|
||||
captured_at: nowIso()
|
||||
}));
|
||||
|
||||
const sharedInsight = (title: string, statement: string, evidenceIds: string[]) => ({
|
||||
card_id: randomUUID(),
|
||||
title,
|
||||
statement,
|
||||
confidence: evidenceIds.length > 0 ? ("high" as const) : ("medium" as const),
|
||||
sample_flag:
|
||||
evidenceIds.length > 0 ? ("sufficient" as const) : ("insufficient" as const),
|
||||
source_scope: {
|
||||
platforms:
|
||||
completedRuns.length > 0
|
||||
? completedRuns.map((run) => run.platform)
|
||||
: (["tmall"] as const),
|
||||
link_count: selectedCandidates.length,
|
||||
review_count: reviewSampleCount
|
||||
},
|
||||
evidence_ids: evidenceIds
|
||||
});
|
||||
|
||||
return parseReportSnapshot({
|
||||
report_id: `report-${task.taskId}-${reportVersion}`,
|
||||
report_version: reportVersion,
|
||||
task_id: task.taskId,
|
||||
generated_at: nowIso(),
|
||||
task_status: task.taskStatus,
|
||||
summary: {
|
||||
headline:
|
||||
completedRuns.length > 1
|
||||
? "双平台已形成可回看报告。"
|
||||
: "已基于当前可用平台生成首版结构化报告。",
|
||||
key_points: [
|
||||
`已确认 ${selectedCandidates.length} 个商品链接,纳入 ${reviewSampleCount} 条评论样本。`,
|
||||
blockedPlatforms.length > 0
|
||||
? `仍有 ${blockedPlatforms.length} 个平台需要先恢复会话。`
|
||||
: "当前已确认平台全部处理完成。"
|
||||
],
|
||||
limitations: [
|
||||
blockedPlatforms.length > 0
|
||||
? "被阻塞平台未进入可发布洞察范围。"
|
||||
: "当前为第一批开发样板数据,后续将替换为真实采集链路。"
|
||||
]
|
||||
},
|
||||
product_snapshot: {
|
||||
query: task.query,
|
||||
normalized_product_name: task.query,
|
||||
platform_count: task.platformRuns.length,
|
||||
selected_link_count: selectedCandidates.length,
|
||||
review_sample_count: reviewSampleCount,
|
||||
analysis_time_range: {
|
||||
start: task.createdAt,
|
||||
end: nowIso()
|
||||
}
|
||||
},
|
||||
platform_insights: task.platformRuns.map((run) => {
|
||||
const platformEvidenceIds = evidenceIndex
|
||||
.filter((evidence) => evidence.platform === run.platform)
|
||||
.map((evidence) => evidence.evidence_id);
|
||||
const selectedForPlatform = selectedCandidates.filter(
|
||||
(candidate) => candidate.platform === run.platform
|
||||
);
|
||||
const prices = selectedForPlatform.map((candidate) => candidate.price);
|
||||
return {
|
||||
platform: run.platform,
|
||||
execution_status: mapPlatformStatusToExecutionStatus(run.status),
|
||||
selected_link_count: run.selectedCandidateIds.length,
|
||||
price_range:
|
||||
prices.length > 0
|
||||
? {
|
||||
min: Math.min(...prices),
|
||||
max: Math.max(...prices)
|
||||
}
|
||||
: null,
|
||||
selling_points:
|
||||
selectedForPlatform.length > 0
|
||||
? [
|
||||
sharedInsight(
|
||||
`${platformCatalogMap[run.platform].label} 卖点稳定`,
|
||||
`${platformCatalogMap[run.platform].label} 当前候选集中强调 ${selectedForPlatform[0]?.highlights[0] ?? "基础卖点"}。`,
|
||||
platformEvidenceIds
|
||||
)
|
||||
]
|
||||
: [],
|
||||
positive_themes:
|
||||
selectedForPlatform.length > 0
|
||||
? [
|
||||
sharedInsight(
|
||||
`${platformCatalogMap[run.platform].label} 正向反馈集中`,
|
||||
"模拟样本显示用户更关注核心功能与履约确定性。",
|
||||
platformEvidenceIds
|
||||
)
|
||||
]
|
||||
: [],
|
||||
negative_themes:
|
||||
run.status === "SearchBlocked"
|
||||
? [
|
||||
sharedInsight(
|
||||
`${platformCatalogMap[run.platform].label} 当前未进入采集`,
|
||||
run.reason ?? "当前平台需要先恢复会话。",
|
||||
[]
|
||||
)
|
||||
]
|
||||
: [],
|
||||
store_diff_notes:
|
||||
selectedForPlatform.length > 1
|
||||
? [
|
||||
sharedInsight(
|
||||
`${platformCatalogMap[run.platform].label} 候选存在规格差异`,
|
||||
"同平台已确认多个链接,报告保留差异而不强行合并。",
|
||||
platformEvidenceIds
|
||||
)
|
||||
]
|
||||
: []
|
||||
};
|
||||
}),
|
||||
cross_platform_insights: [
|
||||
sharedInsight(
|
||||
"当前报告以平台级摘要为主",
|
||||
completedRuns.length > 1
|
||||
? "系统保留链接级差异,同时默认从平台级组织结论。"
|
||||
: "当前仅部分平台完成执行,跨平台对比视角仍然保留但覆盖有限。",
|
||||
evidenceIndex.map((evidence) => evidence.evidence_id)
|
||||
)
|
||||
],
|
||||
recommendations: [
|
||||
sharedInsight(
|
||||
"继续补齐受阻平台会话",
|
||||
blockedPlatforms.length > 0
|
||||
? `建议优先处理 ${blockedPlatforms
|
||||
.map((platform) => platformCatalogMap[platform].label)
|
||||
.join("、")} 的会话准备,再发起下一轮任务。`
|
||||
: "建议基于当前样板链路继续扩展到真实采集策略。",
|
||||
evidenceIndex.map((evidence) => evidence.evidence_id)
|
||||
)
|
||||
],
|
||||
evidence_index: evidenceIndex,
|
||||
quality_flags: {
|
||||
sample_insufficient: reviewSampleCount < 30,
|
||||
partial_platform_failure:
|
||||
blockedPlatforms.length > 0 || failedPlatforms.length > 0,
|
||||
blocked_platforms: blockedPlatforms,
|
||||
failed_platforms: failedPlatforms
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private pushEvent(task: TaskRecord, type: string, message: string): void {
|
||||
const event: TaskEventRecord = {
|
||||
eventId: randomUUID(),
|
||||
createdAt: nowIso(),
|
||||
type,
|
||||
message
|
||||
};
|
||||
task.events.push(event);
|
||||
task.updatedAt = event.createdAt;
|
||||
}
|
||||
|
||||
private requireTask(taskId: string): TaskRecord {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Task ${taskId} not found.`);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private requireReadiness(platform: PlatformId): SessionReadinessRecord {
|
||||
const readiness = this.readiness.get(platform);
|
||||
if (!readiness) {
|
||||
throw new Error(`Platform ${platform} not found.`);
|
||||
}
|
||||
return readiness;
|
||||
}
|
||||
}
|
||||
12
apps/api/tsconfig.json
Normal file
12
apps/api/tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"node",
|
||||
"vitest/globals"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
12
apps/web/index.html
Normal file
12
apps/web/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>跨平台商品聚合与 AI 分析</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
apps/web/package.json
Normal file
31
apps/web/package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@cross-ai/web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cross-ai/domain": "file:../../packages/domain",
|
||||
"@cross-ai/report-schema": "file:../../packages/report-schema",
|
||||
"@tanstack/react-query": "^5.96.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"jsdom": "^29.0.1",
|
||||
"vite": "^8.0.3"
|
||||
}
|
||||
}
|
||||
610
apps/web/src/App.tsx
Normal file
610
apps/web/src/App.tsx
Normal file
@ -0,0 +1,610 @@
|
||||
import {
|
||||
platformCatalogMap,
|
||||
type CandidateRecord,
|
||||
type PlatformId
|
||||
} from "@cross-ai/domain";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams
|
||||
} from "react-router-dom";
|
||||
|
||||
import { PlatformIdentity, PlatformStatusPill, TaskStatusPill } from "./components/StatusPill";
|
||||
import { TaskContextHeader } from "./components/TaskContextHeader";
|
||||
import { TaskSpine } from "./components/TaskSpine";
|
||||
import {
|
||||
confirmTask,
|
||||
createTask,
|
||||
getHistoryTasks,
|
||||
getPlatformReadiness,
|
||||
getTask,
|
||||
getTaskCandidates,
|
||||
getTaskReport,
|
||||
preparePlatform
|
||||
} from "./lib/api";
|
||||
|
||||
function Layout(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div>
|
||||
<p className="eyebrow">Workspace</p>
|
||||
<h1 className="sidebar__title">跨平台商品分析</h1>
|
||||
<p className="sidebar__copy">研究台、证据板、任务主线。</p>
|
||||
</div>
|
||||
<nav className="sidebar__nav">
|
||||
<a href="/tasks/new">新建任务</a>
|
||||
<a href="/history">历史任务</a>
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="main-content">{props.children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewTaskPage() {
|
||||
const navigate = useNavigate();
|
||||
const readinessQuery = useQuery({
|
||||
queryKey: ["platform-readiness"],
|
||||
queryFn: getPlatformReadiness
|
||||
});
|
||||
const [query, setQuery] = useState("");
|
||||
const [perLinkLimit, setPerLinkLimit] = useState(100);
|
||||
const [taskTotalLimit, setTaskTotalLimit] = useState(500);
|
||||
|
||||
const createTaskMutation = useMutation({
|
||||
mutationFn: createTask,
|
||||
onSuccess: ({ task }) => {
|
||||
navigate(`/tasks/${task.taskId}/confirm`);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<section className="page-panel hero-panel">
|
||||
<div className="hero-panel__copy">
|
||||
<p className="eyebrow">Task Composer</p>
|
||||
<h2>从一个商品描述开始,先召回,再确认,再生成报告。</h2>
|
||||
<p>
|
||||
当前先落地 Phase 0 / 1 基线:统一工作台、共享状态模型、候选确认和结构化报告壳层。
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
className="task-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
createTaskMutation.mutate({
|
||||
query,
|
||||
perLinkLimit,
|
||||
taskTotalLimit
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="field">
|
||||
<span>商品关键词 / 描述</span>
|
||||
<textarea
|
||||
name="query"
|
||||
placeholder="例如:iPhone 15 Pro、DJI Pocket 3、某品牌蓝牙耳机"
|
||||
required
|
||||
rows={4}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="field-grid">
|
||||
<label className="field">
|
||||
<span>单链接评论上限</span>
|
||||
<input
|
||||
min={10}
|
||||
max={200}
|
||||
type="number"
|
||||
value={perLinkLimit}
|
||||
onChange={(event) => setPerLinkLimit(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>任务评论总上限</span>
|
||||
<input
|
||||
min={50}
|
||||
max={500}
|
||||
type="number"
|
||||
value={taskTotalLimit}
|
||||
onChange={(event) => setTaskTotalLimit(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button className="primary-button" disabled={createTaskMutation.isPending} type="submit">
|
||||
创建任务
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="page-grid">
|
||||
<div className="page-panel">
|
||||
<p className="eyebrow">Platform Readiness</p>
|
||||
<h3>当前平台预检查基线</h3>
|
||||
<div className="stack">
|
||||
{readinessQuery.data?.platforms.map((platform) => (
|
||||
<article key={platform.platform} className="readiness-card">
|
||||
<div className="readiness-card__header">
|
||||
<PlatformIdentity platform={platform.platform} />
|
||||
<PlatformStatusPill
|
||||
platformLabel={platformCatalogMap[platform.platform].label}
|
||||
status={platform.ready ? "Completed" : "SearchBlocked"}
|
||||
/>
|
||||
</div>
|
||||
<p>{platformCatalogMap[platform.platform].description}</p>
|
||||
<a
|
||||
className="text-link"
|
||||
href={`/sessions/${platform.platform}/prepare?from=/tasks/new`}
|
||||
>
|
||||
进入会话准备
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-panel">
|
||||
<p className="eyebrow">Scope Reminder</p>
|
||||
<h3>P0 当前不做什么</h3>
|
||||
<ul className="list">
|
||||
<li>不做自动绕过风控。</li>
|
||||
<li>不做无人工确认的同款判断。</li>
|
||||
<li>当前工作台只覆盖天猫、京东。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function CandidateCard(props: {
|
||||
candidate: CandidateRecord;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="candidate-card">
|
||||
<div className="candidate-card__media">
|
||||
<img alt={props.candidate.title} src={props.candidate.imageUrl} />
|
||||
</div>
|
||||
<div className="candidate-card__content">
|
||||
<div className="candidate-card__topline">
|
||||
<input checked={props.checked} onChange={props.onToggle} type="checkbox" />
|
||||
<strong>{props.candidate.title}</strong>
|
||||
</div>
|
||||
<p>{props.candidate.storeName}</p>
|
||||
<div className="candidate-card__meta">
|
||||
<span>{props.candidate.priceLabel}</span>
|
||||
<span>{props.candidate.specLabel}</span>
|
||||
<span>{props.candidate.salesHint}</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmPage() {
|
||||
const navigate = useNavigate();
|
||||
const { taskId = "" } = useParams();
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["task", taskId],
|
||||
queryFn: () => getTask(taskId)
|
||||
});
|
||||
const candidatesQuery = useQuery({
|
||||
queryKey: ["task-candidates", taskId],
|
||||
queryFn: () => getTaskCandidates(taskId),
|
||||
enabled: Boolean(taskId)
|
||||
});
|
||||
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
||||
|
||||
const groupedSelections = useMemo(() => {
|
||||
const groups: Array<{ platform: PlatformId; candidateIds: string[] }> = [];
|
||||
const candidates = candidatesQuery.data?.candidates;
|
||||
if (!candidates) {
|
||||
return groups;
|
||||
}
|
||||
(Object.keys(candidates) as PlatformId[]).forEach((platform) => {
|
||||
const candidateIds = candidates[platform]
|
||||
.filter((candidate) => selected[candidate.candidateId])
|
||||
.map((candidate) => candidate.candidateId);
|
||||
if (candidateIds.length > 0) {
|
||||
groups.push({ platform, candidateIds });
|
||||
}
|
||||
});
|
||||
return groups;
|
||||
}, [candidatesQuery.data, selected]);
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: () => confirmTask(taskId, { selections: groupedSelections }),
|
||||
onSuccess: ({ task }) => {
|
||||
if (task.taskStatus === "NoSelection") {
|
||||
navigate("/history");
|
||||
return;
|
||||
}
|
||||
navigate(`/tasks/${task.taskId}/run`);
|
||||
}
|
||||
});
|
||||
|
||||
if (!taskQuery.data || !candidatesQuery.data) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-panel">加载中...</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const task = taskQuery.data.task;
|
||||
const candidates = candidatesQuery.data.candidates;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<TaskContextHeader task={task} />
|
||||
<TaskSpine current="confirmation" />
|
||||
<section className="page-grid">
|
||||
{(Object.keys(candidates) as PlatformId[]).map((platform) => (
|
||||
<article key={platform} className="page-panel">
|
||||
<div className="candidate-section__header">
|
||||
<div>
|
||||
<p className="eyebrow">Candidate Board</p>
|
||||
<h3>{platformCatalogMap[platform].label}</h3>
|
||||
</div>
|
||||
<PlatformStatusPill
|
||||
status={
|
||||
task.platformRuns.find((run) => run.platform === platform)?.status ??
|
||||
"Pending"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{candidates[platform].length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>
|
||||
{task.platformRuns.find((run) => run.platform === platform)?.reason ??
|
||||
"当前没有候选结果。"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="stack">
|
||||
{candidates[platform].map((candidate) => (
|
||||
<CandidateCard
|
||||
key={candidate.candidateId}
|
||||
candidate={candidate}
|
||||
checked={Boolean(selected[candidate.candidateId])}
|
||||
onToggle={() =>
|
||||
setSelected((current) => ({
|
||||
...current,
|
||||
[candidate.candidateId]: !current[candidate.candidateId]
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<div className="sticky-actions">
|
||||
<div>
|
||||
<p className="eyebrow">Selection Basket</p>
|
||||
<strong>
|
||||
已选{" "}
|
||||
{groupedSelections.reduce(
|
||||
(sum, group) => sum + group.candidateIds.length,
|
||||
0
|
||||
)}{" "}
|
||||
个链接
|
||||
</strong>
|
||||
</div>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={confirmMutation.isPending}
|
||||
onClick={() => confirmMutation.mutate()}
|
||||
>
|
||||
提交确认
|
||||
</button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function RunPage() {
|
||||
const { taskId = "" } = useParams();
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["task", taskId],
|
||||
queryFn: () => getTask(taskId)
|
||||
});
|
||||
|
||||
if (!taskQuery.data) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-panel">加载中...</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const task = taskQuery.data.task;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<TaskContextHeader task={task} />
|
||||
<TaskSpine current="execution" />
|
||||
<section className="page-grid">
|
||||
<article className="page-panel">
|
||||
<p className="eyebrow">Task Status</p>
|
||||
<h3>当前阶段:{task.taskStage}</h3>
|
||||
<div className="stack">
|
||||
{task.platformRuns.map((run) => (
|
||||
<div key={run.platform} className="platform-run-panel">
|
||||
<div className="platform-run-panel__header">
|
||||
<PlatformIdentity platform={run.platform} />
|
||||
<PlatformStatusPill status={run.status} />
|
||||
</div>
|
||||
<p>{run.reason ?? "当前平台已进入主线处理。"}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
<article className="page-panel">
|
||||
<p className="eyebrow">Live Event Feed</p>
|
||||
<div aria-live="polite" className="event-feed">
|
||||
{task.events.map((event) => (
|
||||
<div key={event.eventId} className="event-row">
|
||||
<span>{new Date(event.createdAt).toLocaleTimeString("zh-CN")}</span>
|
||||
<p>{event.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{task.defaultReportVersion ? (
|
||||
<a
|
||||
className="primary-button primary-button--link"
|
||||
href={`/tasks/${task.taskId}/report`}
|
||||
>
|
||||
查看报告
|
||||
</a>
|
||||
) : null}
|
||||
</article>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard(props: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="metric-card">
|
||||
<span>{props.label}</span>
|
||||
<strong>{props.value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportPage() {
|
||||
const { taskId = "" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const version = searchParams.get("version");
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["task", taskId],
|
||||
queryFn: () => getTask(taskId)
|
||||
});
|
||||
const reportQuery = useQuery({
|
||||
queryKey: ["report", taskId, version],
|
||||
queryFn: () => getTaskReport(taskId, version ? Number(version) : undefined)
|
||||
});
|
||||
|
||||
if (!taskQuery.data || !reportQuery.data) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-panel">加载中...</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const task = taskQuery.data.task;
|
||||
const report = reportQuery.data.report;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<TaskContextHeader task={task} />
|
||||
<TaskSpine current="report" />
|
||||
<section className="report-grid">
|
||||
<article className="page-panel report-hero">
|
||||
<p className="eyebrow">Report Summary</p>
|
||||
<div className="report-hero__topline">
|
||||
<h2>{report.summary.headline}</h2>
|
||||
<TaskStatusPill status={task.taskStatus} />
|
||||
</div>
|
||||
<div className="metrics-grid">
|
||||
<MetricCard label="平台数" value={String(report.product_snapshot.platform_count)} />
|
||||
<MetricCard
|
||||
label="确认链接"
|
||||
value={String(report.product_snapshot.selected_link_count)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="评论样本"
|
||||
value={String(report.product_snapshot.review_sample_count)}
|
||||
/>
|
||||
<MetricCard label="报告版本" value={`v${report.report_version}`} />
|
||||
</div>
|
||||
<div className="stack">
|
||||
{report.summary.key_points.map((point) => (
|
||||
<p key={point} className="inline-note">
|
||||
{point}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="page-panel">
|
||||
<p className="eyebrow">Quality Flags</p>
|
||||
<h3>当前覆盖范围</h3>
|
||||
<ul className="list">
|
||||
{report.summary.limitations.map((limitation) => (
|
||||
<li key={limitation}>{limitation}</li>
|
||||
))}
|
||||
<li>
|
||||
阻塞平台:
|
||||
{report.quality_flags.blocked_platforms.length > 0
|
||||
? report.quality_flags.blocked_platforms
|
||||
.map((platform) => platformCatalogMap[platform].label)
|
||||
.join("、")
|
||||
: "无"}
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="page-grid">
|
||||
<article className="page-panel">
|
||||
<p className="eyebrow">Platform Insights</p>
|
||||
<div className="stack">
|
||||
{report.platform_insights.map((insight) => (
|
||||
<div key={insight.platform} className="platform-run-panel">
|
||||
<div className="platform-run-panel__header">
|
||||
<PlatformIdentity platform={insight.platform} />
|
||||
<span className="status-pill status-pill--info">
|
||||
{insight.execution_status}
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
已确认 {insight.selected_link_count} 个链接
|
||||
{insight.price_range
|
||||
? `,价格区间 ¥${insight.price_range.min} - ¥${insight.price_range.max}`
|
||||
: ",当前无价格区间。"}
|
||||
</p>
|
||||
<div className="stack stack--dense">
|
||||
{insight.selling_points.concat(insight.negative_themes).map((card) => (
|
||||
<div key={card.card_id} className="insight-card">
|
||||
<strong>{card.title}</strong>
|
||||
<p>{card.statement}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryPage() {
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ["history"],
|
||||
queryFn: getHistoryTasks
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<section className="page-panel">
|
||||
<p className="eyebrow">History</p>
|
||||
<h2>任务账本</h2>
|
||||
<div className="stack">
|
||||
{historyQuery.data?.tasks.map((task) => (
|
||||
<article key={task.taskId} className="history-card">
|
||||
<div className="history-card__topline">
|
||||
<div>
|
||||
<strong>{task.query}</strong>
|
||||
<p>{new Date(task.updatedAt).toLocaleString("zh-CN")}</p>
|
||||
</div>
|
||||
<TaskStatusPill status={task.taskStatus} />
|
||||
</div>
|
||||
<div className="history-card__actions">
|
||||
<a
|
||||
className="text-link"
|
||||
href={
|
||||
task.hasReport
|
||||
? `/tasks/${task.taskId}/report`
|
||||
: `/tasks/${task.taskId}/run`
|
||||
}
|
||||
>
|
||||
{task.hasReport ? "查看报告" : "查看任务"}
|
||||
</a>
|
||||
{task.defaultReportVersion ? (
|
||||
<span>默认版本 v{task.defaultReportVersion}</span>
|
||||
) : (
|
||||
<span>No report</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)) ?? <div className="empty-state">还没有任务,先创建第一条分析任务。</div>}
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionPreparePage() {
|
||||
const navigate = useNavigate();
|
||||
const { platform = "tmall" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const from = searchParams.get("from") ?? "/tasks/new";
|
||||
const prepareMutation = useMutation({
|
||||
mutationFn: () => preparePlatform(platform as PlatformId),
|
||||
onSuccess: () => {
|
||||
navigate(from);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<section className="page-panel session-panel">
|
||||
<p className="eyebrow">Session Console</p>
|
||||
<h2>{platformCatalogMap[platform as PlatformId].label} 会话准备</h2>
|
||||
<p>{platformCatalogMap[platform as PlatformId].recoveryHint}</p>
|
||||
<div className="session-placeholder">
|
||||
<div className="session-placeholder__viewport">Remote Browser Viewport</div>
|
||||
<div className="session-placeholder__sidebar">
|
||||
<strong>当前模式:prepare</strong>
|
||||
<p>
|
||||
本轮实现先用按钮模拟会话预热,后续替换为真正的远程浏览器接管。
|
||||
</p>
|
||||
<button className="primary-button" onClick={() => prepareMutation.mutate()}>
|
||||
标记预热完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Navigate replace to="/history" />} path="/" />
|
||||
<Route element={<NewTaskPage />} path="/tasks/new" />
|
||||
<Route element={<ConfirmPage />} path="/tasks/:taskId/confirm" />
|
||||
<Route element={<RunPage />} path="/tasks/:taskId/run" />
|
||||
<Route element={<ReportPage />} path="/tasks/:taskId/report" />
|
||||
<Route element={<HistoryPage />} path="/history" />
|
||||
<Route element={<SessionPreparePage />} path="/sessions/:platform/prepare" />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
47
apps/web/src/components/StatusPill.tsx
Normal file
47
apps/web/src/components/StatusPill.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import {
|
||||
platformCatalogMap,
|
||||
platformStatusToneMap,
|
||||
taskStatusToneMap,
|
||||
type PlatformStatus,
|
||||
type TaskStatus
|
||||
} from "@cross-ai/domain";
|
||||
|
||||
type Tone =
|
||||
| "neutral"
|
||||
| "info"
|
||||
| "progress"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "blocked"
|
||||
| "danger"
|
||||
| "empty";
|
||||
|
||||
function toneClassName(tone: Tone) {
|
||||
return `status-pill status-pill--${tone}`;
|
||||
}
|
||||
|
||||
export function TaskStatusPill(props: { status: TaskStatus }) {
|
||||
return <span className={toneClassName(taskStatusToneMap[props.status])}>{props.status}</span>;
|
||||
}
|
||||
|
||||
export function PlatformStatusPill(props: {
|
||||
platformLabel?: string;
|
||||
status: PlatformStatus;
|
||||
}) {
|
||||
return (
|
||||
<span className={toneClassName(platformStatusToneMap[props.status])}>
|
||||
{props.platformLabel ? `${props.platformLabel} · ` : ""}
|
||||
{props.status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformIdentity(props: { platform: "tmall" | "jd" }) {
|
||||
const platform = platformCatalogMap[props.platform];
|
||||
return (
|
||||
<span className="platform-identity">
|
||||
<span className="platform-identity__badge">{platform.shortLabel}</span>
|
||||
{platform.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
18
apps/web/src/components/TaskContextHeader.tsx
Normal file
18
apps/web/src/components/TaskContextHeader.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import type { TaskRecord } from "@cross-ai/domain";
|
||||
|
||||
import { TaskStatusPill } from "./StatusPill";
|
||||
|
||||
export function TaskContextHeader(props: { task: TaskRecord }) {
|
||||
return (
|
||||
<header className="task-context-header">
|
||||
<div>
|
||||
<p className="eyebrow">当前任务</p>
|
||||
<h1>{props.task.query}</h1>
|
||||
</div>
|
||||
<div className="task-context-header__meta">
|
||||
<TaskStatusPill status={props.task.taskStatus} />
|
||||
<span>{new Date(props.task.updatedAt).toLocaleString("zh-CN")}</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
18
apps/web/src/components/TaskSpine.test.tsx
Normal file
18
apps/web/src/components/TaskSpine.test.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { TaskSpine } from "./TaskSpine";
|
||||
|
||||
describe("TaskSpine", () => {
|
||||
it("renders the four task steps and highlights the current one", () => {
|
||||
render(<TaskSpine current="execution" />);
|
||||
|
||||
expect(screen.getByText("输入")).toBeInTheDocument();
|
||||
expect(screen.getByText("确认")).toBeInTheDocument();
|
||||
expect(screen.getByText("执行")).toBeInTheDocument();
|
||||
expect(screen.getByText("报告")).toBeInTheDocument();
|
||||
expect(screen.getByText("执行").closest(".task-spine__item")).toHaveClass(
|
||||
"task-spine__item--active"
|
||||
);
|
||||
});
|
||||
});
|
||||
22
apps/web/src/components/TaskSpine.tsx
Normal file
22
apps/web/src/components/TaskSpine.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { taskSpine } from "@cross-ai/domain";
|
||||
|
||||
export function TaskSpine(props: {
|
||||
current: (typeof taskSpine)[number]["id"];
|
||||
}) {
|
||||
return (
|
||||
<div aria-label="任务主线" className="task-spine">
|
||||
{taskSpine.map((step, index) => {
|
||||
const active = step.id === props.current;
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`task-spine__item ${active ? "task-spine__item--active" : ""}`}
|
||||
>
|
||||
<span className="task-spine__index">{index + 1}</span>
|
||||
<span>{step.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
apps/web/src/main.tsx
Normal file
19
apps/web/src/main.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
471
apps/web/src/styles.css
Normal file
471
apps/web/src/styles.css
Normal file
@ -0,0 +1,471 @@
|
||||
: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);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 32px 24px;
|
||||
border-right: 1px solid rgba(31, 42, 48, 0.08);
|
||||
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);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
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;
|
||||
}
|
||||
|
||||
.field textarea,
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line-subtle);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-button--link {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
color: var(--brand-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.readiness-card,
|
||||
.platform-run-panel,
|
||||
.history-card,
|
||||
.candidate-card,
|
||||
.metric-card,
|
||||
.insight-card,
|
||||
.evidence-card {
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
border-radius: 18px;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.readiness-card,
|
||||
.platform-run-panel,
|
||||
.history-card,
|
||||
.metric-card,
|
||||
.insight-card,
|
||||
.evidence-card {
|
||||
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;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.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;
|
||||
background: rgba(20, 108, 110, 0.08);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.metric-card span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.history-card__actions,
|
||||
.sticky-actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.session-placeholder__viewport {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
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);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.app-shell,
|
||||
.hero-panel,
|
||||
.page-grid,
|
||||
.report-grid,
|
||||
.field-grid,
|
||||
.metrics-grid,
|
||||
.session-placeholder {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
1
apps/web/src/test/setup.ts
Normal file
1
apps/web/src/test/setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
15
apps/web/tsconfig.json
Normal file
15
apps/web/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"types": [
|
||||
"vite/client",
|
||||
"vitest/globals"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
10
apps/web/vite.config.ts
Normal file
10
apps/web/vite.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.ts"
|
||||
}
|
||||
});
|
||||
4509
package-lock.json
generated
Normal file
4509
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "cross-platform-products-ai-analyst",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"packageManager": "npm@11.11.1",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "turbo run dev --parallel",
|
||||
"build": "turbo run build",
|
||||
"test": "turbo run test",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"lint": "turbo run lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.21.0",
|
||||
"turbo": "^2.9.3",
|
||||
"typescript": "^6.0.2",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
15
packages/domain/package.json
Normal file
15
packages/domain/package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@cross-ai/domain",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --dts --format esm,cjs --clean",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "tsc -p tsconfig.json --noEmit"
|
||||
}
|
||||
}
|
||||
66
packages/domain/src/enums.ts
Normal file
66
packages/domain/src/enums.ts
Normal file
@ -0,0 +1,66 @@
|
||||
export const platforms = ["tmall", "jd"] as const;
|
||||
export type PlatformId = (typeof platforms)[number];
|
||||
|
||||
export const searchRequirements = ["none", "recommended", "required"] as const;
|
||||
export type SearchRequirement = (typeof searchRequirements)[number];
|
||||
|
||||
export const taskStatuses = [
|
||||
"Draft",
|
||||
"Searching",
|
||||
"AwaitingConfirmation",
|
||||
"NoSelection",
|
||||
"Running",
|
||||
"Completed",
|
||||
"PartialCompleted",
|
||||
"Blocked",
|
||||
"Failed"
|
||||
] as const;
|
||||
export type TaskStatus = (typeof taskStatuses)[number];
|
||||
|
||||
export const taskStages = [
|
||||
"precheck",
|
||||
"search",
|
||||
"confirmation",
|
||||
"session_check",
|
||||
"crawl",
|
||||
"normalize",
|
||||
"analyze",
|
||||
"publish"
|
||||
] as const;
|
||||
export type TaskStage = (typeof taskStages)[number];
|
||||
|
||||
export const platformStatuses = [
|
||||
"Pending",
|
||||
"SearchBlocked",
|
||||
"Searching",
|
||||
"NoResult",
|
||||
"AwaitingSelection",
|
||||
"Skipped",
|
||||
"Selected",
|
||||
"Blocked",
|
||||
"Running",
|
||||
"Completed",
|
||||
"Failed"
|
||||
] as const;
|
||||
export type PlatformStatus = (typeof platformStatuses)[number];
|
||||
|
||||
export const executionStatuses = [
|
||||
"completed",
|
||||
"blocked",
|
||||
"failed",
|
||||
"skipped",
|
||||
"no_result"
|
||||
] as const;
|
||||
export type ExecutionStatus = (typeof executionStatuses)[number];
|
||||
|
||||
export const reportableTaskStatuses = ["Completed", "PartialCompleted"] as const;
|
||||
export type ReportableTaskStatus = (typeof reportableTaskStatuses)[number];
|
||||
|
||||
export const confidenceLevels = ["high", "medium", "low"] as const;
|
||||
export type ConfidenceLevel = (typeof confidenceLevels)[number];
|
||||
|
||||
export const sampleFlags = ["sufficient", "insufficient", "partial"] as const;
|
||||
export type SampleFlag = (typeof sampleFlags)[number];
|
||||
|
||||
export const evidenceSourceTypes = ["product", "review"] as const;
|
||||
export type EvidenceSourceType = (typeof evidenceSourceTypes)[number];
|
||||
5
packages/domain/src/index.ts
Normal file
5
packages/domain/src/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./enums";
|
||||
export * from "./models";
|
||||
export * from "./platforms";
|
||||
export * from "./presentation";
|
||||
export * from "./state-machine";
|
||||
86
packages/domain/src/models.ts
Normal file
86
packages/domain/src/models.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import type {
|
||||
PlatformId,
|
||||
PlatformStatus,
|
||||
SearchRequirement,
|
||||
TaskStage,
|
||||
TaskStatus
|
||||
} from "./enums";
|
||||
|
||||
export interface CreateTaskInput {
|
||||
query: string;
|
||||
perLinkLimit: number;
|
||||
taskTotalLimit: number;
|
||||
}
|
||||
|
||||
export interface CandidateRecord {
|
||||
candidateId: string;
|
||||
platform: PlatformId;
|
||||
title: string;
|
||||
price: number;
|
||||
priceLabel: string;
|
||||
storeName: string;
|
||||
productUrl: string;
|
||||
imageUrl: string;
|
||||
salesHint: string;
|
||||
specLabel: string;
|
||||
highlights: string[];
|
||||
}
|
||||
|
||||
export interface PlatformRunRecord {
|
||||
platform: PlatformId;
|
||||
searchRequirement: SearchRequirement;
|
||||
status: PlatformStatus;
|
||||
reason?: string | undefined;
|
||||
candidateCount: number;
|
||||
selectedCandidateIds: string[];
|
||||
lastUpdatedAt: string;
|
||||
}
|
||||
|
||||
export interface TaskEventRecord {
|
||||
eventId: string;
|
||||
createdAt: string;
|
||||
type: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SessionReadinessRecord {
|
||||
platform: PlatformId;
|
||||
ready: boolean;
|
||||
searchRequirement: SearchRequirement;
|
||||
lastPreparedAt?: string | undefined;
|
||||
}
|
||||
|
||||
export interface TaskRecord {
|
||||
taskId: string;
|
||||
query: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
perLinkLimit: number;
|
||||
taskTotalLimit: number;
|
||||
taskStatus: TaskStatus;
|
||||
taskStage: TaskStage;
|
||||
platformRuns: PlatformRunRecord[];
|
||||
platformCandidates: Record<PlatformId, CandidateRecord[]>;
|
||||
events: TaskEventRecord[];
|
||||
reportVersions: number[];
|
||||
defaultReportVersion?: number | undefined;
|
||||
latestSuccessfulReportVersion?: number | undefined;
|
||||
}
|
||||
|
||||
export interface HistoryTaskRecord {
|
||||
taskId: string;
|
||||
query: string;
|
||||
taskStatus: TaskStatus;
|
||||
updatedAt: string;
|
||||
hasReport: boolean;
|
||||
defaultReportVersion?: number | undefined;
|
||||
failedPlatforms: PlatformId[];
|
||||
blockedPlatforms: PlatformId[];
|
||||
}
|
||||
|
||||
export interface ConfirmTaskPayload {
|
||||
selections: Array<{
|
||||
platform: PlatformId;
|
||||
candidateIds: string[];
|
||||
}>;
|
||||
}
|
||||
33
packages/domain/src/platforms.ts
Normal file
33
packages/domain/src/platforms.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { PlatformId, SearchRequirement } from "./enums";
|
||||
|
||||
export interface PlatformCatalogEntry {
|
||||
id: PlatformId;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
description: string;
|
||||
searchRequirement: SearchRequirement;
|
||||
recoveryHint: string;
|
||||
}
|
||||
|
||||
export const platformCatalog = [
|
||||
{
|
||||
id: "tmall",
|
||||
label: "天猫",
|
||||
shortLabel: "TM",
|
||||
description: "推荐保持会话以提升搜索稳定性。",
|
||||
searchRequirement: "recommended",
|
||||
recoveryHint: "建议先预热会话,再执行候选搜索。"
|
||||
},
|
||||
{
|
||||
id: "jd",
|
||||
label: "京东",
|
||||
shortLabel: "JD",
|
||||
description: "搜索前必须具备有效会话。",
|
||||
searchRequirement: "required",
|
||||
recoveryHint: "需要先完成会话准备,否则系统会标记为 SearchBlocked。"
|
||||
}
|
||||
] as const satisfies readonly PlatformCatalogEntry[];
|
||||
|
||||
export const platformCatalogMap = Object.fromEntries(
|
||||
platformCatalog.map((entry) => [entry.id, entry])
|
||||
) as Record<PlatformId, PlatformCatalogEntry>;
|
||||
44
packages/domain/src/presentation.ts
Normal file
44
packages/domain/src/presentation.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import type { PlatformStatus, TaskStatus } from "./enums";
|
||||
|
||||
export type StatusTone =
|
||||
| "neutral"
|
||||
| "info"
|
||||
| "progress"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "blocked"
|
||||
| "danger"
|
||||
| "empty";
|
||||
|
||||
export const taskStatusToneMap: Record<TaskStatus, StatusTone> = {
|
||||
Draft: "neutral",
|
||||
Searching: "info",
|
||||
AwaitingConfirmation: "info",
|
||||
NoSelection: "empty",
|
||||
Running: "progress",
|
||||
Completed: "success",
|
||||
PartialCompleted: "warning",
|
||||
Blocked: "blocked",
|
||||
Failed: "danger"
|
||||
};
|
||||
|
||||
export const platformStatusToneMap: Record<PlatformStatus, StatusTone> = {
|
||||
Pending: "neutral",
|
||||
SearchBlocked: "blocked",
|
||||
Searching: "info",
|
||||
NoResult: "empty",
|
||||
AwaitingSelection: "info",
|
||||
Skipped: "neutral",
|
||||
Selected: "progress",
|
||||
Blocked: "blocked",
|
||||
Running: "progress",
|
||||
Completed: "success",
|
||||
Failed: "danger"
|
||||
};
|
||||
|
||||
export const taskSpine = [
|
||||
{ id: "input", label: "输入" },
|
||||
{ id: "confirmation", label: "确认" },
|
||||
{ id: "execution", label: "执行" },
|
||||
{ id: "report", label: "报告" }
|
||||
] as const;
|
||||
60
packages/domain/src/state-machine.ts
Normal file
60
packages/domain/src/state-machine.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import type {
|
||||
ExecutionStatus,
|
||||
PlatformStatus,
|
||||
TaskStatus
|
||||
} from "./enums";
|
||||
import type { PlatformRunRecord } from "./models";
|
||||
|
||||
export function mapPlatformStatusToExecutionStatus(
|
||||
status: PlatformStatus
|
||||
): ExecutionStatus {
|
||||
switch (status) {
|
||||
case "Completed":
|
||||
return "completed";
|
||||
case "SearchBlocked":
|
||||
case "Blocked":
|
||||
return "blocked";
|
||||
case "Failed":
|
||||
return "failed";
|
||||
case "Skipped":
|
||||
return "skipped";
|
||||
case "NoResult":
|
||||
return "no_result";
|
||||
default:
|
||||
throw new Error(`Platform status ${status} cannot be published to a report.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveTaskStatusFromConfirmedPlatforms(
|
||||
platformRuns: PlatformRunRecord[]
|
||||
): TaskStatus {
|
||||
const confirmedRuns = platformRuns.filter(
|
||||
(run) => run.selectedCandidateIds.length > 0
|
||||
);
|
||||
|
||||
if (confirmedRuns.length === 0) {
|
||||
return "NoSelection";
|
||||
}
|
||||
|
||||
const completedCount = confirmedRuns.filter(
|
||||
(run) => run.status === "Completed"
|
||||
).length;
|
||||
const hasBlocked = confirmedRuns.some(
|
||||
(run) => run.status === "Blocked" || run.status === "SearchBlocked"
|
||||
);
|
||||
const hasFailed = confirmedRuns.some((run) => run.status === "Failed");
|
||||
|
||||
if (completedCount === confirmedRuns.length) {
|
||||
return "Completed";
|
||||
}
|
||||
|
||||
if (completedCount > 0 && (hasBlocked || hasFailed)) {
|
||||
return "PartialCompleted";
|
||||
}
|
||||
|
||||
if (completedCount === 0 && hasBlocked) {
|
||||
return "Blocked";
|
||||
}
|
||||
|
||||
return "Failed";
|
||||
}
|
||||
75
packages/domain/test/state-machine.test.ts
Normal file
75
packages/domain/test/state-machine.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
deriveTaskStatusFromConfirmedPlatforms,
|
||||
mapPlatformStatusToExecutionStatus,
|
||||
type PlatformRunRecord
|
||||
} from "../src/index";
|
||||
|
||||
function createRun(
|
||||
status: PlatformRunRecord["status"],
|
||||
selected = true
|
||||
): PlatformRunRecord {
|
||||
return {
|
||||
platform: "tmall",
|
||||
searchRequirement: "recommended",
|
||||
status,
|
||||
candidateCount: 1,
|
||||
selectedCandidateIds: selected ? ["candidate-1"] : [],
|
||||
lastUpdatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveTaskStatusFromConfirmedPlatforms", () => {
|
||||
it("returns NoSelection when no platform has confirmed links", () => {
|
||||
expect(
|
||||
deriveTaskStatusFromConfirmedPlatforms([createRun("Skipped", false)])
|
||||
).toBe("NoSelection");
|
||||
});
|
||||
|
||||
it("returns Completed when all confirmed platforms are completed", () => {
|
||||
expect(
|
||||
deriveTaskStatusFromConfirmedPlatforms([
|
||||
createRun("Completed"),
|
||||
createRun("Completed")
|
||||
])
|
||||
).toBe("Completed");
|
||||
});
|
||||
|
||||
it("returns PartialCompleted when at least one confirmed platform is completed and another is blocked", () => {
|
||||
expect(
|
||||
deriveTaskStatusFromConfirmedPlatforms([
|
||||
createRun("Completed"),
|
||||
createRun("Blocked")
|
||||
])
|
||||
).toBe("PartialCompleted");
|
||||
});
|
||||
|
||||
it("returns Blocked when all confirmed platforms are blocked", () => {
|
||||
expect(
|
||||
deriveTaskStatusFromConfirmedPlatforms([createRun("SearchBlocked")])
|
||||
).toBe("Blocked");
|
||||
});
|
||||
|
||||
it("returns Failed when all confirmed platforms fail", () => {
|
||||
expect(
|
||||
deriveTaskStatusFromConfirmedPlatforms([createRun("Failed")])
|
||||
).toBe("Failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapPlatformStatusToExecutionStatus", () => {
|
||||
it("maps publishable platform statuses", () => {
|
||||
expect(mapPlatformStatusToExecutionStatus("Completed")).toBe("completed");
|
||||
expect(mapPlatformStatusToExecutionStatus("SearchBlocked")).toBe("blocked");
|
||||
expect(mapPlatformStatusToExecutionStatus("Failed")).toBe("failed");
|
||||
expect(mapPlatformStatusToExecutionStatus("Skipped")).toBe("skipped");
|
||||
expect(mapPlatformStatusToExecutionStatus("NoResult")).toBe("no_result");
|
||||
});
|
||||
|
||||
it("rejects running states", () => {
|
||||
expect(() => mapPlatformStatusToExecutionStatus("Running")).toThrow(
|
||||
/cannot be published/i
|
||||
);
|
||||
});
|
||||
});
|
||||
13
packages/domain/tsconfig.json
Normal file
13
packages/domain/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"node",
|
||||
"vitest/globals"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
]
|
||||
}
|
||||
19
packages/report-schema/package.json
Normal file
19
packages/report-schema/package.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@cross-ai/report-schema",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --dts --format esm,cjs --clean",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cross-ai/domain": "file:../domain",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
101
packages/report-schema/src/index.ts
Normal file
101
packages/report-schema/src/index.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import {
|
||||
confidenceLevels,
|
||||
evidenceSourceTypes,
|
||||
executionStatuses,
|
||||
platforms,
|
||||
reportableTaskStatuses,
|
||||
sampleFlags
|
||||
} from "@cross-ai/domain";
|
||||
import { z } from "zod";
|
||||
|
||||
export const SourceScopeSchema = z.object({
|
||||
platforms: z.array(z.enum(platforms)).min(1),
|
||||
link_count: z.number().int().nonnegative(),
|
||||
review_count: z.number().int().nonnegative()
|
||||
});
|
||||
|
||||
export const InsightCardSchema = z
|
||||
.object({
|
||||
card_id: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
statement: z.string().min(1),
|
||||
confidence: z.enum(confidenceLevels),
|
||||
sample_flag: z.enum(sampleFlags),
|
||||
source_scope: SourceScopeSchema,
|
||||
evidence_ids: z.array(z.string().min(1))
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.sample_flag !== "insufficient" && value.evidence_ids.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Non-insufficient insights must include evidence_ids.",
|
||||
path: ["evidence_ids"]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const EvidenceSchema = z.object({
|
||||
evidence_id: z.string().min(1),
|
||||
platform: z.enum(platforms),
|
||||
source_type: z.enum(evidenceSourceTypes),
|
||||
source_url: z.string().url(),
|
||||
review_ref: z.string().nullable(),
|
||||
snippet: z.string().min(1),
|
||||
captured_at: z.string().datetime()
|
||||
});
|
||||
|
||||
export const PlatformInsightSchema = z.object({
|
||||
platform: z.enum(platforms),
|
||||
execution_status: z.enum(executionStatuses),
|
||||
selected_link_count: z.number().int().nonnegative(),
|
||||
price_range: z
|
||||
.object({
|
||||
min: z.number().nonnegative(),
|
||||
max: z.number().nonnegative()
|
||||
})
|
||||
.nullable(),
|
||||
selling_points: z.array(InsightCardSchema),
|
||||
positive_themes: z.array(InsightCardSchema),
|
||||
negative_themes: z.array(InsightCardSchema),
|
||||
store_diff_notes: z.array(InsightCardSchema)
|
||||
});
|
||||
|
||||
export const ReportSchema = z.object({
|
||||
report_id: z.string().min(1),
|
||||
report_version: z.number().int().positive(),
|
||||
task_id: z.string().min(1),
|
||||
generated_at: z.string().datetime(),
|
||||
task_status: z.enum(reportableTaskStatuses),
|
||||
summary: z.object({
|
||||
headline: z.string().min(1),
|
||||
key_points: z.array(z.string().min(1)).min(1),
|
||||
limitations: z.array(z.string().min(1))
|
||||
}),
|
||||
product_snapshot: z.object({
|
||||
query: z.string().min(1),
|
||||
normalized_product_name: z.string().min(1),
|
||||
platform_count: z.number().int().nonnegative(),
|
||||
selected_link_count: z.number().int().nonnegative(),
|
||||
review_sample_count: z.number().int().nonnegative(),
|
||||
analysis_time_range: z.object({
|
||||
start: z.string().datetime(),
|
||||
end: z.string().datetime()
|
||||
})
|
||||
}),
|
||||
platform_insights: z.array(PlatformInsightSchema),
|
||||
cross_platform_insights: z.array(InsightCardSchema),
|
||||
recommendations: z.array(InsightCardSchema),
|
||||
evidence_index: z.array(EvidenceSchema),
|
||||
quality_flags: z.object({
|
||||
sample_insufficient: z.boolean(),
|
||||
partial_platform_failure: z.boolean(),
|
||||
blocked_platforms: z.array(z.enum(platforms)),
|
||||
failed_platforms: z.array(z.enum(platforms))
|
||||
})
|
||||
});
|
||||
|
||||
export type ReportSnapshot = z.infer<typeof ReportSchema>;
|
||||
|
||||
export function parseReportSnapshot(input: unknown): ReportSnapshot {
|
||||
return ReportSchema.parse(input);
|
||||
}
|
||||
169
packages/report-schema/test/report-schema.test.ts
Normal file
169
packages/report-schema/test/report-schema.test.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseReportSnapshot } from "../src/index";
|
||||
|
||||
describe("ReportSchema", () => {
|
||||
it("accepts a valid report snapshot", () => {
|
||||
const report = parseReportSnapshot({
|
||||
report_id: "report-1",
|
||||
report_version: 1,
|
||||
task_id: "task-1",
|
||||
generated_at: "2026-04-02T12:00:00.000Z",
|
||||
task_status: "Completed",
|
||||
summary: {
|
||||
headline: "天猫样本较完整,京东待预热。",
|
||||
key_points: ["天猫完成候选确认并产出报告。"],
|
||||
limitations: ["京东本次未进入搜索。"]
|
||||
},
|
||||
product_snapshot: {
|
||||
query: "iPhone 15 Pro",
|
||||
normalized_product_name: "iPhone 15 Pro",
|
||||
platform_count: 2,
|
||||
selected_link_count: 1,
|
||||
review_sample_count: 60,
|
||||
analysis_time_range: {
|
||||
start: "2026-04-02T11:40:00.000Z",
|
||||
end: "2026-04-02T12:00:00.000Z"
|
||||
}
|
||||
},
|
||||
platform_insights: [
|
||||
{
|
||||
platform: "tmall",
|
||||
execution_status: "completed",
|
||||
selected_link_count: 1,
|
||||
price_range: { min: 7999, max: 7999 },
|
||||
selling_points: [
|
||||
{
|
||||
card_id: "card-1",
|
||||
title: "卖点稳定",
|
||||
statement: "标题与详情页都突出影像能力。",
|
||||
confidence: "high",
|
||||
sample_flag: "sufficient",
|
||||
source_scope: {
|
||||
platforms: ["tmall"],
|
||||
link_count: 1,
|
||||
review_count: 60
|
||||
},
|
||||
evidence_ids: ["evidence-1"]
|
||||
}
|
||||
],
|
||||
positive_themes: [],
|
||||
negative_themes: [],
|
||||
store_diff_notes: []
|
||||
},
|
||||
{
|
||||
platform: "jd",
|
||||
execution_status: "blocked",
|
||||
selected_link_count: 0,
|
||||
price_range: null,
|
||||
selling_points: [],
|
||||
positive_themes: [],
|
||||
negative_themes: [],
|
||||
store_diff_notes: []
|
||||
}
|
||||
],
|
||||
cross_platform_insights: [
|
||||
{
|
||||
card_id: "card-2",
|
||||
title: "跨平台覆盖有限",
|
||||
statement: "本轮仅天猫形成可发布洞察。",
|
||||
confidence: "medium",
|
||||
sample_flag: "partial",
|
||||
source_scope: {
|
||||
platforms: ["tmall", "jd"],
|
||||
link_count: 1,
|
||||
review_count: 60
|
||||
},
|
||||
evidence_ids: ["evidence-1"]
|
||||
}
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
card_id: "card-3",
|
||||
title: "优先补齐京东会话",
|
||||
statement: "建议在下一次任务前先完成京东会话准备。",
|
||||
confidence: "medium",
|
||||
sample_flag: "partial",
|
||||
source_scope: {
|
||||
platforms: ["jd"],
|
||||
link_count: 0,
|
||||
review_count: 0
|
||||
},
|
||||
evidence_ids: ["evidence-1"]
|
||||
}
|
||||
],
|
||||
evidence_index: [
|
||||
{
|
||||
evidence_id: "evidence-1",
|
||||
platform: "tmall",
|
||||
source_type: "product",
|
||||
source_url: "https://example.com/tmall/iphone-15-pro",
|
||||
review_ref: null,
|
||||
snippet: "详情页强调 5 倍长焦与钛金属材质。",
|
||||
captured_at: "2026-04-02T11:58:00.000Z"
|
||||
}
|
||||
],
|
||||
quality_flags: {
|
||||
sample_insufficient: false,
|
||||
partial_platform_failure: false,
|
||||
blocked_platforms: [],
|
||||
failed_platforms: []
|
||||
}
|
||||
});
|
||||
|
||||
expect(report.report_version).toBe(1);
|
||||
expect(report.platform_insights).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects a strong insight without evidence ids", () => {
|
||||
expect(() =>
|
||||
parseReportSnapshot({
|
||||
report_id: "report-2",
|
||||
report_version: 1,
|
||||
task_id: "task-2",
|
||||
generated_at: "2026-04-02T12:00:00.000Z",
|
||||
task_status: "Completed",
|
||||
summary: {
|
||||
headline: "invalid",
|
||||
key_points: ["invalid"],
|
||||
limitations: []
|
||||
},
|
||||
product_snapshot: {
|
||||
query: "x",
|
||||
normalized_product_name: "x",
|
||||
platform_count: 1,
|
||||
selected_link_count: 1,
|
||||
review_sample_count: 1,
|
||||
analysis_time_range: {
|
||||
start: "2026-04-02T11:40:00.000Z",
|
||||
end: "2026-04-02T12:00:00.000Z"
|
||||
}
|
||||
},
|
||||
platform_insights: [],
|
||||
cross_platform_insights: [
|
||||
{
|
||||
card_id: "card-1",
|
||||
title: "缺少证据",
|
||||
statement: "这里没有 evidence ids。",
|
||||
confidence: "high",
|
||||
sample_flag: "sufficient",
|
||||
source_scope: {
|
||||
platforms: ["tmall"],
|
||||
link_count: 1,
|
||||
review_count: 1
|
||||
},
|
||||
evidence_ids: []
|
||||
}
|
||||
],
|
||||
recommendations: [],
|
||||
evidence_index: [],
|
||||
quality_flags: {
|
||||
sample_insufficient: false,
|
||||
partial_platform_failure: false,
|
||||
blocked_platforms: [],
|
||||
failed_platforms: []
|
||||
}
|
||||
})
|
||||
).toThrow(/evidence_ids/i);
|
||||
});
|
||||
});
|
||||
13
packages/report-schema/tsconfig.json
Normal file
13
packages/report-schema/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"node",
|
||||
"vitest/globals"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
]
|
||||
}
|
||||
19
tsconfig.base.json
Normal file
19
tsconfig.base.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"useDefineForClassFields": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"declaration": true
|
||||
}
|
||||
}
|
||||
35
turbo.json
Normal file
35
turbo.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
"dist/**"
|
||||
]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": [
|
||||
"^typecheck"
|
||||
],
|
||||
"outputs": []
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": [
|
||||
"^lint"
|
||||
],
|
||||
"outputs": []
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": [
|
||||
"^test"
|
||||
],
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user