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:
+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