"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({ state, onCancel, onDismiss, /** 完成后是否自动滚到视野(提交时恒滚,完成后不打断阅读) */ anchorId = "job-progress", /** 作业类型:决定阶段小圆点画哪几条 —— 因子测试没有「逐择股日选股 / 撮合与净值结算」, * 选股也只有一个真实阶段,全站共用一张表会把这些不存在的阶段画成「○ 待开始」。 */ kind = "backtest", }: { state: JobRunState; onCancel?: () => void; onDismiss?: () => void; anchorId?: string; kind?: JobKind; }) { const ref = useRef(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 (
{active ? ( {state.phase === "submitting" ? "正在提交作业…" : stageText} ) : state.phase === "success" ? ( 已完成 ) : state.phase === "cancelled" ? ( 已取消 ) : ( {state.phase === "timeout" ? "等待超时" : "执行失败"} )} {state.label || "研究任务"} {state.progress ? ( {" "} (第 {state.progress.index}/{state.progress.total} 个) ) : null} {state.jobId ? ( <> 作业 {state.jobId} ) : ( 正在换取作业号… )} {state.startedAt ? 发起于 {fmtClock(state.startedAt)} : null} {active ? 已用 {fmtElapsed(state.elapsedMs)} : null} {active && onCancel && state.jobId ? ( 取消任务 ) : null} {!active && archiveHref ? ( 去对比 ) : null} {!active && state.experimentId ? ( 打开归档 ) : null} {!active && onDismiss ? ( 收起 ) : null}
{active ? (
{pipeline.map((st) => { const mine = pipeline.indexOf(st); const cls = cur >= 0 && mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage"; return ( {cur >= 0 && mine < cur ? "✓" : mine === cur ? "●" : "○"} {STAGE_LABEL[st] ?? st} ); })}
) : null} {active ? (
后台执行中:可以离开本页(刷新 / 关页都不影响),跑完会自动归档到「实验对比」。 全市场多年区间通常 3~5 分钟;阶段与已用时间来自后端真实状态,不是假进度条。
) : null} {state.phase === "failed" || state.phase === "timeout" ? (
{state.error || "任务失败,后端没有给出原因。"}
) : null} {state.phase === "cancelled" ? (
任务已取消,没有归档(取消的任务不产生实验记录)。可以改完参数再跑一次。
) : null} {state.phase === "success" ? (
{state.experimentId ? `已归档为实验 ${state.experimentId},可在「实验对比」里与其它版本叠曲线、比参数。` : "已完成(本次没有归档,可能是同步任务)。"}
) : null}
); }