feat(tsk_PbcHccuPmwxV): 任务取消/删除 UI 入口(带确认)

## 后端
- Store.deleteTask():BFS 收集子孙 id,清理 events,删根节点(子孙/runs/approvals 由 FK CASCADE 自动清除),广播 task.updated{kind:'delete'}
- API POST /api/tasks/:id/cancel:transition → cancelled(复用状态机守卫)
- API DELETE /api/tasks/:id:级联删除任务及子孙

## 前端
- 任务行(task-row):鼠标悬停后右侧出现「✕取消」「🗑删除」浮动按钮,点击弹 confirm() 二次确认(含子孙数提示),stopPropagation 防止触发行展开
- 任务详情(detail):底部行内确认区——「取消任务」显示确认条(确认取消 / 算了),「删除」显示含级联提示的高亮确认条,折叠/切换项目时自动清除确认状态
- 归档详情对话框:底部增加「🗑 删除归档」按钮,confirm() 确认后删除并关闭弹窗
- CSS:.task-row-cancel/.task-row-delete(悬停淡入)、.detail-actions / .detail-action-confirm / .detail-confirm-msg、.archive-modal-actions

## 测试
- 新增 4 个 store 测试:deleteTask 叶子、deleteTask 级联、deleteTask 不存在、transition→cancelled
- 全部 78 条 pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 03:37:01 +08:00
parent 740d2c2637
commit b60225779c
5 changed files with 279 additions and 3 deletions
+12
View File
@@ -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 / rejectreject 必带 reason
// exec 闸的 accept = 通过并合并(PR 闭环):先 merge 再 decidemerge 失败 → 400,任务保留在审核闸。
app.post('/api/tasks/:id/decide', async (req) => {
+37
View File
@@ -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),天然排除。
+50
View File
@@ -189,6 +189,56 @@ test('createTaskdeps 引用不存在/跨项目任务被拒绝', () => {
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() });
+144 -2
View File
@@ -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) {
<div class="preview-body">
${attrs}${docs}${runRows}${resultBlock}${approvalRows}${timeline}
</div>
<div class="preview-foot archive-modal-actions">
<button class="btn btn-xs btn-reject" data-action="archive-delete" data-id="${esc(t.id)}" title="彻底删除(不可恢复)">🗑 删除归档</button>
<button class="btn btn-ghost" data-action="archive-modal-close">关闭</button>
</div>
</div>`;
}
@@ -858,6 +864,8 @@ function renderTree() {
<span class="t-prio ${t.priority === 0 ? 'hot' : ''}">P${t.priority}</span>
${cplxBadge(t.complexity, t.id)}
${statusChip(t.status)}
${!ARCHIVED.has(t.status) ? `<button class="btn btn-ghost btn-xs task-row-cancel" data-action="row-cancel" data-id="${esc(t.id)}" title="取消任务(进入归档)">✕</button>` : ''}
<button class="btn btn-ghost btn-xs task-row-delete" data-action="row-delete" data-id="${esc(t.id)}" title="彻底删除任务(不可恢复)">🗑</button>
</div>
${expanded ? renderDetail(t) : ''}
${kids.length && !collapsed ? `<div class="task-children">${kids.map(renderNode).join('')}</div>` : ''}
@@ -940,6 +948,35 @@ function renderDetail(t) {
parts.push(`<div class="proj-meta">id ${esc(t.id)} · 深度 ${t.depth} · 创建 ${fmtTime(t.createdAt)} · 更新 ${fmtTime(t.updatedAt)}</div>`);
// ── 取消 / 删除操作区 ──
const m = childrenMap(S.tasks);
const childCount = (m.get(t.id) || []).length;
const cancelSection = !ARCHIVED.has(t.status) ? (() => {
if (S.confirmCancel === t.id) {
return `<div class="detail-action-confirm">
<span class="detail-confirm-msg">确定取消任务「${esc(t.title)}」?(将进入归档,子任务保持原状)</span>
<button class="btn btn-xs btn-reject" data-action="task-cancel-confirm" data-id="${esc(t.id)}">确认取消</button>
<button class="btn btn-xs" data-action="task-cancel-abort" data-id="${esc(t.id)}">算了</button>
</div>`;
}
return `<button class="btn btn-xs btn-reject" data-action="task-cancel" data-id="${esc(t.id)}" title="取消任务(进入 cancelled 状态,计入归档)">✕ 取消任务</button>`;
})() : '';
const deleteSection = (() => {
if (S.confirmDelete === t.id) {
const cascadeNote = childCount ? `(含 ${childCount} 个直接子任务,及其全部子孙)` : '';
return `<div class="detail-action-confirm detail-action-confirm-del">
<span class="detail-confirm-msg">彻底删除「${esc(t.title)}${esc(cascadeNote)}?此操作不可恢复!</span>
<button class="btn btn-xs btn-reject" data-action="task-delete-confirm" data-id="${esc(t.id)}">确认删除</button>
<button class="btn btn-xs" data-action="task-delete-abort" data-id="${esc(t.id)}">算了</button>
</div>`;
}
const cascadeHint = childCount ? `(含 ${childCount} 子任务)` : '';
return `<button class="btn btn-xs" data-action="task-delete" data-id="${esc(t.id)}" title="彻底删除任务(不可恢复)">🗑 删除${cascadeHint}</button>`;
})();
if (cancelSection || deleteSection) {
parts.push(`<div class="detail-actions">${cancelSection}${deleteSection}</div>`);
}
return `<div class="task-detail"><div class="detail-grid">${parts.join('')}</div></div>`;
}
@@ -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;
}
}
});
+36 -1
View File
@@ -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);
}