接着 /backtest 的那次改造,把其余会跑异步 Job 的页面也切到同一套
`useJobRunner` + `JobProgress`(用户原话:包括因子测试等所有测试都帮我完善用户反馈):
- **/factors**(一个因子一个 Job 的批量场景):删掉 `running/processed/current/jobId` 与
按「已处理数 ÷ 总数」自算的 `Progress` **假百分比**;每轮把 `第 i/共 n 个` 交给反馈条,
阶段/作业号/已用秒数全部来自后端。取消 = 用户明确意图 → 保留已出的报告、不再跑后续因子,
不报错;单个因子失败仍继续跑其余因子,并把后端原文累积成**批级清单**(多因子时反馈条
只能显示最后一个作业,前几个失败不能丢)。
- **/factors/compose**:删掉 `value={30}` 的假进度条;`archiveId` / 复用 `BacktestResultView` /
「新页面放大」全部保留;failed/cancelled 交给反馈条,页面 error 只留参数与目录错误
(同一失败不在两处各说一遍)。
- **/selection**:异步选股切反馈条;**同步**的「执行选股」保留原 loading(`POST /selections`
没有 job_id,套上会去 `GET /jobs/{signal}` 撞 404)。
- **/signals**:`POST /api/signals` 是同步接口,**不套**作业反馈(不编作业号、不编阶段),
改为点击即现的 `role="status"` 提示,如实写明「同步请求、请求期间不能关页、无阶段无取消、
出错显示后端原文」。
- **阶段圆点按作业类型区分**(修掉一个真实缺陷):原来全站共用一张含 `queued/done` 的
`STAGE_ORDER`,因子测试页实测出现过**裸英文** `factor_calculation` 且 4 个圆点全灰
(`indexOf` = -1),还画出了因子测试根本不存在的「逐择股日选股 / 撮合与净值结算」。
现在 `STAGE_PIPELINES = { factor_test: [加载→计算因子值→汇总], backtest: [加载→撮合→汇总],
selection: [逐择股日选股] }`(阶段序列**不含 queued/done**,那是作业状态不是阶段),
未知阶段显示「执行中(stage)」并保留已推进的圆点,不整排灰、不露裸枚举。
验证(真实浏览器 CDP,读数原文已记录):
- /factors:点击后 0.2s 内 `submitting`→`queued` + 作业号;+30s Pill「计算因子值」、
圆点 `✓ 加载行情与因子数据 / ● 计算因子值 / ○ 汇总指标与曲线`(无「选股/撮合」、无英文枚举);
成功态给「去对比 / 打开归档」。
- /selection:圆点只有 `逐择股日选股` 一段;取消 → 「已取消,没有归档」;
另实测撞并发上限时如实显示后端原文「系统繁忙:并发研究任务已达上限」。
- /factors/compose:`submitting→queued→撮合与净值结算→success(EXP-…)`,业务结果与 5 处放大入口照旧。
- /backtest 回归:圆点由 4 个变 3 个(去掉后端**从不上报**的 selection 阶段),取消仍「已取消 + 没归档」。
- 自检产生的 12 个实验归档已全部删除(bulk-delete count:12,复查无残留)。
222 lines
8.5 KiB
TypeScript
222 lines
8.5 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* 作业反馈条(全站统一)。
|
||
*
|
||
* 解决的问题(用户反馈原话):「点了回测没有任何反馈,不清楚是不是已经开始回测」。
|
||
* 因此这里有三条硬要求:
|
||
* 1. **点了立刻有字**:`submitting` 阶段就显示「正在提交作业…」,不等到后端返回;
|
||
* 2. **看得出在动**:作业号 + 真实阶段 + **每秒自增**的已用时间(阶段没变也在走);
|
||
* 3. **出事了能自救**:排队中可「取消」,失败显示后端原文,成功给「打开归档 / 去对比」。
|
||
*
|
||
* 反馈条出现时会自动滚到视野中央 —— 按钮常在长表单底部,反馈条在页面顶部,
|
||
* 不滚一下用户就看不见(这正是「没有反馈」错觉的来源之一)。
|
||
*/
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
import Link from "next/link";
|
||
|
||
import { Btn, Pill } from "@/components/ui";
|
||
import { Icon } from "@/components/icons";
|
||
import { STAGE_LABEL, STAGE_PIPELINES, type JobKind, 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",
|
||
/** 作业类型:决定阶段小圆点画哪几条 —— 因子测试没有「逐择股日选股 / 撮合与净值结算」,
|
||
* 选股也只有一个真实阶段,全站共用一张表会把这些不存在的阶段画成「○ 待开始」。 */
|
||
kind = "backtest",
|
||
}: {
|
||
state: JobRunState<T>;
|
||
onCancel?: () => void;
|
||
onDismiss?: () => void;
|
||
anchorId?: string;
|
||
kind?: JobKind;
|
||
}) {
|
||
const ref = useRef<HTMLDivElement | null>(null);
|
||
const scrolled = useRef(false);
|
||
const active = state.phase === "submitting" || state.phase === "queued" || state.phase === "running";
|
||
/** 本类作业真实会走的阶段(不含 queued / done —— 那是作业状态,不是后端阶段) */
|
||
const pipeline = STAGE_PIPELINES[kind];
|
||
/**
|
||
* 后端阶段是前端还不认识的枚举时(后端先加、前端文案没跟上),
|
||
* 保留**上一个已知阶段**的进度而不是把整排圆点打成灰(那看起来像「什么都没在跑」);
|
||
* 原始枚举另在 Pill 上兜底显示(如「执行中(factor_calculation)」),不出现裸英文。
|
||
*/
|
||
const [lastKnown, setLastKnown] = useState("");
|
||
useEffect(() => {
|
||
// 新一轮提交(submitting)先清空:否则同一页第二次运行时,会拿上一轮的最后阶段当进度,
|
||
// 新作业明明刚排队却显示「已完成到分析的 ✓」——那是假的进度。
|
||
if (state.phase === "submitting") {
|
||
setLastKnown("");
|
||
return;
|
||
}
|
||
if (pipeline.includes(state.stage)) setLastKnown(state.stage);
|
||
}, [state.phase, state.stage, pipeline]);
|
||
|
||
// 提交瞬间(含同一页面第二次提交)把反馈条滚进视野:按钮在长表单底部时,
|
||
// 顶部出现的反馈条不滚过去就等于没显示。
|
||
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 = pipeline.indexOf(pipeline.includes(state.stage) ? state.stage : lastKnown);
|
||
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 }}>
|
||
{pipeline.map((st) => {
|
||
const mine = pipeline.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>
|
||
);
|
||
} |