fix: 补齐前端实测与恢复重试链路
This commit is contained in:
parent
a5b079a673
commit
99718c94fd
23
AGENT.md
23
AGENT.md
@ -67,6 +67,29 @@
|
|||||||
|
|
||||||
对 Gemini、Codex 等其他代理,执行同样的文档产出标准与评审门禁。
|
对 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 Commit Rules
|
||||||
当用户要求提交代码时,代理应在确认变更范围和内容正确后,直接完成 `git add` / `git commit`,不要只停留在提示层。
|
当用户要求提交代码时,代理应在确认变更范围和内容正确后,直接完成 `git add` / `git commit`,不要只停留在提示层。
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,27 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import { createServer } from "./server";
|
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", () => {
|
describe("API server", () => {
|
||||||
it("returns platform readiness with jd blocked by default", async () => {
|
it("returns platform readiness with jd blocked by default", async () => {
|
||||||
const app = createServer();
|
const app = createServer();
|
||||||
@ -65,20 +86,11 @@ describe("API server", () => {
|
|||||||
const app = createServer();
|
const app = createServer();
|
||||||
await app.ready();
|
await app.ready();
|
||||||
|
|
||||||
const createResponse = await app.inject({
|
const createdTask = await createTask(app, "DJI Pocket 3");
|
||||||
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({
|
const confirmResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/api/tasks/${taskId}/confirm`,
|
url: `/api/tasks/${createdTask.taskId}/confirm`,
|
||||||
payload: {
|
payload: {
|
||||||
selections: []
|
selections: []
|
||||||
}
|
}
|
||||||
@ -89,7 +101,7 @@ describe("API server", () => {
|
|||||||
|
|
||||||
const reportResponse = await app.inject({
|
const reportResponse = await app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `/api/tasks/${taskId}/report`
|
url: `/api/tasks/${createdTask.taskId}/report`
|
||||||
});
|
});
|
||||||
expect(reportResponse.statusCode).toBe(404);
|
expect(reportResponse.statusCode).toBe(404);
|
||||||
|
|
||||||
@ -100,27 +112,17 @@ describe("API server", () => {
|
|||||||
const app = createServer();
|
const app = createServer();
|
||||||
await app.ready();
|
await app.ready();
|
||||||
|
|
||||||
const createResponse = await app.inject({
|
const createdTask = await createTask(app, "Nintendo Switch 2");
|
||||||
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({
|
const candidatesResponse = await app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `/api/tasks/${taskId}/candidates`
|
url: `/api/tasks/${createdTask.taskId}/candidates`
|
||||||
});
|
});
|
||||||
const firstCandidateId =
|
const firstCandidateId = candidatesResponse.json().candidates.tmall[0].candidateId;
|
||||||
candidatesResponse.json().candidates.tmall[0].candidateId;
|
|
||||||
|
|
||||||
const confirmResponse = await app.inject({
|
const confirmResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/api/tasks/${taskId}/confirm`,
|
url: `/api/tasks/${createdTask.taskId}/confirm`,
|
||||||
payload: {
|
payload: {
|
||||||
selections: [
|
selections: [
|
||||||
{
|
{
|
||||||
@ -137,7 +139,7 @@ describe("API server", () => {
|
|||||||
|
|
||||||
const reportResponse = await app.inject({
|
const reportResponse = await app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `/api/tasks/${taskId}/report`
|
url: `/api/tasks/${createdTask.taskId}/report`
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(reportResponse.statusCode).toBe(200);
|
expect(reportResponse.statusCode).toBe(200);
|
||||||
@ -146,4 +148,141 @@ describe("API server", () => {
|
|||||||
|
|
||||||
await app.close();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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<{
|
app.get<{
|
||||||
Params: { taskId: string };
|
Params: { taskId: string };
|
||||||
Querystring: { version?: string };
|
Querystring: { version?: string };
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
type HistoryTaskRecord,
|
type HistoryTaskRecord,
|
||||||
type PlatformId,
|
type PlatformId,
|
||||||
type PlatformRunRecord,
|
type PlatformRunRecord,
|
||||||
|
type PlatformStatus,
|
||||||
type SessionReadinessRecord,
|
type SessionReadinessRecord,
|
||||||
type TaskEventRecord,
|
type TaskEventRecord,
|
||||||
type TaskRecord
|
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 {
|
export class InMemoryTaskStore {
|
||||||
private readonly tasks = new Map<string, TaskRecord>();
|
private readonly tasks = new Map<string, TaskRecord>();
|
||||||
private readonly reports = new Map<string, ReportSnapshot[]>();
|
private readonly reports = new Map<string, ReportSnapshot[]>();
|
||||||
|
private readonly reportFingerprints = new Map<string, string[]>();
|
||||||
private readonly readiness = new Map<PlatformId, SessionReadinessRecord>();
|
private readonly readiness = new Map<PlatformId, SessionReadinessRecord>();
|
||||||
|
private readonly executionScenarios = new Map<
|
||||||
|
string,
|
||||||
|
Partial<Record<PlatformId, MockExecutionScenario>>
|
||||||
|
>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.readiness.set("tmall", {
|
this.readiness.set("tmall", {
|
||||||
@ -67,6 +116,7 @@ export class InMemoryTaskStore {
|
|||||||
createTask(input: CreateTaskInput): TaskRecord {
|
createTask(input: CreateTaskInput): TaskRecord {
|
||||||
const timestamp = nowIso();
|
const timestamp = nowIso();
|
||||||
const taskId = randomUUID();
|
const taskId = randomUUID();
|
||||||
|
const executionScenarios = parseMockExecutionScenarios(input.query);
|
||||||
const task: TaskRecord = {
|
const task: TaskRecord = {
|
||||||
taskId,
|
taskId,
|
||||||
query: input.query.trim(),
|
query: input.query.trim(),
|
||||||
@ -91,6 +141,10 @@ export class InMemoryTaskStore {
|
|||||||
reportVersions: []
|
reportVersions: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (Object.keys(executionScenarios).length > 0) {
|
||||||
|
this.executionScenarios.set(taskId, executionScenarios);
|
||||||
|
}
|
||||||
|
|
||||||
this.pushEvent(task, "task.created", "任务已创建,准备进入平台预检查。");
|
this.pushEvent(task, "task.created", "任务已创建,准备进入平台预检查。");
|
||||||
task.taskStatus = "Searching";
|
task.taskStatus = "Searching";
|
||||||
task.taskStage = "precheck";
|
task.taskStage = "precheck";
|
||||||
@ -168,35 +222,78 @@ export class InMemoryTaskStore {
|
|||||||
task.taskStage = "session_check";
|
task.taskStage = "session_check";
|
||||||
this.pushEvent(task, "task.running", "系统开始执行抓取前校验。");
|
this.pushEvent(task, "task.running", "系统开始执行抓取前校验。");
|
||||||
|
|
||||||
task.taskStage = "crawl";
|
this.executeSelectedPlatforms(task, selectedRuns);
|
||||||
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.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
|
||||||
task.updatedAt = nowIso();
|
task.updatedAt = nowIso();
|
||||||
const report = this.buildReport(task);
|
this.publishReportIfNeeded(task);
|
||||||
const previousReports = this.reports.get(task.taskId) ?? [];
|
|
||||||
this.reports.set(task.taskId, [...previousReports, report]);
|
return task;
|
||||||
task.reportVersions = [...task.reportVersions, report.report_version];
|
}
|
||||||
task.defaultReportVersion = report.report_version;
|
|
||||||
task.latestSuccessfulReportVersion = report.report_version;
|
retryPlatform(taskId: string, platform: PlatformId): TaskRecord {
|
||||||
this.pushEvent(task, "task.report_published", `报告 v${report.report_version} 已生成。`);
|
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;
|
return task;
|
||||||
}
|
}
|
||||||
@ -218,38 +315,7 @@ export class InMemoryTaskStore {
|
|||||||
task.taskStage = "search";
|
task.taskStage = "search";
|
||||||
|
|
||||||
for (const run of task.platformRuns) {
|
for (const run of task.platformRuns) {
|
||||||
const readiness = this.requireReadiness(run.platform);
|
this.runSearchForPlatform(task, run);
|
||||||
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.taskStage = "confirmation";
|
||||||
@ -317,13 +383,13 @@ export class InMemoryTaskStore {
|
|||||||
: "已基于当前可用平台生成首版结构化报告。",
|
: "已基于当前可用平台生成首版结构化报告。",
|
||||||
key_points: [
|
key_points: [
|
||||||
`已确认 ${selectedCandidates.length} 个商品链接,纳入 ${reviewSampleCount} 条评论样本。`,
|
`已确认 ${selectedCandidates.length} 个商品链接,纳入 ${reviewSampleCount} 条评论样本。`,
|
||||||
blockedPlatforms.length > 0
|
blockedPlatforms.length + failedPlatforms.length > 0
|
||||||
? `仍有 ${blockedPlatforms.length} 个平台需要先恢复会话。`
|
? `仍有 ${blockedPlatforms.length + failedPlatforms.length} 个平台未完成,其中 ${blockedPlatforms.length} 个阻塞、${failedPlatforms.length} 个失败。`
|
||||||
: "当前已确认平台全部处理完成。"
|
: "当前已确认平台全部处理完成。"
|
||||||
],
|
],
|
||||||
limitations: [
|
limitations: [
|
||||||
blockedPlatforms.length > 0
|
blockedPlatforms.length + failedPlatforms.length > 0
|
||||||
? "被阻塞平台未进入可发布洞察范围。"
|
? "未完成平台不进入当前可发布洞察范围。"
|
||||||
: "当前为第一批开发样板数据,后续将替换为真实采集链路。"
|
: "当前为第一批开发样板数据,后续将替换为真实采集链路。"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -378,11 +444,11 @@ export class InMemoryTaskStore {
|
|||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
negative_themes:
|
negative_themes:
|
||||||
run.status === "SearchBlocked"
|
run.status === "SearchBlocked" || run.status === "Blocked" || run.status === "Failed"
|
||||||
? [
|
? [
|
||||||
sharedInsight(
|
sharedInsight(
|
||||||
`${platformCatalogMap[run.platform].label} 当前未进入采集`,
|
`${platformCatalogMap[run.platform].label} 当前未完成执行`,
|
||||||
run.reason ?? "当前平台需要先恢复会话。",
|
run.reason ?? "当前平台需要先恢复会话或稍后重试。",
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@ -411,10 +477,10 @@ export class InMemoryTaskStore {
|
|||||||
recommendations: [
|
recommendations: [
|
||||||
sharedInsight(
|
sharedInsight(
|
||||||
"继续补齐受阻平台会话",
|
"继续补齐受阻平台会话",
|
||||||
blockedPlatforms.length > 0
|
blockedPlatforms.length + failedPlatforms.length > 0
|
||||||
? `建议优先处理 ${blockedPlatforms
|
? `建议优先处理 ${[...blockedPlatforms, ...failedPlatforms]
|
||||||
.map((platform) => platformCatalogMap[platform].label)
|
.map((platform) => platformCatalogMap[platform].label)
|
||||||
.join("、")} 的会话准备,再发起下一轮任务。`
|
.join("、")} 的恢复或重试,再发起下一轮任务。`
|
||||||
: "建议基于当前样板链路继续扩展到真实采集策略。",
|
: "建议基于当前样板链路继续扩展到真实采集策略。",
|
||||||
evidenceIndex.map((evidence) => evidence.evidence_id)
|
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 {
|
private pushEvent(task: TaskRecord, type: string, message: string): void {
|
||||||
const event: TaskEventRecord = {
|
const event: TaskEventRecord = {
|
||||||
eventId: randomUUID(),
|
eventId: randomUUID(),
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import {
|
|||||||
type CandidateRecord,
|
type CandidateRecord,
|
||||||
type PlatformId
|
type PlatformId
|
||||||
} from "@cross-ai/domain";
|
} 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 { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Navigate,
|
Navigate,
|
||||||
@ -25,7 +25,8 @@ import {
|
|||||||
getTask,
|
getTask,
|
||||||
getTaskCandidates,
|
getTaskCandidates,
|
||||||
getTaskReport,
|
getTaskReport,
|
||||||
preparePlatform
|
preparePlatform,
|
||||||
|
retryTaskPlatform
|
||||||
} from "./lib/api";
|
} from "./lib/api";
|
||||||
|
|
||||||
function Layout(props: { children: React.ReactNode }) {
|
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() {
|
function NewTaskPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const readinessQuery = useQuery({
|
const readinessQuery = useQuery({
|
||||||
@ -100,8 +105,8 @@ function NewTaskPage() {
|
|||||||
<label className="field">
|
<label className="field">
|
||||||
<span>单链接评论上限</span>
|
<span>单链接评论上限</span>
|
||||||
<input
|
<input
|
||||||
min={10}
|
|
||||||
max={200}
|
max={200}
|
||||||
|
min={10}
|
||||||
type="number"
|
type="number"
|
||||||
value={perLinkLimit}
|
value={perLinkLimit}
|
||||||
onChange={(event) => setPerLinkLimit(Number(event.target.value))}
|
onChange={(event) => setPerLinkLimit(Number(event.target.value))}
|
||||||
@ -110,8 +115,8 @@ function NewTaskPage() {
|
|||||||
<label className="field">
|
<label className="field">
|
||||||
<span>任务评论总上限</span>
|
<span>任务评论总上限</span>
|
||||||
<input
|
<input
|
||||||
min={50}
|
|
||||||
max={500}
|
max={500}
|
||||||
|
min={50}
|
||||||
type="number"
|
type="number"
|
||||||
value={taskTotalLimit}
|
value={taskTotalLimit}
|
||||||
onChange={(event) => setTaskTotalLimit(Number(event.target.value))}
|
onChange={(event) => setTaskTotalLimit(Number(event.target.value))}
|
||||||
@ -248,27 +253,30 @@ function ConfirmPage() {
|
|||||||
<TaskContextHeader task={task} />
|
<TaskContextHeader task={task} />
|
||||||
<TaskSpine current="confirmation" />
|
<TaskSpine current="confirmation" />
|
||||||
<section className="page-grid">
|
<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">
|
<article key={platform} className="page-panel">
|
||||||
<div className="candidate-section__header">
|
<div className="candidate-section__header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Candidate Board</p>
|
<p className="eyebrow">Candidate Board</p>
|
||||||
<h3>{platformCatalogMap[platform].label}</h3>
|
<h3>{platformCatalogMap[platform].label}</h3>
|
||||||
</div>
|
</div>
|
||||||
<PlatformStatusPill
|
<PlatformStatusPill status={platformRun?.status ?? "Pending"} />
|
||||||
status={
|
|
||||||
task.platformRuns.find((run) => run.platform === platform)?.status ??
|
|
||||||
"Pending"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{candidates[platform].length === 0 ? (
|
{candidates[platform].length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>
|
<p>{platformRun?.reason ?? "当前没有候选结果。"}</p>
|
||||||
{task.platformRuns.find((run) => run.platform === platform)?.reason ??
|
{platformRun?.status === "SearchBlocked" ? (
|
||||||
"当前没有候选结果。"}
|
<a
|
||||||
</p>
|
className="text-link"
|
||||||
|
href={`/tasks/${taskId}/recovery/${platform}?from=/tasks/${taskId}/confirm`}
|
||||||
|
>
|
||||||
|
处理阻塞并重试
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
@ -288,24 +296,22 @@ function ConfirmPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</section>
|
</section>
|
||||||
<div className="sticky-actions">
|
<div className="sticky-actions">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Selection Basket</p>
|
<p className="eyebrow">Selection Basket</p>
|
||||||
<strong>
|
<strong>
|
||||||
已选{" "}
|
已选{" "}
|
||||||
{groupedSelections.reduce(
|
{groupedSelections.reduce((sum, group) => sum + group.candidateIds.length, 0)} 个链接
|
||||||
(sum, group) => sum + group.candidateIds.length,
|
|
||||||
0
|
|
||||||
)}{" "}
|
|
||||||
个链接
|
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
disabled={confirmMutation.isPending}
|
disabled={confirmMutation.isPending}
|
||||||
onClick={() => confirmMutation.mutate()}
|
onClick={() => confirmMutation.mutate()}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
提交确认
|
提交确认
|
||||||
</button>
|
</button>
|
||||||
@ -315,11 +321,22 @@ function ConfirmPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RunPage() {
|
function RunPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { taskId = "" } = useParams();
|
const { taskId = "" } = useParams();
|
||||||
const taskQuery = useQuery({
|
const taskQuery = useQuery({
|
||||||
queryKey: ["task", taskId],
|
queryKey: ["task", taskId],
|
||||||
queryFn: () => getTask(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) {
|
if (!taskQuery.data) {
|
||||||
return (
|
return (
|
||||||
@ -347,6 +364,28 @@ function RunPage() {
|
|||||||
<PlatformStatusPill status={run.status} />
|
<PlatformStatusPill status={run.status} />
|
||||||
</div>
|
</div>
|
||||||
<p>{run.reason ?? "当前平台已进入主线处理。"}</p>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -385,6 +424,7 @@ function MetricCard(props: { label: string; value: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ReportPage() {
|
function ReportPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { taskId = "" } = useParams();
|
const { taskId = "" } = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const version = searchParams.get("version");
|
const version = searchParams.get("version");
|
||||||
@ -407,6 +447,10 @@ function ReportPage() {
|
|||||||
|
|
||||||
const task = taskQuery.data.task;
|
const task = taskQuery.data.task;
|
||||||
const report = reportQuery.data.report;
|
const report = reportQuery.data.report;
|
||||||
|
const currentVersion =
|
||||||
|
version && Number.isFinite(Number(version))
|
||||||
|
? Number(version)
|
||||||
|
: task.defaultReportVersion ?? report.report_version;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
@ -431,6 +475,23 @@ function ReportPage() {
|
|||||||
/>
|
/>
|
||||||
<MetricCard label="报告版本" value={`v${report.report_version}`} />
|
<MetricCard label="报告版本" value={`v${report.report_version}`} />
|
||||||
</div>
|
</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">
|
<div className="stack">
|
||||||
{report.summary.key_points.map((point) => (
|
{report.summary.key_points.map((point) => (
|
||||||
<p key={point} className="inline-note">
|
<p key={point} className="inline-note">
|
||||||
@ -455,6 +516,14 @@ function ReportPage() {
|
|||||||
.join("、")
|
.join("、")
|
||||||
: "无"}
|
: "无"}
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
失败平台:
|
||||||
|
{report.quality_flags.failed_platforms.length > 0
|
||||||
|
? report.quality_flags.failed_platforms
|
||||||
|
.map((platform) => platformCatalogMap[platform].label)
|
||||||
|
.join("、")
|
||||||
|
: "无"}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
@ -546,6 +615,15 @@ function HistoryPage() {
|
|||||||
>
|
>
|
||||||
{task.hasReport ? "查看报告" : "查看任务"}
|
{task.hasReport ? "查看报告" : "查看任务"}
|
||||||
</a>
|
</a>
|
||||||
|
{task.blockedPlatforms.length > 0 || task.failedPlatforms.length > 0 ? (
|
||||||
|
<span>
|
||||||
|
待处理:
|
||||||
|
{formatPlatformNames([
|
||||||
|
...task.blockedPlatforms,
|
||||||
|
...task.failedPlatforms
|
||||||
|
])}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{task.defaultReportVersion ? (
|
{task.defaultReportVersion ? (
|
||||||
<span>默认版本 v{task.defaultReportVersion}</span>
|
<span>默认版本 v{task.defaultReportVersion}</span>
|
||||||
) : (
|
) : (
|
||||||
@ -562,12 +640,14 @@ function HistoryPage() {
|
|||||||
|
|
||||||
function SessionPreparePage() {
|
function SessionPreparePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { platform = "tmall" } = useParams();
|
const { platform = "tmall" } = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const from = searchParams.get("from") ?? "/tasks/new";
|
const from = searchParams.get("from") ?? "/tasks/new";
|
||||||
const prepareMutation = useMutation({
|
const prepareMutation = useMutation({
|
||||||
mutationFn: () => preparePlatform(platform as PlatformId),
|
mutationFn: () => preparePlatform(platform as PlatformId),
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["platform-readiness"] });
|
||||||
navigate(from);
|
navigate(from);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -582,10 +662,8 @@ function SessionPreparePage() {
|
|||||||
<div className="session-placeholder__viewport">Remote Browser Viewport</div>
|
<div className="session-placeholder__viewport">Remote Browser Viewport</div>
|
||||||
<div className="session-placeholder__sidebar">
|
<div className="session-placeholder__sidebar">
|
||||||
<strong>当前模式:prepare</strong>
|
<strong>当前模式:prepare</strong>
|
||||||
<p>
|
<p>本轮实现先用按钮模拟会话预热,后续替换为真正的远程浏览器接管。</p>
|
||||||
本轮实现先用按钮模拟会话预热,后续替换为真正的远程浏览器接管。
|
<button className="primary-button" onClick={() => prepareMutation.mutate()} type="button">
|
||||||
</p>
|
|
||||||
<button className="primary-button" onClick={() => prepareMutation.mutate()}>
|
|
||||||
标记预热完成
|
标记预热完成
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
@ -602,6 +729,7 @@ export function App() {
|
|||||||
<Route element={<NewTaskPage />} path="/tasks/new" />
|
<Route element={<NewTaskPage />} path="/tasks/new" />
|
||||||
<Route element={<ConfirmPage />} path="/tasks/:taskId/confirm" />
|
<Route element={<ConfirmPage />} path="/tasks/:taskId/confirm" />
|
||||||
<Route element={<RunPage />} path="/tasks/:taskId/run" />
|
<Route element={<RunPage />} path="/tasks/:taskId/run" />
|
||||||
|
<Route element={<RecoveryPage />} path="/tasks/:taskId/recovery/:platform" />
|
||||||
<Route element={<ReportPage />} path="/tasks/:taskId/report" />
|
<Route element={<ReportPage />} path="/tasks/:taskId/report" />
|
||||||
<Route element={<HistoryPage />} path="/history" />
|
<Route element={<HistoryPage />} path="/history" />
|
||||||
<Route element={<SessionPreparePage />} path="/sessions/:platform/prepare" />
|
<Route element={<SessionPreparePage />} path="/sessions/:platform/prepare" />
|
||||||
|
|||||||
@ -128,7 +128,8 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field textarea,
|
.field textarea,
|
||||||
.field input {
|
.field input,
|
||||||
|
.field select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@ -138,6 +139,11 @@ a {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field--inline {
|
||||||
|
grid-template-columns: 96px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.field-grid,
|
.field-grid,
|
||||||
.page-grid,
|
.page-grid,
|
||||||
.report-grid,
|
.report-grid,
|
||||||
@ -174,6 +180,21 @@ a {
|
|||||||
cursor: pointer;
|
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 {
|
.primary-button--link {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
@ -218,6 +239,13 @@ a {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.task-context-header {
|
.task-context-header {
|
||||||
padding: 20px 0 0;
|
padding: 20px 0 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user