feat(web): 回测页统一作业反馈条(点了立刻有字 / 真实阶段 / 可取消)

用户反馈:「点击回测没有任何反馈,不清楚是不是已经开始回测」。

- 新增全站统一的 `useJobRunner`(`lib/jobs.ts`)与 `JobProgress` 组件:
  提交瞬间即显示「排队中 + 作业号 + 发起时间」,已用时间**每秒自增**(不依赖后端
  是否报新阶段),阶段来自后端真实 `stage`,排队/运行中可**取消任务**、失败直接显示
  后端原文、成功给「打开归档 / 去对比」。反馈条出现时自动滚入视野(按钮在长表单底部,
  不滚过去就等于没显示);`role="status" aria-live="polite"`。
- `/backtest` 改用它:删掉原先手写的阶段条与 stage/elapsed/jobId 三个 state,
  取消走 `POST /jobs/{id}/cancel`,取消后如实提示「已取消,没有归档」而不是报错。
- 顺带修掉「未命名组合点运行 → 撞 422」:运行是**临时组合不落库**,未填名字时按
  「未命名组合(仅本次运行)」提交并明确提示,不再把后端 422 原文甩给用户。
- 修掉反馈条里 `**加粗**` 字面残留(hint 不走 Markdown)。

验证(真实浏览器 CDP,实测数字):
- 勾选策略 → 点「运行回测组合」→ **+0.11s** 反馈条已显示 `排队中` + `JOB-8508F84D`
  + 发起于 18:05:28 + 已用 0s + 四个真实阶段 + 「取消任务」按钮;+4.5s 进入
  `加载行情与因子数据`;点取消 → 「正在取消…」→「已取消」并提示没有归档。
- 反馈条截图逐字核对(阶段、作业号、时间、按钮),并据此修掉 markdown 残留。
This commit is contained in:
Simon
2026-10-01 18:08:58 +08:00
parent 7e369d9680
commit e8fa67ad40
4 changed files with 465 additions and 34 deletions
+47 -34
View File
@@ -18,11 +18,12 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "rea
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
import { STAGE_LABEL, runSavedCombo, submitComboJob, waitJob } from "@/lib/jobs";
import { runSavedCombo, submitComboJob, useJobRunner } from "@/lib/jobs";
import { recentRange } from "@/lib/dates";
import type { BacktestCombo, BacktestResult, GlobalConfig, SelectionStrategy } from "@/lib/types";
import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading, Field, Progress, BacktestMetrics } from "@/components/ui";
import { BacktestResultView } from "@/components/BacktestResultView";
import { JobProgress } from "@/components/JobProgress";
import { adjustLabel } from "@/lib/labels";
export default function BacktestPage() {
@@ -61,9 +62,18 @@ function emptyDraft(range: { start: string; end: string }): Draft {
};
}
function draftToCombo(d: Draft): BacktestCombo {
/**
* 草稿 → 组合对象。
*
* `nameFallback`:**运行**(不保存)时组合名可以为空,但 `/combos/run` 的契约里
* `name` 必填 —— 老版本直接提交空名字,用户看到的是 422 原文
* (`String should have at least 1 character`),点了「运行」却像什么都没发生。
* 运行是临时组合、不会落库,所以这里给一个**如实说明「未命名/仅本次运行」**的名字,
* 而不是编一个像真的组合名。要正式命名请用「保存为回测组合」。
*/
function draftToCombo(d: Draft, nameFallback?: string): BacktestCombo {
return {
name: d.name.trim(),
name: d.name.trim() || nameFallback || "",
description: d.description.trim(),
strategy_ids: d.strategyIds,
initial_capital: d.initialCapital,
@@ -132,9 +142,12 @@ function BacktestInner() {
const [resultArchiveId, setResultArchiveId] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const [runningComboId, setRunningComboId] = useState<string | null>(null);
const [jobId, setJobId] = useState("");
const [stage, setStage] = useState("");
const [elapsed, setElapsed] = useState(0);
/**
* 作业反馈统一交给 `useJobRunner`(全站同一套):提交瞬间就有「正在提交作业…」,
* 之后是真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消 —— 用户反馈的
* 「点了回测没有任何反馈,不知道有没有开始」就是这里的缺口。
*/
const job = useJobRunner<BacktestResult>("回测组合");
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [saving, setSaving] = useState(false);
@@ -225,7 +238,13 @@ function BacktestInner() {
});
return;
}
await runVia(() => submitComboJob(draftToCombo(draft)), null);
// 未命名时用「未命名组合(仅本次运行)」:临时组合不落库,只是给作业一个可读标签;
// 不让用户在点「运行」后撞上 422(那正是「点了没反应」的观感来源之一)。
const fallback = draft.name.trim() ? undefined : "未命名组合(仅本次运行)";
if (fallback) {
setNotice("本次运行未命名组合名 —— 按「未命名组合(仅本次运行)」提交(不会保存到组合库)。");
}
await runVia(() => submitComboJob(draftToCombo(draft, fallback)), null);
}
/**
@@ -240,22 +259,24 @@ function BacktestInner() {
setRunningComboId(comboId);
setError("");
setNotice("");
setJobId("");
setResult(null);
setResultArchiveId(null);
try {
const { job_id } = await submit();
setJobId(job_id);
setStage("queued");
const out = await waitJob<BacktestResult>(job_id, 900_000, (info) => {
setStage(info.stage);
setElapsed(info.elapsedMs);
const out = await job.run(submit, {
label: draft.name ? `回测组合「${draft.name}」` : "回测组合",
});
if (out.status === "success" && out.result) {
setResult(out.result);
setResult(out.result as BacktestResult);
const exp = out.experimentId ?? null;
setResultArchiveId(exp);
setNotice(exp ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` : "回测完成,已自动归档。");
setNotice(
exp
? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。`
: "回测完成,已自动归档。",
);
} else if (out.status === "cancelled") {
// 主动取消不是错误:如实说明,并提示可重新运行
setNotice("已取消该回测任务(未产生结果)。可改完参数后重新运行。");
} else {
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
}
@@ -264,7 +285,6 @@ function BacktestInner() {
} finally {
setRunning(false);
setRunningComboId(null);
setJobId("");
}
}
@@ -626,23 +646,16 @@ function BacktestInner() {
</div>
</div>
{running ? (
<div className="stages" style={{ marginTop: 12 }}>
{["data_loading", "backtesting", "analysis"].map((st) => {
const order = ["queued", "data_loading", "backtesting", "analysis", "done"];
const cur = order.indexOf(stage);
const mine = order.indexOf(st);
const cls = mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage";
return (
<span className={cls} key={st}>
{mine < cur ? "✓" : mine === cur ? "●" : "○"} {STAGE_LABEL[st] ?? st}
</span>
);
})}
<span className="hint">
作业 <span className="mono">{jobId || "排队中…"}</span> · 已用 {Math.round(elapsed / 1000)}s
(全市场多年区间约 3~5 分钟,可离开本页,结果会归档)
</span>
{/* 反馈条紧贴运行按钮:点了就有字,且在排队阶段就能取消 */}
<JobProgress
state={job.state}
onCancel={job.cancel}
onDismiss={job.reset}
anchorId="backtest-job-progress"
/>
{job.state.phase === "idle" && running ? (
<div className="hint" style={{ marginTop: 12 }} role="status" aria-live="polite">
正在提交作业…(提交成功后会显示作业号与真实阶段,可随时取消)
</div>
) : null}
</Card>