fix: 补齐前端实测与恢复重试链路

This commit is contained in:
renzhiye 2026-04-02 16:57:21 +08:00
parent a5b079a673
commit 99718c94fd
6 changed files with 733 additions and 150 deletions

View File

@ -67,6 +67,29 @@
对 Gemini、Codex 等其他代理,执行同样的文档产出标准与评审门禁。
## Frontend Verification
前端页面是否“正确”,不能只靠读代码或跑单测判断。只要修改了页面结构、交互流程、状态文案、路由跳转或 API 串联,代理都必须优先用 Playwright MCP 做一次真实页面核查,再决定是否宣称完成。
建议按以下顺序执行:
1. 先启动本地 API 和 Web 服务,再访问真实页面,不要只看静态文件。
2. 用 Playwright MCP 打开目标页面,至少调用一次页面快照和一次截图。
3. 先检查浏览器 console 与 network/请求结果,再检查视觉呈现;不要只看截图。
4. 对照 `docs/PRD.md``docs/FeatureSummary.md``docs/DevelopmentPlan.md``docs/UIDesign.md``docs/tdd.md``docs/tasks.md` 核对页面状态、按钮文案、空态、异常态和跳转是否一致。
5. 必须走一遍主流程,而不是只停留在入口页。对于本仓库,至少覆盖:`/tasks/new``/tasks/:taskId/confirm``/tasks/:taskId/run``/tasks/:taskId/report``/history`;若涉及会话或阻塞恢复,还要覆盖 `/sessions/:platform/prepare``/tasks/:taskId/recovery/:platform`
6. 每次检查都要保存可回看的截图;如果发现问题,先记录触发路径、页面 URL、截图和报错再修复。
7. 修复后必须重新用 Playwright MCP 复测同一路径,确认问题已经消失,而不是仅凭代码推断。
最低核查项如下:
- 页面能正常打开,没有白屏、报错弹窗或关键请求失败。
- 关键状态文案与状态色一致,例如 `SearchBlocked``AwaitingConfirmation``Completed``PartialCompleted`
- 关键按钮可点击且跳转正确,例如创建任务、处理阻塞并重试、查看报告。
- 主流程数据真的更新到下一页,而不是停留在旧状态。
- console 中除开发环境常见噪音外,不应出现由当前改动引入的新错误。
如果代理没有实际调用 Playwright MCP 并查看截图,就不应声称“前端页面已经确认正确”。
## Git Commit Rules
当用户要求提交代码时,代理应在确认变更范围和内容正确后,直接完成 `git add` / `git commit`,不要只停留在提示层。

View File

