Files
Simon e58367af27 feat(web): 因子研究/因子组合/股票筛选接入统一作业反馈;阶段圆点按作业类型区分
接着 /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,复查无残留)。
2026-10-01 18:37:25 +08:00

222 lines
8.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}