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:
@@ -3,6 +3,8 @@
|
||||
* 大样本研究(全市场)可能耗时数十秒到分钟级,经异步 Job 后台执行,
|
||||
* 避免 HTTP 长阻塞(AGENT §19)。页面提交后即时返回 job_id,再轮询到终态。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { apiGet, apiPost } from "./api";
|
||||
import type { ResearchSpec } from "./types";
|
||||
|
||||
@@ -75,6 +77,19 @@ export const STAGE_LABEL: Record<string, string> = {
|
||||
done: "完成",
|
||||
};
|
||||
|
||||
/** 执行阶段时间线(顺序即推进顺序,用于「阶段小圆点」) */
|
||||
export const STAGE_ORDER = ["queued", "data_loading", "selection", "backtesting", "analysis", "done"];
|
||||
|
||||
/** 取消排队中/执行中的作业(POST /api/jobs/{id}/cancel;已结束的会返回 cancelled=false)。 */
|
||||
export function cancelJob(
|
||||
jobId: string,
|
||||
): Promise<{ job_id: string; status: string; cancelled: boolean }> {
|
||||
return apiPost<{ job_id: string; status: string; cancelled: boolean }>(
|
||||
`/jobs/${encodeURIComponent(jobId)}/cancel`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/** 提交一个回测组合为异步 Job(POST /api/combos/run,不保存组合)。 */
|
||||
export async function submitComboJob(combo: unknown): Promise<JobSubmit> {
|
||||
@@ -90,3 +105,204 @@ export async function submitComboJob(combo: unknown): Promise<JobSubmit> {
|
||||
export async function runSavedCombo(comboId: string): Promise<JobSubmit> {
|
||||
return apiPost<JobSubmit>(`/combos/${encodeURIComponent(comboId)}/run`, {});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 统一的「提交 → 轮询 → 终态」状态机(useJobRunner)
|
||||
*
|
||||
* 为什么要有它:以前每个页面各写一遍 running/jobId/error,结果参差不齐 ——
|
||||
* 有的页面点了「运行」只在按钮上转圈,用户不知道到底提交没提交、跑到哪一步;
|
||||
* 有的页面「已用秒数」只在后端阶段变化时才更新,看起来像卡死了(0s 一动不动)。
|
||||
* 这里把状态、秒表、取消、终态收敛到一处,页面只管渲染。
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export type JobPhase =
|
||||
| "idle"
|
||||
| "submitting"
|
||||
| "queued"
|
||||
| "running"
|
||||
| "success"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "timeout";
|
||||
|
||||
export interface JobRunState<T> {
|
||||
phase: JobPhase;
|
||||
/** 作业号(提交成功后立即拿到;这是「到底提交没提交」的凭据) */
|
||||
jobId: string;
|
||||
/** 后端上报的执行阶段(queued/data_loading/selection/backtesting/analysis/done) */
|
||||
stage: string;
|
||||
/** 已用毫秒:**每秒自增**,不依赖后端阶段变化,所以界面不会看起来卡住 */
|
||||
elapsedMs: number;
|
||||
/** 这次跑的是什么(如「因子测试 · momentum_60」),显示在反馈条上 */
|
||||
label: string;
|
||||
/** 批量场景的进度(第 index / total 个),单跑时为 null */
|
||||
progress: { index: number; total: number } | null;
|
||||
error: string;
|
||||
result: T | null;
|
||||
experimentId: string | null;
|
||||
/** 提交时刻(本地时间,用于「发起于 hh:mm:ss」) */
|
||||
startedAt: Date | null;
|
||||
/** 正在请求取消 */
|
||||
cancelling: boolean;
|
||||
}
|
||||
|
||||
function emptyState<T>(label = ""): JobRunState<T> {
|
||||
return {
|
||||
phase: "idle",
|
||||
jobId: "",
|
||||
stage: "",
|
||||
elapsedMs: 0,
|
||||
label,
|
||||
progress: null,
|
||||
error: "",
|
||||
result: null,
|
||||
experimentId: null,
|
||||
startedAt: null,
|
||||
cancelling: false,
|
||||
};
|
||||
}
|
||||
|
||||
export interface JobRunOptions {
|
||||
/** 反馈条上显示的任务名(如「因子测试 · momentum_60」) */
|
||||
label?: string;
|
||||
/** 批量进度(第 index / total 个) */
|
||||
progress?: { index: number; total: number } | null;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface JobRunner<T> {
|
||||
state: JobRunState<T>;
|
||||
/** 提交并轮询到终态;返回终态结果(供页面继续处理,如渲染报告) */
|
||||
run: (
|
||||
submit: () => Promise<{ job_id: string }>,
|
||||
opts?: JobRunOptions,
|
||||
) => Promise<JobOutcome<T>>;
|
||||
cancel: () => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 作业运行状态机。
|
||||
*
|
||||
* 生命周期:idle → submitting(已点击,正在换 job_id)→ queued/running(轮询中,秒表走)
|
||||
* → success | failed | cancelled | timeout。**submitting 也是状态**:点了按钮立刻就有
|
||||
* 反馈文字,不会出现「点了没反应」的空白期。
|
||||
*/
|
||||
export function useJobRunner<T>(defaultLabel = ""): JobRunner<T> {
|
||||
const [state, setState] = useState<JobRunState<T>>(() => emptyState<T>(defaultLabel));
|
||||
const t0 = useRef(0);
|
||||
const timer = useRef<number | null>(null);
|
||||
const alive = useRef(true);
|
||||
const currentJob = useRef("");
|
||||
// 后端最近上报的阶段放在 ref 里:终态那一帧要写「最后一个阶段」,但不想让
|
||||
// `run` 依赖 state(否则每次 state 变化都换一个新函数,页面 effect 会连环触发)。
|
||||
const stageRef = useRef("");
|
||||
|
||||
const stopTick = useCallback(() => {
|
||||
if (timer.current !== null) {
|
||||
window.clearInterval(timer.current);
|
||||
timer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
alive.current = false;
|
||||
if (timer.current !== null) window.clearInterval(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const patch = useCallback((next: Partial<JobRunState<T>>) => {
|
||||
if (!alive.current) return;
|
||||
setState((prev) => ({ ...prev, ...next }));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
stopTick();
|
||||
currentJob.current = "";
|
||||
if (alive.current) setState(emptyState<T>(defaultLabel));
|
||||
}, [defaultLabel, stopTick]);
|
||||
|
||||
const run = useCallback(
|
||||
async (
|
||||
submit: () => Promise<{ job_id: string }>,
|
||||
opts: JobRunOptions = {},
|
||||
): Promise<JobOutcome<T>> => {
|
||||
stopTick();
|
||||
currentJob.current = "";
|
||||
stageRef.current = "";
|
||||
t0.current = Date.now();
|
||||
setState({
|
||||
...emptyState<T>(opts.label ?? defaultLabel),
|
||||
phase: "submitting",
|
||||
label: opts.label ?? defaultLabel,
|
||||
progress: opts.progress ?? null,
|
||||
startedAt: new Date(),
|
||||
});
|
||||
// 秒表独立于后端阶段:即使后端几秒内不报新阶段,「已用 Xs」也在动
|
||||
timer.current = window.setInterval(() => {
|
||||
if (!alive.current) return;
|
||||
setState((prev) =>
|
||||
prev.phase === "submitting" || prev.phase === "queued" || prev.phase === "running"
|
||||
? { ...prev, elapsedMs: Date.now() - t0.current }
|
||||
: prev,
|
||||
);
|
||||
}, 500);
|
||||
try {
|
||||
const { job_id } = await submit();
|
||||
currentJob.current = job_id;
|
||||
stageRef.current = "queued";
|
||||
patch({ jobId: job_id, phase: "queued", stage: "queued" });
|
||||
const out = await waitJob<T>(job_id, opts.timeoutMs ?? 900_000, (info) => {
|
||||
stageRef.current = info.stage;
|
||||
patch({
|
||||
stage: info.stage,
|
||||
elapsedMs: info.elapsedMs,
|
||||
phase: info.stage && info.stage !== "queued" ? "running" : "queued",
|
||||
});
|
||||
});
|
||||
const finalPhase: JobPhase =
|
||||
out.status === "success"
|
||||
? "success"
|
||||
: out.status === "cancelled"
|
||||
? "cancelled"
|
||||
: out.status === "timeout"
|
||||
? "timeout"
|
||||
: "failed";
|
||||
patch({
|
||||
phase: finalPhase,
|
||||
stage: out.status === "success" ? "done" : stageRef.current,
|
||||
elapsedMs: Date.now() - t0.current,
|
||||
result: out.result,
|
||||
experimentId: out.experimentId ?? null,
|
||||
error: out.error ?? "",
|
||||
});
|
||||
return out;
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
patch({ phase: "failed", error: msg, elapsedMs: Date.now() - t0.current });
|
||||
return { status: "failed", result: null, error: msg };
|
||||
} finally {
|
||||
stopTick();
|
||||
}
|
||||
},
|
||||
[defaultLabel, patch, stopTick],
|
||||
);
|
||||
|
||||
const cancel = useCallback(async () => {
|
||||
const jobId = currentJob.current;
|
||||
if (!jobId) return;
|
||||
patch({ cancelling: true });
|
||||
try {
|
||||
await cancelJob(jobId);
|
||||
// 取消后轮询会在 1.5s 内看到 cancelled 终态;这里先把阶段文字改掉,
|
||||
// 让用户马上知道「取消请求已发出」而不是等下一个轮询周期。
|
||||
patch({ stage: "cancelling" });
|
||||
} finally {
|
||||
patch({ cancelling: false });
|
||||
}
|
||||
}, [patch]);
|
||||
|
||||
return { state, run, cancel, reset };
|
||||
}
|
||||
|
||||
@@ -234,6 +234,7 @@ export const REASON_LABELS: Record<string, string> = {
|
||||
buy_skip_min_commission: "不足最低佣金",
|
||||
sell_drop_topn: "跌出 TopN",
|
||||
sell_force_tmax: "持有超 Tmax",
|
||||
sell_rebalance_full: "调仓换仓卖出",
|
||||
sell_defer_tmin: "Tmin 保护暂留",
|
||||
sell_defer_halted: "停牌未卖",
|
||||
sell_defer_limit_down: "跌停未卖",
|
||||
|
||||
Reference in New Issue
Block a user