@ -2,6 +2,27 @@ import { describe, expect, it } from "vitest";
import { createServer } from "./server";
async function createTask(app: ReturnType<typeof createServer>, query: string) {
const response = await app.inject({
method: "POST",
url: "/api/tasks",
payload: {
query,
perLinkLimit: 100,
taskTotalLimit: 500
}
});
return response.json().task;
}
async function preparePlatform(app: ReturnType<typeof createServer>, platform: "tmall" | "jd") {
await app.inject({
method: "POST",
url: `/api/platforms/${platform}/prepare`
});
}
describe("API server", () => {
it("returns platform readiness with jd blocked by default", async () => {
const app = createServer();
@ -65,20 +86,11 @@ describe("API server", () => {
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 createdTask = await createTask(app, "DJI Pocket 3");
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${taskId}/confirm`,
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: []
}
@ -89,7 +101,7 @@ describe("API server", () => {
const reportResponse = await app.inject({
method: "GET",
url: `/api/tasks/${taskId}/report`
url: `/api/tasks/${createdTask.taskId}/report`
});
expect(reportResponse.statusCode).toBe(404);
@ -100,27 +112,17 @@ describe("API server", () => {
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 createdTask = await createTask(app, "Nintendo Switch 2");
const candidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${taskId}/candidates`
url: `/api/tasks/${createdTask.taskId}/candidates`
});
const firstCandidateId =
candidatesResponse.json().candidates.tmall[0].candidateId;
const firstCandidateId = candidatesResponse.json().candidates.tmall[0].candidateId;
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${taskId}/confirm`,
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
@ -137,7 +139,7 @@ describe("API server", () => {
const reportResponse = await app.inject({
method: "GET",
url: `/api/tasks/${taskId}/report`
url: `/api/tasks/${createdTask.taskId}/report`
});
expect(reportResponse.statusCode).toBe(200);
@ -146,4 +148,141 @@ describe("API server", () => {
await app.close();
});
it("retries a SearchBlocked platform after session preparation and restores candidates", async () => {
const app = createServer();
await app.ready();
const createdTask = await createTask(app, "iPhone 15 Pro");
await preparePlatform(app, "jd");
const retryResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/platforms/jd/retry`
});
expect(retryResponse.statusCode).toBe(200);
expect(
retryResponse
.json()
.task.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(3);
await app.close();
});
it("publishes a new report version when a blocked platform recovers successfully", async () => {
const app = createServer();
await app.ready();
await preparePlatform(app, "jd");
const createdTask = await createTask(app, "Nintendo Switch 2 [jd:blocked-once]");
const candidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
const candidates = candidatesResponse.json().candidates;
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
platform: "tmall",
candidateIds: [candidates.tmall[0].candidateId]
},
{
platform: "jd",
candidateIds: [candidates.jd[0].candidateId]
}
]
}
});
expect(confirmResponse.statusCode).toBe(200);
expect(confirmResponse.json().task.taskStatus).toBe("PartialCompleted");
expect(confirmResponse.json().task.reportVersions).toEqual([1]);
const retryResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/platforms/jd/retry`
});
expect(retryResponse.statusCode).toBe(200);
expect(retryResponse.json().task.taskStatus).toBe("Completed");
expect(retryResponse.json().task.reportVersions).toEqual([1, 2]);
expect(retryResponse.json().task.defaultReportVersion).toBe(2);
const firstReport = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/report?version=1`
});
const secondReport = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/report?version=2`
});
expect(firstReport.json().report.task_status).toBe("PartialCompleted");
expect(secondReport.json().report.task_status).toBe("Completed");
await app.close();
});
it("keeps the current report version when retry does not change the result", async () => {
const app = createServer();
await app.ready();
await preparePlatform(app, "jd");
const createdTask = await createTask(app, "Nintendo Switch 2 [jd:failed-always]");
const candidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
const candidates = candidatesResponse.json().candidates;
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
platform: "tmall",
candidateIds: [candidates.tmall[0].candidateId]
},
{
platform: "jd",
candidateIds: [candidates.jd[0].candidateId]
}
]
}
});
expect(confirmResponse.json().task.taskStatus).toBe("PartialCompleted");
expect(confirmResponse.json().task.reportVersions).toEqual([1]);
const retryResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/platforms/jd/retry`
});
expect(retryResponse.statusCode).toBe(200);
expect(retryResponse.json().task.taskStatus).toBe("PartialCompleted");
expect(retryResponse.json().task.reportVersions).toEqual([1]);
expect(retryResponse.json().task.defaultReportVersion).toBe(1);
await app.close();
});
});

View File

