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
+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);
}