fix: 补齐前端实测与恢复重试链路
This commit is contained in:
parent
a5b079a673
commit
99718c94fd
23
AGENT.md
23
AGENT.md
@ -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`,不要只停留在提示层。
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,64 +253,65 @@ function ConfirmPage() {
|
||||
<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>
|
||||
{(Object.keys(candidates) as PlatformId[]).map((platform) => {
|
||||
const platformRun = task.platformRuns.find((run) => run.platform === platform);
|
||||
|
||||
{candidates[platform].length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>
|
||||
{task.platformRuns.find((run) => run.platform === platform)?.reason ??
|
||||
"当前没有候选结果。"}
|
||||
</p>
|
||||
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={platformRun?.status ?? "Pending"} />
|
||||
</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>
|
||||
))}
|
||||
|
||||
{candidates[platform].length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<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">
|
||||
{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
|
||||
)}{" "}
|
||||
个链接
|
||||
{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" />
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user