feat(data): 数据页同步任务支持手动停止 (二次确认)

- 同步运行中顶栏出现「停止」按钮, 点击先弹二次确认: 注明协作式停止
  (当前分块完成后中断, 不损坏已写入数据) + 停止后再次拉取需重新走
  完整管道 (拉取→指标计算→监控规则), 无法从停止处续跑
- api.ts 新增 pipelineJobCancel, 调用既有 POST /api/pipeline/jobs/{id}/cancel
  (取消标志 + 分块回调检查 + JobCancelledError 协作式退出, 后端零改动)
- 补 cancel 端点契约测试: running/pending 可停、终态 400、未知 404、
  停止后可立即新建任务
This commit is contained in:
shy3130
2026-09-01 22:42:59 +08:00
parent 84725cd362
commit 576850d0fa
3 changed files with 124 additions and 1 deletions
@@ -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
+3
View File
@@ -2525,6 +2525,9 @@ export const api = {
'/api/pipeline/run', { method: 'POST' },
),
pipelineJob: (id: string) => request<PipelineJob>(`/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}`,
+80 -1
View File
@@ -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) ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Play className="h-3.5 w-3.5" />
)}
{isStarting ? '启动中…' : isRunning ? '同步中…' : '立即同步'}
</button>
{isRunning && !!activeJobId && (
<button
onClick={() => setShowStopConfirm(true)}
title="停止当前同步任务"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-danger/12 border border-danger/30 text-danger text-xs font-medium hover:bg-danger/20 transition-all duration-150"
>
<Square className="h-3 w-3 fill-current" />
</button>
)}
<button
onClick={() => setOpenSettings('pipeline-scope')}
className="inline-flex items-center gap-1 px-2 py-1 rounded-btn text-secondary hover:text-accent hover:bg-accent/8 text-xs transition-colors duration-150"
@@ -1155,6 +1176,64 @@ export function Data() {
)}
</AnimatePresence>
{/* 停止同步二次确认弹窗 */}
<AnimatePresence>
{showStopConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => !stopSync.isPending && setShowStopConfirm(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="relative w-[90vw] max-w-[420px] rounded-card border border-border bg-base shadow-2xl p-6"
>
<div className="flex items-start gap-3">
<div className="shrink-0 h-10 w-10 rounded-full bg-danger/12 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground mb-1.5"></h3>
<p className="text-xs text-secondary leading-relaxed">
<span className="text-foreground font-medium"></span>
</p>
<p className="mt-2 text-[11px] text-danger/90 leading-relaxed">
</p>
<div className="mt-2 flex items-start gap-1.5 text-[11px] text-muted">
<Info className="h-3.5 w-3.5 shrink-0 mt-px text-muted" />
<span></span>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-5">
<button
onClick={() => setShowStopConfirm(false)}
disabled={stopSync.isPending}
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors disabled:opacity-50"
>
</button>
<button
onClick={() => stopSync.mutate()}
disabled={stopSync.isPending}
className="px-3 py-1.5 rounded-btn bg-danger/90 text-base text-sm font-medium hover:bg-danger disabled:opacity-50 transition-colors"
>
{stopSync.isPending ? '停止中…' : '确认停止'}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* 清除数据二次确认弹窗 */}
<AnimatePresence>
{adjGate.dialog}