From 576850d0fa204b8d451cf17c6fdbb86ee4e77013 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Tue, 1 Sep 2026 22:42:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(data):=20=E6=95=B0=E6=8D=AE=E9=A1=B5?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E4=BB=BB=E5=8A=A1=E6=94=AF=E6=8C=81=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E5=81=9C=E6=AD=A2=20(=E4=BA=8C=E6=AC=A1=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同步运行中顶栏出现「停止」按钮, 点击先弹二次确认: 注明协作式停止 (当前分块完成后中断, 不损坏已写入数据) + 停止后再次拉取需重新走 完整管道 (拉取→指标计算→监控规则), 无法从停止处续跑 - api.ts 新增 pipelineJobCancel, 调用既有 POST /api/pipeline/jobs/{id}/cancel (取消标志 + 分块回调检查 + JobCancelledError 协作式退出, 后端零改动) - 补 cancel 端点契约测试: running/pending 可停、终态 400、未知 404、 停止后可立即新建任务 --- backend/tests/test_job_stall_and_cancel.py | 41 +++++++++++ frontend/src/lib/api.ts | 3 + frontend/src/pages/Data.tsx | 81 +++++++++++++++++++++- 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_job_stall_and_cancel.py b/backend/tests/test_job_stall_and_cancel.py index af75c62..64be23b 100644 --- a/backend/tests/test_job_stall_and_cancel.py +++ b/backend/tests/test_job_stall_and_cancel.py @@ -165,3 +165,44 @@ def test_run_slot_reap_release_prevents_zombie_release(): pipeline_jobs.release_run_slot("jobB") assert pipeline_jobs.try_acquire_run_slot("jobC") is True pipeline_jobs.release_run_slot("jobC") + + +# ── 手动取消 API 端点契约 (数据页「停止」按钮) ────────────────────────── + +def test_manual_cancel_endpoint_contract(monkeypatch, tmp_path): + """POST /api/pipeline/jobs/{id}/cancel: running/pending 可停, 终态 400, 未知 404。""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from app.api.pipeline import router + + monkeypatch.setattr(preferences, "load", lambda: {}) + store = JobStore(store_dir=tmp_path / "jobs") + monkeypatch.setattr("app.api.pipeline.job_store", store) + + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + # 未知 job → 404 + assert client.post("/api/pipeline/jobs/nope/cancel").status_code == 404 + + # running → 协作式终止: 标 failed + 置取消标志 + 释放执行槽 + jid = _make_running_job(store, timeout_s=60) + pipeline_jobs.try_acquire_run_slot(jid) + resp = client.post(f"/api/pipeline/jobs/{jid}/cancel") + assert resp.status_code == 200 + assert resp.json() == {"cancelled": jid} + j = store.get(jid) + assert j["status"] == "failed" + assert "手动取消" in j["error"] + assert pipeline_jobs.is_cancelled(jid) + assert pipeline_jobs.try_acquire_run_slot("next") is True + + # 已终态 (failed) → 400 拒绝重复取消 + assert client.post(f"/api/pipeline/jobs/{jid}/cancel").status_code == 400 + + # 停止后可再建新任务 (再次拉取走完整管道的单飞基础) + jid2, is_new = store.create(timeout_s=60) + assert is_new is True + assert store.active_id() == jid2 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4b51447..5533b70 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2525,6 +2525,9 @@ export const api = { '/api/pipeline/run', { method: 'POST' }, ), pipelineJob: (id: string) => request(`/api/pipeline/jobs/${id}`), + /** 手动停止一个 running/pending 的同步任务 (协作式: 当前分块完成后线程自行退出) */ + pipelineJobCancel: (id: string) => + request<{ cancelled: string }>(`/api/pipeline/jobs/${id}/cancel`, { method: 'POST' }), pipelineJobs: (limit = 20) => request<{ active_id: string | null; jobs: PipelineJobSummary[] }>( `/api/pipeline/jobs?limit=${limit}`, diff --git a/frontend/src/pages/Data.tsx b/frontend/src/pages/Data.tsx index 99c122b..81695fe 100644 --- a/frontend/src/pages/Data.tsx +++ b/frontend/src/pages/Data.tsx @@ -4,6 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion' import { Database, Play, + Square, Loader2, HardDrive, Clock, @@ -106,6 +107,16 @@ export function Data() { }, }) + // 停止同步: 二次确认后调 cancel 端点 (协作式终止, 当前分块完成后线程自行退出) + const [showStopConfirm, setShowStopConfirm] = useState(false) + const stopSync = useMutation({ + mutationFn: () => api.pipelineJobCancel(activeJobId!), + onSuccess: () => { + setShowStopConfirm(false) + qc.invalidateQueries({ queryKey: QK.pipelineJob(activeJobId!) }) + }, + }) + // 无除权因子能力时同步前置确认 (静默降级告知) const adjGate = useAdjFactorSyncGate() @@ -589,13 +600,23 @@ export function Data() { disabled={isStarting} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-accent/25 to-accent/10 border border-accent/30 text-accent text-xs font-medium hover:from-accent/35 hover:to-accent/20 disabled:opacity-40 transition-all duration-150" > - {(isRunning || isStarting) ? ( + {(isStarting || isRunning) ? ( ) : ( )} {isStarting ? '启动中…' : isRunning ? '同步中…' : '立即同步'} + {isRunning && !!activeJobId && ( + + )} + + + + + )} + + {/* 清除数据二次确认弹窗 */} {adjGate.dialog}