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
+201
View File
@@ -0,0 +1,201 @@
"use client";
/**
* 作业反馈条(全站统一)。
*
* 解决的问题(用户反馈原话):「点了回测没有任何反馈,不清楚是不是已经开始回测」。
* 因此这里有三条硬要求:
* 1. **点了立刻有字**:`submitting` 阶段就显示「正在提交作业…」,不等到后端返回;
* 2. **看得出在动**:作业号 + 真实阶段 + **每秒自增**的已用时间(阶段没变也在走);
* 3. **出事了能自救**:排队中可「取消」,失败显示后端原文,成功给「打开归档 / 去对比」。
*
* 反馈条出现时会自动滚到视野中央 —— 按钮常在长表单底部,反馈条在页面顶部,
* 不滚一下用户就看不见(这正是「没有反馈」错觉的来源之一)。
*/
import { useEffect, useRef } from "react";
import Link from "next/link";
import { Btn, Pill } from "@/components/ui";
import { Icon } from "@/components/icons";
import { STAGE_LABEL, STAGE_ORDER, type JobRunState } from "@/lib/jobs";
/** 已用时间:< 60s 显示秒,否则 mm:ss(跑几分钟时更好读) */
function fmtElapsed(ms: number): string {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
}
function fmtClock(d: Date | null): string {
if (!d) return "";
return d.toLocaleTimeString("zh-CN", { hour12: false });
}
export function JobProgress<T>({
state,
onCancel,
onDismiss,
/** 完成后是否自动滚到视野(提交时恒滚,完成后不打断阅读) */
anchorId = "job-progress",
}: {
state: JobRunState<T>;
onCancel?: () => void;
onDismiss?: () => void;
anchorId?: string;
}) {
const ref = useRef<HTMLDivElement | null>(null);
const scrolled = useRef(false);
const active = state.phase === "submitting" || state.phase === "queued" || state.phase === "running";
// 提交瞬间(含同一页面第二次提交)把反馈条滚进视野:按钮在长表单底部时,
// 顶部出现的反馈条不滚过去就等于没显示。
useEffect(() => {
if (state.phase === "idle") {
scrolled.current = false;
return;
}
if ((state.phase === "submitting" || state.phase === "queued") && !scrolled.current) {
scrolled.current = true;
ref.current?.scrollIntoView({ block: "center", behavior: "smooth" });
}
}, [state.phase]);
if (state.phase === "idle") return null;
const stageText = state.stage
? state.stage === "cancelling"
? "正在取消…"
: (STAGE_LABEL[state.stage] ?? state.stage)
: "等待后端回报阶段";
const cur = STAGE_ORDER.indexOf(state.stage);
const archiveHref = state.experimentId
? `/experiments?exp=${encodeURIComponent(state.experimentId)}`
: null;
const tone =
state.phase === "success" ? "job-progress--ok" : state.phase === "failed" || state.phase === "timeout" ? "job-progress--bad" : "job-progress--run";
return (
<div
ref={ref}
id={anchorId}
className={`job-progress ${tone}`}
role="status"
aria-live="polite"
data-phase={state.phase}
>
<div className="job-progress__head">
{active ? (
<Pill tone="accent" icon="spinner">
{state.phase === "submitting" ? "正在提交作业…" : stageText}
</Pill>
) : state.phase === "success" ? (
<Pill tone="pos" icon="check">
已完成
</Pill>
) : state.phase === "cancelled" ? (
<Pill tone="warn" icon="x">
已取消
</Pill>
) : (
<Pill tone="neg" icon="alert">
{state.phase === "timeout" ? "等待超时" : "执行失败"}
</Pill>
)}
<span className="job-progress__title">
{state.label || "研究任务"}
{state.progress ? (
<span className="hint">
{" "}
(第 {state.progress.index}/{state.progress.total} 个)
</span>
) : null}
</span>
<span className="job-progress__meta">
{state.jobId ? (
<>
作业 <span className="mono">{state.jobId}</span>
</>
) : (
<span className="hint">正在换取作业号…</span>
)}
{state.startedAt ? <span className="hint">发起于 {fmtClock(state.startedAt)}</span> : null}
{active ? <span className="mono">已用 {fmtElapsed(state.elapsedMs)}</span> : null}
</span>
<span className="job-progress__actions">
{active && onCancel && state.jobId ? (
<Btn size="sm" icon="x" loading={state.cancelling} disabled={state.cancelling} onClick={onCancel}>
取消任务
</Btn>
) : null}
{!active && archiveHref ? (
<Link className="btn btn--sm" href={archiveHref}>
<span>去对比</span>
</Link>
) : null}
{!active && state.experimentId ? (
<Link
className="btn btn--sm"
href={`/experiments/${encodeURIComponent(state.experimentId)}`}
target="_blank"
rel="noreferrer"
>
<span>打开归档</span>
<Icon name="arrowUpRight" size={13} />
</Link>
) : null}
{!active && onDismiss ? (
<Btn size="sm" onClick={onDismiss}>
收起
</Btn>
) : null}
</span>
</div>
{active ? (
<div className="stages" style={{ marginTop: 8 }}>
{["data_loading", "selection", "backtesting", "analysis"].map((st) => {
const mine = STAGE_ORDER.indexOf(st);
const cls = cur >= 0 && mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage";
return (
<span className={cls} key={st}>
{cur >= 0 && mine < cur ? "✓" : mine === cur ? "●" : "○"} {STAGE_LABEL[st] ?? st}
</span>
);
})}
</div>
) : null}
{active ? (
<div className="hint" style={{ marginTop: 6 }}>
后台执行中:可以离开本页(刷新 / 关页都不影响),跑完会自动归档到「实验对比」。
全市场多年区间通常 3~5 分钟;阶段与已用时间来自后端真实状态,不是假进度条。
</div>
) : null}
{state.phase === "failed" || state.phase === "timeout" ? (
<div className="job-progress__err" role="alert">
{state.error || "任务失败,后端没有给出原因。"}
</div>
) : null}
{state.phase === "cancelled" ? (
<div className="hint" style={{ marginTop: 6 }}>
任务已取消,没有归档(取消的任务不产生实验记录)。可以改完参数再跑一次。
</div>
) : null}
{state.phase === "success" ? (
<div className="hint" style={{ marginTop: 6 }}>
{state.experimentId
? `已归档为实验 ${state.experimentId},可在「实验对比」里与其它版本叠曲线、比参数。`
: "已完成(本次没有归档,可能是同步任务)。"}
</div>
) : null}
</div>
);
}