@ -78,6 +78,18 @@ export function createServer() {
}
});
app.post<{
Params: { taskId: string; platform: PlatformId };
}>("/api/tasks/:taskId/platforms/:platform/retry", async (request, reply) => {
try {
const task = store.retryPlatform(request.params.taskId, request.params.platform);
return { task };
} catch {
reply.code(404);
return { message: "Task not found." };
}
});
app.get<{
Params: { taskId: string };
Querystring: { version?: string };

View File

@ -10,6 +10,7 @@ import {
type HistoryTaskRecord,
type PlatformId,
type PlatformRunRecord,
type PlatformStatus,
type SessionReadinessRecord,
type TaskEventRecord,
type TaskRecord
@ -30,10 +31,58 @@ function createPlatformCandidatesRecord(): Record<PlatformId, CandidateRecord[]>
};
}
type MockExecutionScenario = {
outcome: Extract<PlatformStatus, "Blocked" | "Failed">;
mode: "once" | "always";
};
const mockScenarioPattern =
/\[(?<platform>tmall|jd):(?<outcome>blocked|failed)(?:-(?<mode>once|always))?\]/giu;
function parseMockExecutionScenarios(
query: string
): Partial<Record<PlatformId, MockExecutionScenario>> {
const scenarios: Partial<Record<PlatformId, MockExecutionScenario>> = {};
for (const match of query.matchAll(mockScenarioPattern)) {
const platform = match.groups?.platform as PlatformId | undefined;
const outcome = match.groups?.outcome;
const mode = match.groups?.mode;
if (!platform || (outcome !== "blocked" && outcome !== "failed")) {
continue;
}
scenarios[platform] = {
outcome: outcome === "blocked" ? "Blocked" : "Failed",
mode: mode === "always" ? "always" : "once"
};
}
return scenarios;
}
function isRetryableStatus(
status: PlatformStatus
): status is Extract<PlatformStatus, "SearchBlocked" | "Blocked" | "Failed"> {
return status === "SearchBlocked" || status === "Blocked" || status === "Failed";
}
function isReportableTaskStatus(
status: TaskRecord["taskStatus"]
): status is Extract<TaskRecord["taskStatus"], "Completed" | "PartialCompleted"> {
return status === "Completed" || status === "PartialCompleted";
}
export class InMemoryTaskStore {
private readonly tasks = new Map<string, TaskRecord>();
private readonly reports = new Map<string, ReportSnapshot[]>();
private readonly reportFingerprints = new Map<string, string[]>();
private readonly readiness = new Map<PlatformId, SessionReadinessRecord>();
private readonly executionScenarios = new Map<
string,
Partial<Record<PlatformId, MockExecutionScenario>>
>();
constructor() {
this.readiness.set("tmall", {
@ -67,6 +116,7 @@ export class InMemoryTaskStore {
createTask(input: CreateTaskInput): TaskRecord {
const timestamp = nowIso();
const taskId = randomUUID();
const executionScenarios = parseMockExecutionScenarios(input.query);
const task: TaskRecord = {
taskId,
query: input.query.trim(),
@ -91,6 +141,10 @@ export class InMemoryTaskStore {
reportVersions: []
};
if (Object.keys(executionScenarios).length > 0) {
this.executionScenarios.set(taskId, executionScenarios);
}
this.pushEvent(task, "task.created", "任务已创建,准备进入平台预检查。");
task.taskStatus = "Searching";
task.taskStage = "precheck";
@ -168,35 +222,78 @@ export class InMemoryTaskStore {
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";
this.executeSelectedPlatforms(task, selectedRuns);
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} 已生成。`);
this.publishReportIfNeeded(task);
return task;
}
retryPlatform(taskId: string, platform: PlatformId): TaskRecord {
const task = this.requireTask(taskId);
const run = task.platformRuns.find((item) => item.platform === platform);
if (!run) {
throw new Error(`Platform ${platform} not found.`);
}
if (!isRetryableStatus(run.status)) {
return task;
}
if (run.status === "SearchBlocked") {
const readiness = this.requireReadiness(platform);
if (readiness.searchRequirement === "required" && !readiness.ready) {
this.pushEvent(
task,
`platform.${platform}.retry_blocked`,
`${platformCatalogMap[platform].label} 仍缺少有效会话,无法重新搜索。`
);
return task;
}
this.pushEvent(
task,
`platform.${platform}.retry_started`,
`${platformCatalogMap[platform].label} 正在重新获取候选结果。`
);
this.runSearchForPlatform(task, run);
const recoveredCandidateCount = task.platformCandidates[platform].length;
task.taskStage = "confirmation";
task.taskStatus = "AwaitingConfirmation";
task.updatedAt = nowIso();
this.pushEvent(
task,
`platform.${platform}.retry_finished`,
recoveredCandidateCount > 0
? `${platformCatalogMap[platform].label} 已恢复候选确认。`
: `${platformCatalogMap[platform].label} 重试后仍未返回候选结果。`
);
return task;
}
if (run.selectedCandidateIds.length === 0) {
this.pushEvent(
task,
`platform.${platform}.retry_skipped`,
`${platformCatalogMap[platform].label} 当前没有已确认链接,无法执行定向重试。`
);
return task;
}
task.taskStatus = "Running";
task.taskStage = "session_check";
this.pushEvent(
task,
`platform.${platform}.retry_started`,
`${platformCatalogMap[platform].label} 正在执行定向重试。`
);
this.executeSelectedPlatforms(task, [run]);
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
task.updatedAt = nowIso();
this.publishReportIfNeeded(task);
return task;
}
@ -218,38 +315,7 @@ export class InMemoryTaskStore {
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} 未返回候选结果。`
);
this.runSearchForPlatform(task, run);
}
task.taskStage = "confirmation";
@ -317,13 +383,13 @@ export class InMemoryTaskStore {
: "已基于当前可用平台生成首版结构化报告。",
key_points: [
`已确认 ${selectedCandidates.length} 个商品链接,纳入 ${reviewSampleCount} 条评论样本。`,
blockedPlatforms.length > 0
? `仍有 ${blockedPlatforms.length} 个平台需要先恢复会话`
blockedPlatforms.length + failedPlatforms.length > 0
? `仍有 ${blockedPlatforms.length + failedPlatforms.length} 个平台未完成,其中 ${blockedPlatforms.length} 个阻塞、${failedPlatforms.length} 个失败`
: "当前已确认平台全部处理完成。"
],
limitations: [
blockedPlatforms.length > 0
? "被阻塞平台未进入可发布洞察范围。"
blockedPlatforms.length + failedPlatforms.length > 0
? "未完成平台不进入当前可发布洞察范围。"
: "当前为第一批开发样板数据,后续将替换为真实采集链路。"
]
},
@ -378,11 +444,11 @@ export class InMemoryTaskStore {
]
: [],
negative_themes:
run.status === "SearchBlocked"
run.status === "SearchBlocked" || run.status === "Blocked" || run.status === "Failed"
? [
sharedInsight(
`${platformCatalogMap[run.platform].label} 当前未进入采集`,
run.reason ?? "当前平台需要先恢复会话。",
`${platformCatalogMap[run.platform].label} 当前未完成执行`,
run.reason ?? "当前平台需要先恢复会话或稍后重试。",
[]
)
]
@ -411,10 +477,10 @@ export class InMemoryTaskStore {
recommendations: [
sharedInsight(
"继续补齐受阻平台会话",
blockedPlatforms.length > 0
? `建议优先处理 ${blockedPlatforms
blockedPlatforms.length + failedPlatforms.length > 0
? `建议优先处理 ${[...blockedPlatforms, ...failedPlatforms]
.map((platform) => platformCatalogMap[platform].label)
.join("、")} `
.join("、")} `
: "建议基于当前样板链路继续扩展到真实采集策略。",
evidenceIndex.map((evidence) => evidence.evidence_id)
)
@ -430,6 +496,193 @@ export class InMemoryTaskStore {
});
}
private runSearchForPlatform(task: TaskRecord, run: PlatformRunRecord): void {
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} 缺少有效会话,搜索被阻塞。`
);
return;
}
run.status = "Searching";
run.reason = undefined;
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} 未返回候选结果。`
);
}
private executeSelectedPlatforms(
task: TaskRecord,
runs: PlatformRunRecord[]
): void {
task.taskStage = "crawl";
for (const run of runs) {
const readiness = this.requireReadiness(run.platform);
if (readiness.searchRequirement === "required" && !readiness.ready) {
run.status = "Blocked";
run.reason = platformCatalogMap[run.platform].recoveryHint;
run.lastUpdatedAt = nowIso();
this.pushEvent(
task,
`platform.${run.platform}.blocked`,
`${platformCatalogMap[run.platform].label} 抓取前校验失败,需要先恢复会话。`
);
continue;
}
const mockOutcome = this.consumeExecutionScenario(task.taskId, run.platform);
if (mockOutcome === "Blocked") {
run.status = "Blocked";
run.reason = `${platformCatalogMap[run.platform].label} 命中模拟阻塞,需要先进入恢复流程。`;
run.lastUpdatedAt = nowIso();
this.pushEvent(
task,
`platform.${run.platform}.blocked`,
`${platformCatalogMap[run.platform].label} 本轮执行被阻塞。`
);
continue;
}
run.status = "Running";
run.reason = undefined;
run.lastUpdatedAt = nowIso();
this.pushEvent(
task,
`platform.${run.platform}.running`,
`${platformCatalogMap[run.platform].label} 开始抓取商品与评论。`
);
if (mockOutcome === "Failed") {
run.status = "Failed";
run.reason = `${platformCatalogMap[run.platform].label} 命中模拟失败,可稍后发起定向重试。`;
run.lastUpdatedAt = nowIso();
this.pushEvent(
task,
`platform.${run.platform}.failed`,
`${platformCatalogMap[run.platform].label} 本轮执行失败。`
);
continue;
}
run.status = "Completed";
run.reason = undefined;
run.lastUpdatedAt = nowIso();
this.pushEvent(
task,
`platform.${run.platform}.completed`,
`${platformCatalogMap[run.platform].label} 已完成当前轮执行。`
);
}
const hasCompletedConfirmedPlatform = task.platformRuns.some(
(run) => run.selectedCandidateIds.length > 0 && run.status === "Completed"
);
if (!hasCompletedConfirmedPlatform) {
task.taskStage = "publish";
this.pushEvent(task, "task.publish_skipped", "当前没有可发布结果,暂不生成报告。");
return;
}
task.taskStage = "normalize";
this.pushEvent(task, "task.normalize", "系统正在标准化商品与评论数据。");
task.taskStage = "analyze";
this.pushEvent(task, "task.analyze", "系统正在生成结构化报告。");
task.taskStage = "publish";
}
private publishReportIfNeeded(task: TaskRecord): boolean {
if (!isReportableTaskStatus(task.taskStatus)) {
this.pushEvent(task, "task.report_skipped", "当前任务状态不满足报告发布条件。");
return false;
}
const fingerprint = this.buildReportFingerprint(task);
const previousFingerprints = this.reportFingerprints.get(task.taskId) ?? [];
const lastFingerprint = previousFingerprints[previousFingerprints.length - 1];
if (lastFingerprint === fingerprint) {
this.pushEvent(task, "task.report_unchanged", "本轮重试未改变报告结果,沿用当前版本。");
return false;
}
const report = this.buildReport(task);
const previousReports = this.reports.get(task.taskId) ?? [];
this.reports.set(task.taskId, [...previousReports, report]);
this.reportFingerprints.set(task.taskId, [...previousFingerprints, fingerprint]);
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 true;
}
private buildReportFingerprint(task: TaskRecord): string {
const selectedLinkCount = task.platformRuns.reduce(
(sum, run) => sum + run.selectedCandidateIds.length,
0
);
return JSON.stringify({
taskStatus: task.taskStatus,
perLinkLimit: task.perLinkLimit,
taskTotalLimit: task.taskTotalLimit,
selectedLinkCount,
platformRuns: task.platformRuns.map((run) => ({
platform: run.platform,
status: run.status,
reason: run.reason ?? null,
selectedCandidateIds: [...run.selectedCandidateIds].sort()
}))
});
}
private consumeExecutionScenario(
taskId: string,
platform: PlatformId
): MockExecutionScenario["outcome"] | null {
const taskScenarios = this.executionScenarios.get(taskId);
const scenario = taskScenarios?.[platform];
if (!scenario) {
return null;
}
if (scenario.mode === "once") {
delete taskScenarios?.[platform];
if (taskScenarios && Object.keys(taskScenarios).length === 0) {
this.executionScenarios.delete(taskId);
}
}
return scenario.outcome;
}
private pushEvent(task: TaskRecord, type: string, message: string): void {
const event: TaskEventRecord = {
eventId: randomUUID(),

View File

@ -3,7 +3,7 @@ import {
type CandidateRecord,
type PlatformId
} from "@cross-ai/domain";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import {
Navigate,
@ -25,7 +25,8 @@ import {
getTask,
getTaskCandidates,
getTaskReport,
preparePlatform
preparePlatform,
retryTaskPlatform
} from "./lib/api";
function Layout(props: { children: React.ReactNode }) {
@ -47,6 +48,10 @@ function Layout(props: { children: React.ReactNode }) {
);
}
function formatPlatformNames(platforms: PlatformId[]) {
return platforms.map((platform) => platformCatalogMap[platform].label).join("、");
}
function NewTaskPage() {
const navigate = useNavigate();
const readinessQuery = useQuery({
@ -100,8 +105,8 @@ function NewTaskPage() {
<label className="field">
<span></span>
<input
min={10}
max={200}
min={10}
type="number"
value={perLinkLimit}
onChange={(event) => setPerLinkLimit(Number(event.target.value))}
@ -110,8 +115,8 @@ function NewTaskPage() {
<label className="field">
<span></span>
<input
min={50}
max={500}
min={50}
type="number"
value={taskTotalLimit}
onChange={(event) => setTaskTotalLimit(Number(event.target.value))}
@ -248,27 +253,30 @@ function ConfirmPage() {
<TaskContextHeader task={task} />
<TaskSpine current="confirmation" />
<section className="page-grid">
{(Object.keys(candidates) as PlatformId[]).map((platform) => (
{(Object.keys(candidates) as PlatformId[]).map((platform) => {
const platformRun = task.platformRuns.find((run) => run.platform === platform);
return (
<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"
}
/>
<PlatformStatusPill status={platformRun?.status ?? "Pending"} />
</div>
{candidates[platform].length === 0 ? (
<div className="empty-state">
<p>
{task.platformRuns.find((run) => run.platform === platform)?.reason ??
"当前没有候选结果。"}
</p>
<p>{platformRun?.reason ?? "当前没有候选结果。"}</p>
{platformRun?.status === "SearchBlocked" ? (
<a
className="text-link"
href={`/tasks/${taskId}/recovery/${platform}?from=/tasks/${taskId}/confirm`}
>
</a>
) : null}
</div>
) : (
<div className="stack">
@ -288,24 +296,22 @@ function ConfirmPage() {
</div>
)}
</article>
))}
);
})}
</section>
<div className="sticky-actions">
<div>
<p className="eyebrow">Selection Basket</p>
<strong>
{" "}
{groupedSelections.reduce(
(sum, group) => sum + group.candidateIds.length,
0
)}{" "}
{groupedSelections.reduce((sum, group) => sum + group.candidateIds.length, 0)}
</strong>
</div>
<button
className="primary-button"
disabled={confirmMutation.isPending}
onClick={() => confirmMutation.mutate()}
type="button"
>
</button>
@ -315,11 +321,22 @@ function ConfirmPage() {
}
function RunPage() {
const queryClient = useQueryClient();
const { taskId = "" } = useParams();
const taskQuery = useQuery({
queryKey: ["task", taskId],
queryFn: () => getTask(taskId)
});
const retryMutation = useMutation({
mutationFn: (platform: PlatformId) => retryTaskPlatform(taskId, platform),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["task", taskId] }),
queryClient.invalidateQueries({ queryKey: ["history"] }),
queryClient.invalidateQueries({ queryKey: ["report", taskId] })
]);
}
});
if (!taskQuery.data) {
return (
@ -347,6 +364,28 @@ function RunPage() {
<PlatformStatusPill status={run.status} />
</div>
<p>{run.reason ?? "当前平台已进入主线处理。"}</p>
<div className="panel-actions">
{run.status === "SearchBlocked" || run.status === "Blocked" ? (
<a
className="text-link"
href={`/tasks/${taskId}/recovery/${run.platform}?from=/tasks/${taskId}/run`}
>
</a>
) : null}
{run.status === "Failed" ? (
<button
className="ghost-button"
disabled={
retryMutation.isPending && retryMutation.variables === run.platform
}
onClick={() => retryMutation.mutate(run.platform)}
type="button"
>
</button>
) : null}
</div>
</div>
))}
</div>
@ -385,6 +424,7 @@ function MetricCard(props: { label: string; value: string }) {
}
function ReportPage() {
const navigate = useNavigate();
const { taskId = "" } = useParams();
const [searchParams] = useSearchParams();
const version = searchParams.get("version");
@ -407,6 +447,10 @@ function ReportPage() {
const task = taskQuery.data.task;
const report = reportQuery.data.report;
const currentVersion =
version && Number.isFinite(Number(version))
? Number(version)
: task.defaultReportVersion ?? report.report_version;
return (
<Layout>
@ -431,6 +475,23 @@ function ReportPage() {
/>
<MetricCard label="报告版本" value={`v${report.report_version}`} />
</div>
{task.reportVersions.length > 1 ? (
<label className="field field--inline">
<span></span>
<select
value={String(currentVersion)}
onChange={(event) =>
navigate(`/tasks/${taskId}/report?version=${event.target.value}`)
}
>
{task.reportVersions.map((reportVersion) => (
<option key={reportVersion} value={reportVersion}>
v{reportVersion}
</option>
))}
</select>
</label>
) : null}
<div className="stack">
{report.summary.key_points.map((point) => (
<p key={point} className="inline-note">
@ -455,6 +516,14 @@ function ReportPage() {
.join("、")
: "无"}
</li>
<li>
{report.quality_flags.failed_platforms.length > 0
? report.quality_flags.failed_platforms
.map((platform) => platformCatalogMap[platform].label)
.join("、")
: "无"}
</li>
</ul>
</article>
</section>
@ -546,6 +615,15 @@ function HistoryPage() {
>
{task.hasReport ? "查看报告" : "查看任务"}
</a>
{task.blockedPlatforms.length > 0 || task.failedPlatforms.length > 0 ? (
<span>
{formatPlatformNames([
...task.blockedPlatforms,
...task.failedPlatforms
])}
</span>
) : null}
{task.defaultReportVersion ? (
<span> v{task.defaultReportVersion}</span>
) : (
@ -562,12 +640,14 @@ function HistoryPage() {
function SessionPreparePage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { platform = "tmall" } = useParams();
const [searchParams] = useSearchParams();
const from = searchParams.get("from") ?? "/tasks/new";
const prepareMutation = useMutation({
mutationFn: () => preparePlatform(platform as PlatformId),
onSuccess: () => {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["platform-readiness"] });
navigate(from);
}
});
@ -582,10 +662,8 @@ function SessionPreparePage() {
<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()}>
<p></p>
<button className="primary-button" onClick={() => prepareMutation.mutate()} type="button">
</button>
</div>
@ -595,6 +673,55 @@ function SessionPreparePage() {
);
}
function RecoveryPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { taskId = "", platform = "tmall" } = useParams();
const [searchParams] = useSearchParams();
const from = searchParams.get("from") ?? `/tasks/${taskId}/run`;
const recoveryMutation = useMutation({
mutationFn: async () => {
await preparePlatform(platform as PlatformId);
return retryTaskPlatform(taskId, platform as PlatformId);
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] }),
queryClient.invalidateQueries({ queryKey: ["task", taskId] }),
queryClient.invalidateQueries({ queryKey: ["task-candidates", taskId] }),
queryClient.invalidateQueries({ queryKey: ["history"] }),
queryClient.invalidateQueries({ queryKey: ["report", taskId] })
]);
navigate(from);
}
});
return (
<Layout>
<section className="page-panel session-panel">
<p className="eyebrow">Recovery 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>recovery</strong>
<p></p>
<button
className="primary-button"
disabled={recoveryMutation.isPending}
onClick={() => recoveryMutation.mutate()}
type="button"
>
</button>
</div>
</div>
</section>
</Layout>
);
}
export function App() {
return (
<Routes>
@ -602,6 +729,7 @@ export function App() {
<Route element={<NewTaskPage />} path="/tasks/new" />
<Route element={<ConfirmPage />} path="/tasks/:taskId/confirm" />
<Route element={<RunPage />} path="/tasks/:taskId/run" />
<Route element={<RecoveryPage />} path="/tasks/:taskId/recovery/:platform" />
<Route element={<ReportPage />} path="/tasks/:taskId/report" />
<Route element={<HistoryPage />} path="/history" />
<Route element={<SessionPreparePage />} path="/sessions/:platform/prepare" />

View File

@ -128,7 +128,8 @@ a {
}
.field textarea,
.field input {
.field input,
.field select {
width: 100%;
padding: 14px 16px;
border-radius: 16px;
@ -138,6 +139,11 @@ a {
font: inherit;
}
.field--inline {
grid-template-columns: 96px minmax(0, 1fr);
align-items: center;
}
.field-grid,
.page-grid,
.report-grid,
@ -174,6 +180,21 @@ a {
cursor: pointer;
}
.ghost-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(20, 108, 110, 0.18);
background: rgba(20, 108, 110, 0.08);
color: var(--brand-primary);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.primary-button--link {
width: fit-content;
}
@ -218,6 +239,13 @@ a {
gap: 16px;
}
.panel-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.task-context-header {
padding: 20px 0 0;
}