diff --git a/src/api/server.ts b/src/api/server.ts index 2e584d2..294ba36 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -203,6 +203,18 @@ export function buildServer(opts: ApiOptions): FastifyInstance { return store.transition(id, b.to as TaskStatus, b.meta ?? {}); }); + // 取消任务(transition → cancelled;状态机已守卫合法流转) + app.post('/api/tasks/:id/cancel', (req) => { + const { id } = req.params as { id: string }; + return store.transition(id, 'cancelled', { by: 'user' }); + }); + + // 彻底删除任务及其子孙(不可恢复,cascade) + app.delete('/api/tasks/:id', (req) => { + const { id } = req.params as { id: string }; + return store.deleteTask(id); + }); + // 审批闸:accept / reject(reject 必带 reason) // exec 闸的 accept = 通过并合并(PR 闭环):先 merge 再 decide;merge 失败 → 400,任务保留在审核闸。 app.post('/api/tasks/:id/decide', async (req) => { diff --git a/src/store/store.ts b/src/store/store.ts index 0f74ccb..0665354 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -552,6 +552,43 @@ export class Store { return rows.map(rowToEvent).reverse(); } + /** + * 彻底删除任务及其全部子孙(runs/approvals 由 FK CASCADE 自动清除,events 手动清理)。 + * 不可恢复——调用方须在上层做二次确认。 + */ + deleteTask(taskId: string): { deleted: number } { + const root = this.getTaskRow(taskId); + if (!root) throw new StoreError(`任务不存在: ${taskId}`); + + // BFS 收集所有子孙 id(用于清理 events,其他表有 FK ON DELETE CASCADE) + const toDelete: string[] = []; + const queue: string[] = [taskId]; + while (queue.length) { + const tid = queue.shift()!; + toDelete.push(tid); + const kids = this.db.prepare( + `SELECT id FROM tasks WHERE parent_id = ?`, + ).all(tid) as Array<{ id: string }>; + for (const k of kids) queue.push(k.id); + } + + const projectId = root.project_id; + const txn = this.db.transaction(() => { + // events 没有 FK,逐条清理 + for (const tid of toDelete) { + this.db.prepare(`DELETE FROM events WHERE task_id = ?`).run(tid); + } + // 删根节点:子孙、runs、approvals 均 ON DELETE CASCADE + this.db.prepare(`DELETE FROM tasks WHERE id = ?`).run(taskId); + }); + txn(); + + this.emit(projectId, null, 'task.updated', { + kind: 'delete', taskId, count: toDelete.length, + }); + return { deleted: toDelete.length }; + } + /** * 取下一个可执行任务(叶子、ready、依赖全部 done)。供编排器领取。 * 被拆解的 Hard 容器任务不会是 ready(停在 decomposed),天然排除。 diff --git a/test/store.test.ts b/test/store.test.ts index 3749a36..2bb2950 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -189,6 +189,56 @@ test('createTask:deps 引用不存在/跨项目任务被拒绝', () => { s.close(); }); +test('deleteTask:删除叶子任务,不影响同项目其他任务', () => { + const s = freshStore(); + const p = s.createProject({ name: 'del1', repoPath: '/tmp/del1-' + Math.random() }); + const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' }); + const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy' }); + const { deleted } = s.deleteTask(a.id); + assert.equal(deleted, 1); + assert.equal(s.getTask(a.id), null); // 已删 + assert.ok(s.getTask(b.id)); // 不受影响 + s.close(); +}); + +test('deleteTask:级联删除带子任务的父任务', () => { + const s = freshStore(); + const p = s.createProject({ name: 'del2', repoPath: '/tmp/del2-' + Math.random() }); + const root = s.createTask({ projectId: p.id, title: 'root', complexity: 'hard' }); + s.setPlan(root.id, '拆'); s.transition(root.id, 'plan_review'); s.decide(root.id, 'accept', 'user'); + const c1 = s.createTask({ projectId: p.id, parentId: root.id, title: 'c1', complexity: 'easy' }); + const c2 = s.createTask({ projectId: p.id, parentId: root.id, title: 'c2', complexity: 'easy' }); + // c1 下再建孙子 + const gc = s.createTask({ projectId: p.id, parentId: c1.id, title: 'gc', complexity: 'easy' }); + const { deleted } = s.deleteTask(root.id); + assert.equal(deleted, 4); // root + c1 + c2 + gc + assert.equal(s.getTask(root.id), null); + assert.equal(s.getTask(c1.id), null); + assert.equal(s.getTask(c2.id), null); + assert.equal(s.getTask(gc.id), null); + s.close(); +}); + +test('deleteTask:不存在的任务抛 StoreError', () => { + const s = freshStore(); + assert.throws(() => s.deleteTask('tsk_nonexistent'), (e: Error) => e.message.includes('任务不存在')); + s.close(); +}); + +test('transition → cancelled:合法的状态流转(init/ready/executing 等)', () => { + const s = freshStore(); + const p = s.createProject({ name: 'can', repoPath: '/tmp/can-' + Math.random() }); + + const t1 = s.createTask({ projectId: p.id, title: 'init task', complexity: 'easy' }); + assert.equal(t1.status, 'ready'); + const cancelled = s.transition(t1.id, 'cancelled', { by: 'user' }); + assert.equal(cancelled.status, 'cancelled'); + + // 已取消的任务不可再次流转 + assert.throws(() => s.transition(t1.id, 'ready'), (e: Error) => e.message.includes('非法状态流转')); + s.close(); +}); + test('容器收口:已拆解 Hard 的子任务全 done → 容器自动 done(逐级向上)', () => { const s = freshStore(); const p = s.createProject({ name: 'cc', repoPath: '/tmp/cc-' + Math.random() }); diff --git a/web/app.js b/web/app.js index 733f9d5..7cb7dde 100644 --- a/web/app.js +++ b/web/app.js @@ -65,6 +65,8 @@ const S = { previewId: null, // 全局预览中的任务 id previewReject: false, // 预览层内驳回意见框是否展开 archive: { page: 1, size: 20 }, // 归档区分页 + confirmCancel: null, // 取消确认中的任务 id + confirmDelete: null, // 删除确认中的任务 id }; const $ = (sel) => document.querySelector(sel); @@ -628,6 +630,10 @@ function renderArchiveModal(t, runs, events) {
${attrs}${docs}${runRows}${resultBlock}${approvalRows}${timeline}
+
+ + +
`; } @@ -858,6 +864,8 @@ function renderTree() { P${t.priority} ${cplxBadge(t.complexity, t.id)} ${statusChip(t.status)} + ${!ARCHIVED.has(t.status) ? `` : ''} + ${expanded ? renderDetail(t) : ''} ${kids.length && !collapsed ? `
${kids.map(renderNode).join('')}
` : ''} @@ -940,6 +948,35 @@ function renderDetail(t) { parts.push(`
id ${esc(t.id)} · 深度 ${t.depth} · 创建 ${fmtTime(t.createdAt)} · 更新 ${fmtTime(t.updatedAt)}
`); + // ── 取消 / 删除操作区 ── + const m = childrenMap(S.tasks); + const childCount = (m.get(t.id) || []).length; + const cancelSection = !ARCHIVED.has(t.status) ? (() => { + if (S.confirmCancel === t.id) { + return `
+ 确定取消任务「${esc(t.title)}」?(将进入归档,子任务保持原状) + + +
`; + } + return ``; + })() : ''; + const deleteSection = (() => { + if (S.confirmDelete === t.id) { + const cascadeNote = childCount ? `(含 ${childCount} 个直接子任务,及其全部子孙)` : ''; + return `
+ 彻底删除「${esc(t.title)}」${esc(cascadeNote)}?此操作不可恢复! + + +
`; + } + const cascadeHint = childCount ? `(含 ${childCount} 子任务)` : ''; + return ``; + })(); + if (cancelSection || deleteSection) { + parts.push(`
${cancelSection}${deleteSection}
`); + } + return `
${parts.join('')}
`; } @@ -1020,7 +1057,7 @@ document.addEventListener('click', (ev) => { if (S.currentProjectId !== id) { S.currentProjectId = id; S.expanded.clear(); S.collapsed.clear(); S.rejectOpen.clear(); - S.cplxMenuFor = null; + S.cplxMenuFor = null; S.confirmCancel = null; S.confirmDelete = null; $('#configPanel').hidden = true; refresh(); } @@ -1050,7 +1087,14 @@ document.addEventListener('click', (ev) => { case 'toggle-detail': { // 点的是 caret 时由上面分支处理(stopPropagation);输入区内点击不折叠 if (ev.target.closest('.task-detail')) break; - S.expanded.has(id) ? S.expanded.delete(id) : S.expanded.add(id); + if (S.expanded.has(id)) { + S.expanded.delete(id); + // 折叠时清除行内确认状态 + if (S.confirmCancel === id) S.confirmCancel = null; + if (S.confirmDelete === id) S.confirmDelete = null; + } else { + S.expanded.add(id); + } renderTree(); break; } @@ -1134,6 +1178,24 @@ document.addEventListener('click', (ev) => { $('#archiveModalRoot').innerHTML = ''; break; + case 'archive-delete': { + const t = S.tasks.find((x) => x.id === id); + const m = childrenMap(S.tasks); + let descCount = 0; + const countDesc = (tid) => { for (const c of m.get(tid) || []) { descCount++; countDesc(c.id); } }; + countDesc(id); + const title = t ? t.title : id; + const hint = descCount ? `及其 ${descCount} 个子孙任务` : ''; + if (!confirm(`彻底删除「${title}」${hint}?\n此操作不可恢复,所有相关数据将一并删除。`)) break; + $('#archiveModalRoot').hidden = true; + $('#archiveModalRoot').innerHTML = ''; + act(async () => { + await api(`/api/tasks/${id}`, { method: 'DELETE' }); + S.expanded.delete(id); + }, `归档任务已删除${descCount ? `(含 ${descCount} 子孙)` : ''}`); + break; + } + case 'archive-size': S.archive.size = Number(el.dataset.size) || 20; S.archive.page = 1; @@ -1235,6 +1297,86 @@ document.addEventListener('click', (ev) => { break; } + // ── 任务行:取消(带确认) ── + case 'row-cancel': { + ev.stopPropagation(); + const t = S.tasks.find((x) => x.id === id); + if (!t) break; + const m = childrenMap(S.tasks); + const childCount = (m.get(id) || []).length; + const hint = childCount ? `(含 ${childCount} 个直接子任务,子任务保持原状)` : ''; + if (!confirm(`确定取消任务「${t.title}」${hint}?\n取消后任务进入归档,不影响子任务执行。`)) break; + act(() => api(`/api/tasks/${id}/cancel`, { method: 'POST', body: '{}' }), '任务已取消'); + break; + } + + // ── 任务行:删除(带级联确认) ── + case 'row-delete': { + ev.stopPropagation(); + const t = S.tasks.find((x) => x.id === id); + const m = childrenMap(S.tasks); + // 递归统计子孙数 + let descCount = 0; + const countDesc = (tid) => { for (const c of m.get(tid) || []) { descCount++; countDesc(c.id); } }; + countDesc(id); + const title = t ? t.title : id; + const hint = descCount ? `及其 ${descCount} 个子孙任务` : ''; + if (!confirm(`彻底删除「${title}」${hint}?\n此操作不可恢复,所有相关数据(执行历史、审批记录)将一并删除。`)) break; + act(async () => { + await api(`/api/tasks/${id}`, { method: 'DELETE' }); + S.expanded.delete(id); + S.collapsed.delete(id); + }, `任务已删除${descCount ? `(含 ${descCount} 子孙)` : ''}`); + break; + } + + // ── 详情:取消 — 显示行内确认 ── + case 'task-cancel': { + ev.stopPropagation(); + S.confirmCancel = id; + S.confirmDelete = null; + renderTree(); + break; + } + case 'task-cancel-abort': { + ev.stopPropagation(); + S.confirmCancel = null; + renderTree(); + break; + } + case 'task-cancel-confirm': { + ev.stopPropagation(); + S.confirmCancel = null; + act(() => api(`/api/tasks/${id}/cancel`, { method: 'POST', body: '{}' }), '任务已取消'); + break; + } + + // ── 详情:删除 — 显示行内确认 ── + case 'task-delete': { + ev.stopPropagation(); + S.confirmDelete = id; + S.confirmCancel = null; + renderTree(); + break; + } + case 'task-delete-abort': { + ev.stopPropagation(); + S.confirmDelete = null; + renderTree(); + break; + } + case 'task-delete-confirm': { + ev.stopPropagation(); + const delId = id; + S.confirmDelete = null; + act(async () => { + await api(`/api/tasks/${delId}`, { method: 'DELETE' }); + S.expanded.delete(delId); + S.collapsed.delete(delId); + }, '任务已删除'); + break; + } + } }); diff --git a/web/style.css b/web/style.css index 871d497..c23a523 100644 --- a/web/style.css +++ b/web/style.css @@ -608,7 +608,8 @@ li.ev-updated { --ev: var(--muted); } max-height: none; overflow-y: visible; /* 解除闸卡片 pre.doc 的 280px 限高,由 .preview-body 统一滚动 */ } .preview-body .gate-doc-label { margin-top: 14px; } -.preview-foot { +.preview-foot, +.archive-modal-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 12px 16px; border-top: 1px solid var(--line); background: var(--panel-2); @@ -770,3 +771,37 @@ li.ev-updated { --ev: var(--muted); } /* 配置面板内的同步行 */ .cfg-sync-row { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--line-soft); align-items: center; gap: 10px; } + +/* ── 任务行:取消 / 删除快捷按钮(默认隐藏,鼠标悬停行时显示) ── */ +.task-row-cancel, +.task-row-delete { + flex: none; opacity: 0; transition: opacity .15s; + pointer-events: none; +} +.task-row:hover .task-row-cancel, +.task-row:hover .task-row-delete { + opacity: 1; pointer-events: auto; +} +.task-row-cancel { color: var(--red); } +.task-row-delete { color: var(--faint); } +.task-row-delete:hover { color: var(--red) !important; } + +/* ── 任务详情:取消 / 删除操作区 ── */ +.detail-actions { + display: flex; align-items: flex-start; gap: 10px; + flex-wrap: wrap; + padding-top: 6px; + border-top: 1px dashed var(--line-soft); +} +.detail-action-confirm { + display: flex; align-items: center; flex-wrap: wrap; gap: 8px; + padding: 6px 10px; + background: rgba(255,93,93,.06); + border: 1px solid var(--red-dim); +} +.detail-action-confirm-del { + background: rgba(255,93,93,.09); +} +.detail-confirm-msg { + font-size: 12px; color: var(--ink); +}