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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user