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,复查无残留)。
This commit is contained in:
Simon
2026-10-01 18:37:25 +08:00
parent c974415691
commit e58367af27
8 changed files with 228 additions and 134 deletions
+27 -6
View File
@@ -13,12 +13,12 @@
* 不滚一下用户就看不见(这正是「没有反馈」错觉的来源之一)。
*/
import { useEffect, useRef } from "react";
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_ORDER, type JobRunState } from "@/lib/jobs";
import { STAGE_LABEL, STAGE_PIPELINES, type JobKind, type JobRunState } from "@/lib/jobs";
/** 已用时间:< 60s 显示秒,否则 mm:ss(跑几分钟时更好读) */
function fmtElapsed(ms: number): string {
@@ -38,15 +38,36 @@ export function JobProgress<T>({
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]);
// 提交瞬间(含同一页面第二次提交)把反馈条滚进视野:按钮在长表单底部时,
// 顶部出现的反馈条不滚过去就等于没显示。
@@ -66,9 +87,9 @@ export function JobProgress<T>({
const stageText = state.stage
? state.stage === "cancelling"
? "正在取消…"
: (STAGE_LABEL[state.stage] ?? state.stage)
: (STAGE_LABEL[state.stage] ?? `执行中(${state.stage})`)
: "等待后端回报阶段";
const cur = STAGE_ORDER.indexOf(state.stage);
const cur = pipeline.indexOf(pipeline.includes(state.stage) ? state.stage : lastKnown);
const archiveHref = state.experimentId
? `/experiments?exp=${encodeURIComponent(state.experimentId)}`
: null;
@@ -158,8 +179,8 @@ export function JobProgress<T>({
{active ? (
<div className="stages" style={{ marginTop: 8 }}>
{["data_loading", "selection", "backtesting", "analysis"].map((st) => {
const mine = STAGE_ORDER.indexOf(st);
{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}>