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:
@@ -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) => {
|
||||
|
||||
@@ -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),天然排除。
|
||||
|
||||
@@ -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() });
|
||||
|
||||
+144
-2
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user