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