From e8fa67ad40ff28c9530328fc162bd45564e45422 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 1 Oct 2026 18:08:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=9B=9E=E6=B5=8B=E9=A1=B5?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=BD=9C=E4=B8=9A=E5=8F=8D=E9=A6=88=E6=9D=A1?= =?UTF-8?q?=EF=BC=88=E7=82=B9=E4=BA=86=E7=AB=8B=E5=88=BB=E6=9C=89=E5=AD=97?= =?UTF-8?q?=20/=20=E7=9C=9F=E5=AE=9E=E9=98=B6=E6=AE=B5=20/=20=E5=8F=AF?= =?UTF-8?q?=E5=8F=96=E6=B6=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈:「点击回测没有任何反馈,不清楚是不是已经开始回测」。 - 新增全站统一的 `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 残留。 --- frontend/web/app/backtest/page.tsx | 81 +++++---- frontend/web/components/JobProgress.tsx | 201 ++++++++++++++++++++++ frontend/web/lib/jobs.ts | 216 ++++++++++++++++++++++++ frontend/web/lib/types.ts | 1 + 4 files changed, 465 insertions(+), 34 deletions(-) create mode 100644 frontend/web/components/JobProgress.tsx diff --git a/frontend/web/app/backtest/page.tsx b/frontend/web/app/backtest/page.tsx index 2673040..8c1edb9 100644 --- a/frontend/web/app/backtest/page.tsx +++ b/frontend/web/app/backtest/page.tsx @@ -18,11 +18,12 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "rea import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api"; -import { STAGE_LABEL, runSavedCombo, submitComboJob, waitJob } from "@/lib/jobs"; +import { runSavedCombo, submitComboJob, useJobRunner } from "@/lib/jobs"; import { recentRange } from "@/lib/dates"; import type { BacktestCombo, BacktestResult, GlobalConfig, SelectionStrategy } from "@/lib/types"; import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading, Field, Progress, BacktestMetrics } from "@/components/ui"; import { BacktestResultView } from "@/components/BacktestResultView"; +import { JobProgress } from "@/components/JobProgress"; import { adjustLabel } from "@/lib/labels"; export default function BacktestPage() { @@ -61,9 +62,18 @@ function emptyDraft(range: { start: string; end: string }): Draft { }; } -function draftToCombo(d: Draft): BacktestCombo { +/** + * 草稿 → 组合对象。 + * + * `nameFallback`:**运行**(不保存)时组合名可以为空,但 `/combos/run` 的契约里 + * `name` 必填 —— 老版本直接提交空名字,用户看到的是 422 原文 + * (`String should have at least 1 character`),点了「运行」却像什么都没发生。 + * 运行是临时组合、不会落库,所以这里给一个**如实说明「未命名/仅本次运行」**的名字, + * 而不是编一个像真的组合名。要正式命名请用「保存为回测组合」。 + */ +function draftToCombo(d: Draft, nameFallback?: string): BacktestCombo { return { - name: d.name.trim(), + name: d.name.trim() || nameFallback || "", description: d.description.trim(), strategy_ids: d.strategyIds, initial_capital: d.initialCapital, @@ -132,9 +142,12 @@ function BacktestInner() { const [resultArchiveId, setResultArchiveId] = useState(null); const [running, setRunning] = useState(false); const [runningComboId, setRunningComboId] = useState(null); - const [jobId, setJobId] = useState(""); - const [stage, setStage] = useState(""); - const [elapsed, setElapsed] = useState(0); + /** + * 作业反馈统一交给 `useJobRunner`(全站同一套):提交瞬间就有「正在提交作业…」, + * 之后是真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消 —— 用户反馈的 + * 「点了回测没有任何反馈,不知道有没有开始」就是这里的缺口。 + */ + const job = useJobRunner("回测组合"); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); const [saving, setSaving] = useState(false); @@ -225,7 +238,13 @@ function BacktestInner() { }); return; } - await runVia(() => submitComboJob(draftToCombo(draft)), null); + // 未命名时用「未命名组合(仅本次运行)」:临时组合不落库,只是给作业一个可读标签; + // 不让用户在点「运行」后撞上 422(那正是「点了没反应」的观感来源之一)。 + const fallback = draft.name.trim() ? undefined : "未命名组合(仅本次运行)"; + if (fallback) { + setNotice("本次运行未命名组合名 —— 按「未命名组合(仅本次运行)」提交(不会保存到组合库)。"); + } + await runVia(() => submitComboJob(draftToCombo(draft, fallback)), null); } /** @@ -240,22 +259,24 @@ function BacktestInner() { setRunningComboId(comboId); setError(""); setNotice(""); - setJobId(""); setResult(null); setResultArchiveId(null); try { - const { job_id } = await submit(); - setJobId(job_id); - setStage("queued"); - const out = await waitJob(job_id, 900_000, (info) => { - setStage(info.stage); - setElapsed(info.elapsedMs); + const out = await job.run(submit, { + label: draft.name ? `回测组合「${draft.name}」` : "回测组合", }); if (out.status === "success" && out.result) { - setResult(out.result); + setResult(out.result as BacktestResult); const exp = out.experimentId ?? null; setResultArchiveId(exp); - setNotice(exp ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` : "回测完成,已自动归档。"); + setNotice( + exp + ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` + : "回测完成,已自动归档。", + ); + } else if (out.status === "cancelled") { + // 主动取消不是错误:如实说明,并提示可重新运行 + setNotice("已取消该回测任务(未产生结果)。可改完参数后重新运行。"); } else { setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`); } @@ -264,7 +285,6 @@ function BacktestInner() { } finally { setRunning(false); setRunningComboId(null); - setJobId(""); } } @@ -626,23 +646,16 @@ function BacktestInner() { - {running ? ( -
- {["data_loading", "backtesting", "analysis"].map((st) => { - const order = ["queued", "data_loading", "backtesting", "analysis", "done"]; - const cur = order.indexOf(stage); - const mine = order.indexOf(st); - const cls = mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage"; - return ( - - {mine < cur ? "✓" : mine === cur ? "●" : "○"} {STAGE_LABEL[st] ?? st} - - ); - })} - - 作业 {jobId || "排队中…"} · 已用 {Math.round(elapsed / 1000)}s - (全市场多年区间约 3~5 分钟,可离开本页,结果会归档) - + {/* 反馈条紧贴运行按钮:点了就有字,且在排队阶段就能取消 */} + + {job.state.phase === "idle" && running ? ( +
+ 正在提交作业…(提交成功后会显示作业号与真实阶段,可随时取消)
) : null} diff --git a/frontend/web/components/JobProgress.tsx b/frontend/web/components/JobProgress.tsx new file mode 100644 index 0000000..db9fde4 --- /dev/null +++ b/frontend/web/components/JobProgress.tsx @@ -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({ + state, + onCancel, + onDismiss, + /** 完成后是否自动滚到视野(提交时恒滚,完成后不打断阅读) */ + anchorId = "job-progress", +}: { + state: JobRunState; + onCancel?: () => void; + onDismiss?: () => void; + anchorId?: string; +}) { + const ref = useRef(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 ( +
+
+ {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 ? ( +
+ {["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 ( + + {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} +
+ ); +} \ No newline at end of file diff --git a/frontend/web/lib/jobs.ts b/frontend/web/lib/jobs.ts index 9e0a0b0..8ed4b4f 100644 --- a/frontend/web/lib/jobs.ts +++ b/frontend/web/lib/jobs.ts @@ -3,6 +3,8 @@ * 大样本研究(全市场)可能耗时数十秒到分钟级,经异步 Job 后台执行, * 避免 HTTP 长阻塞(AGENT §19)。页面提交后即时返回 job_id,再轮询到终态。 */ +import { useCallback, useEffect, useRef, useState } from "react"; + import { apiGet, apiPost } from "./api"; import type { ResearchSpec } from "./types"; @@ -75,6 +77,19 @@ export const STAGE_LABEL: Record = { done: "完成", }; +/** 执行阶段时间线(顺序即推进顺序,用于「阶段小圆点」) */ +export const STAGE_ORDER = ["queued", "data_loading", "selection", "backtesting", "analysis", "done"]; + +/** 取消排队中/执行中的作业(POST /api/jobs/{id}/cancel;已结束的会返回 cancelled=false)。 */ +export function cancelJob( + jobId: string, +): Promise<{ job_id: string; status: string; cancelled: boolean }> { + return apiPost<{ job_id: string; status: string; cancelled: boolean }>( + `/jobs/${encodeURIComponent(jobId)}/cancel`, + {}, + ); +} + /** 提交一个回测组合为异步 Job(POST /api/combos/run,不保存组合)。 */ export async function submitComboJob(combo: unknown): Promise { @@ -90,3 +105,204 @@ export async function submitComboJob(combo: unknown): Promise { export async function runSavedCombo(comboId: string): Promise { return apiPost(`/combos/${encodeURIComponent(comboId)}/run`, {}); } + +/* ------------------------------------------------------------------ * + * 统一的「提交 → 轮询 → 终态」状态机(useJobRunner) + * + * 为什么要有它:以前每个页面各写一遍 running/jobId/error,结果参差不齐 —— + * 有的页面点了「运行」只在按钮上转圈,用户不知道到底提交没提交、跑到哪一步; + * 有的页面「已用秒数」只在后端阶段变化时才更新,看起来像卡死了(0s 一动不动)。 + * 这里把状态、秒表、取消、终态收敛到一处,页面只管渲染。 + * ------------------------------------------------------------------ */ + +export type JobPhase = + | "idle" + | "submitting" + | "queued" + | "running" + | "success" + | "failed" + | "cancelled" + | "timeout"; + +export interface JobRunState { + phase: JobPhase; + /** 作业号(提交成功后立即拿到;这是「到底提交没提交」的凭据) */ + jobId: string; + /** 后端上报的执行阶段(queued/data_loading/selection/backtesting/analysis/done) */ + stage: string; + /** 已用毫秒:**每秒自增**,不依赖后端阶段变化,所以界面不会看起来卡住 */ + elapsedMs: number; + /** 这次跑的是什么(如「因子测试 · momentum_60」),显示在反馈条上 */ + label: string; + /** 批量场景的进度(第 index / total 个),单跑时为 null */ + progress: { index: number; total: number } | null; + error: string; + result: T | null; + experimentId: string | null; + /** 提交时刻(本地时间,用于「发起于 hh:mm:ss」) */ + startedAt: Date | null; + /** 正在请求取消 */ + cancelling: boolean; +} + +function emptyState(label = ""): JobRunState { + return { + phase: "idle", + jobId: "", + stage: "", + elapsedMs: 0, + label, + progress: null, + error: "", + result: null, + experimentId: null, + startedAt: null, + cancelling: false, + }; +} + +export interface JobRunOptions { + /** 反馈条上显示的任务名(如「因子测试 · momentum_60」) */ + label?: string; + /** 批量进度(第 index / total 个) */ + progress?: { index: number; total: number } | null; + timeoutMs?: number; +} + +export interface JobRunner { + state: JobRunState; + /** 提交并轮询到终态;返回终态结果(供页面继续处理,如渲染报告) */ + run: ( + submit: () => Promise<{ job_id: string }>, + opts?: JobRunOptions, + ) => Promise>; + cancel: () => Promise; + reset: () => void; +} + +/** + * 作业运行状态机。 + * + * 生命周期:idle → submitting(已点击,正在换 job_id)→ queued/running(轮询中,秒表走) + * → success | failed | cancelled | timeout。**submitting 也是状态**:点了按钮立刻就有 + * 反馈文字,不会出现「点了没反应」的空白期。 + */ +export function useJobRunner(defaultLabel = ""): JobRunner { + const [state, setState] = useState>(() => emptyState(defaultLabel)); + const t0 = useRef(0); + const timer = useRef(null); + const alive = useRef(true); + const currentJob = useRef(""); + // 后端最近上报的阶段放在 ref 里:终态那一帧要写「最后一个阶段」,但不想让 + // `run` 依赖 state(否则每次 state 变化都换一个新函数,页面 effect 会连环触发)。 + const stageRef = useRef(""); + + const stopTick = useCallback(() => { + if (timer.current !== null) { + window.clearInterval(timer.current); + timer.current = null; + } + }, []); + + useEffect( + () => () => { + alive.current = false; + if (timer.current !== null) window.clearInterval(timer.current); + }, + [], + ); + + const patch = useCallback((next: Partial>) => { + if (!alive.current) return; + setState((prev) => ({ ...prev, ...next })); + }, []); + + const reset = useCallback(() => { + stopTick(); + currentJob.current = ""; + if (alive.current) setState(emptyState(defaultLabel)); + }, [defaultLabel, stopTick]); + + const run = useCallback( + async ( + submit: () => Promise<{ job_id: string }>, + opts: JobRunOptions = {}, + ): Promise> => { + stopTick(); + currentJob.current = ""; + stageRef.current = ""; + t0.current = Date.now(); + setState({ + ...emptyState(opts.label ?? defaultLabel), + phase: "submitting", + label: opts.label ?? defaultLabel, + progress: opts.progress ?? null, + startedAt: new Date(), + }); + // 秒表独立于后端阶段:即使后端几秒内不报新阶段,「已用 Xs」也在动 + timer.current = window.setInterval(() => { + if (!alive.current) return; + setState((prev) => + prev.phase === "submitting" || prev.phase === "queued" || prev.phase === "running" + ? { ...prev, elapsedMs: Date.now() - t0.current } + : prev, + ); + }, 500); + try { + const { job_id } = await submit(); + currentJob.current = job_id; + stageRef.current = "queued"; + patch({ jobId: job_id, phase: "queued", stage: "queued" }); + const out = await waitJob(job_id, opts.timeoutMs ?? 900_000, (info) => { + stageRef.current = info.stage; + patch({ + stage: info.stage, + elapsedMs: info.elapsedMs, + phase: info.stage && info.stage !== "queued" ? "running" : "queued", + }); + }); + const finalPhase: JobPhase = + out.status === "success" + ? "success" + : out.status === "cancelled" + ? "cancelled" + : out.status === "timeout" + ? "timeout" + : "failed"; + patch({ + phase: finalPhase, + stage: out.status === "success" ? "done" : stageRef.current, + elapsedMs: Date.now() - t0.current, + result: out.result, + experimentId: out.experimentId ?? null, + error: out.error ?? "", + }); + return out; + } catch (e) { + const msg = (e as Error).message; + patch({ phase: "failed", error: msg, elapsedMs: Date.now() - t0.current }); + return { status: "failed", result: null, error: msg }; + } finally { + stopTick(); + } + }, + [defaultLabel, patch, stopTick], + ); + + const cancel = useCallback(async () => { + const jobId = currentJob.current; + if (!jobId) return; + patch({ cancelling: true }); + try { + await cancelJob(jobId); + // 取消后轮询会在 1.5s 内看到 cancelled 终态;这里先把阶段文字改掉, + // 让用户马上知道「取消请求已发出」而不是等下一个轮询周期。 + patch({ stage: "cancelling" }); + } finally { + patch({ cancelling: false }); + } + }, [patch]); + + return { state, run, cancel, reset }; +} diff --git a/frontend/web/lib/types.ts b/frontend/web/lib/types.ts index 85f03d2..2019a35 100644 --- a/frontend/web/lib/types.ts +++ b/frontend/web/lib/types.ts @@ -234,6 +234,7 @@ export const REASON_LABELS: Record = { buy_skip_min_commission: "不足最低佣金", sell_drop_topn: "跌出 TopN", sell_force_tmax: "持有超 Tmax", + sell_rebalance_full: "调仓换仓卖出", sell_defer_tmin: "Tmin 保护暂留", sell_defer_halted: "停牌未卖", sell_defer_limit_down: "跌停未卖",