feat: 支持依赖(deps)创建后编辑 (tsk_UQ7sNRM0Yi1L)
deps 此前仅建任务时可设,本次让 PATCH /api/tasks/:id 受理 deps, 拆解后可随时调整任务依赖,无需删重建。 - API:PATCH /api/tasks/:id 受理 deps:string[](类型校验) - Store.patchTask:写 deps 前复用建任务校验(存在/同项目/非自身) 并加 DFS 环检测(拒绝 A→B→A);越权/缺失/成环 → StoreError(400) - 仅允许在未进入执行链路的状态改 deps(与 complexity 同一组守卫) - 写后调 reconcileDeps 重算:未满足 ready→blocked、刚补全 blocked→ready, 广播 status.changed - 看板:任务详情加依赖编辑器(多选当前项目其它任务,排除自身与会成环者), 保存走 PATCH - 测试:加/删 dep、已 done 依赖放行、环检测、跨项目/缺失拒绝、 执行链路状态拒绝、status.changed 重算(test/patch.test.ts) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+87
-5
@@ -46,6 +46,8 @@ const FILTER_GROUPS = [
|
||||
];
|
||||
// 归档态:不进任务树,沉到页面底部的归档区(时间倒序分页)
|
||||
const ARCHIVED = new Set(['done', 'cancelled']);
|
||||
// 允许编辑 complexity / deps 的状态(与 store.ts 的 COMPLEXITY_EDITABLE 对齐:尚未进入执行链路)
|
||||
const STRUCT_EDITABLE = new Set(['init', 'analyzing', 'speccing', 'ready', 'plan_review', 'spec_review', 'blocked']);
|
||||
|
||||
// ── 全局状态 ──
|
||||
const S = {
|
||||
@@ -67,6 +69,8 @@ const S = {
|
||||
archive: { page: 1, size: 20 }, // 归档区分页
|
||||
confirmCancel: null, // 取消确认中的任务 id
|
||||
confirmDelete: null, // 删除确认中的任务 id
|
||||
depsEditFor: null, // 正在编辑依赖的任务 id
|
||||
depsEditSel: null, // 编辑中已选依赖 id 集合(Set)
|
||||
};
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
@@ -938,9 +942,12 @@ function renderDetail(t) {
|
||||
</div>`);
|
||||
}
|
||||
|
||||
// 依赖列表:标题 + 状态 chip,未完成的高亮(全部 done 本任务才可被领取)
|
||||
if (t.deps && t.deps.length) {
|
||||
const items = t.deps.map((d) => {
|
||||
// 依赖列表:标题 + 状态 chip,未完成的高亮(全部 done 本任务才可被领取);可编辑状态支持增删
|
||||
const depsEditable = STRUCT_EDITABLE.has(t.status);
|
||||
if (S.depsEditFor === t.id) {
|
||||
parts.push(depsEditorBlock(t));
|
||||
} else if ((t.deps && t.deps.length) || depsEditable) {
|
||||
const items = (t.deps || []).map((d) => {
|
||||
const dt = S.tasks.find((x) => x.id === d);
|
||||
const ok = dt && dt.status === 'done';
|
||||
return `<div class="dep-item ${ok ? 'ok' : 'wait'}" data-action="goto-task" data-id="${esc(d)}" title="点击定位到该任务">
|
||||
@@ -950,8 +957,11 @@ function renderDetail(t) {
|
||||
<span class="t-id">${esc(d.slice(-6))}</span>
|
||||
<span class="dep-go">↧</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
parts.push(`<div><div class="detail-label">DEPS · 依赖(全部完成才可执行)</div><div class="dep-list">${items}</div></div>`);
|
||||
}).join('') || `<div class="dep-empty">暂无依赖</div>`;
|
||||
const editBtn = depsEditable
|
||||
? `<button class="btn btn-xs deps-edit-btn" data-action="deps-edit" data-id="${esc(t.id)}" title="增删本任务的依赖">✎ 编辑</button>`
|
||||
: '';
|
||||
parts.push(`<div><div class="detail-label">DEPS · 依赖(全部完成才可执行)${editBtn}</div><div class="dep-list">${items}</div></div>`);
|
||||
}
|
||||
|
||||
if (!parts.length) parts.push(`<div class="proj-meta">暂无产出与历史 —— 状态:${STATUS_LABEL[t.status]}</div>`);
|
||||
@@ -990,6 +1000,50 @@ function renderDetail(t) {
|
||||
return `<div class="task-detail"><div class="detail-grid">${parts.join('')}</div></div>`;
|
||||
}
|
||||
|
||||
/** 选某任务为 t 的依赖会成环的候选集合:所有(传递)依赖 t 的任务 id */
|
||||
function depsWouldCycle(t) {
|
||||
const byId = new Map(S.tasks.map((x) => [x.id, x]));
|
||||
const reaches = (id, target, seen) => {
|
||||
const node = byId.get(id);
|
||||
if (!node) return false;
|
||||
for (const d of (node.deps || [])) {
|
||||
if (d === target) return true;
|
||||
if (!seen.has(d)) { seen.add(d); if (reaches(d, target, seen)) return true; }
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const bad = new Set();
|
||||
for (const x of S.tasks) {
|
||||
if (x.id === t.id) continue;
|
||||
if (reaches(x.id, t.id, new Set())) bad.add(x.id);
|
||||
}
|
||||
return bad;
|
||||
}
|
||||
|
||||
/** 依赖编辑器:多选当前项目其它任务(排除自己与会成环者),保存走 PATCH deps */
|
||||
function depsEditorBlock(t) {
|
||||
const sel = S.depsEditSel || new Set();
|
||||
const bad = depsWouldCycle(t);
|
||||
const candidates = S.tasks
|
||||
.filter((x) => x.id !== t.id && !ARCHIVED.has(x.status) && !bad.has(x.id))
|
||||
.sort((a, b) => (a.depth - b.depth) || a.title.localeCompare(b.title));
|
||||
const rows = candidates.map((x) => {
|
||||
const on = sel.has(x.id);
|
||||
return `<div class="dep-pick ${on ? 'on' : ''}" data-action="deps-edit-toggle" data-id="${esc(t.id)}" data-dep="${esc(x.id)}">
|
||||
<span class="dep-check">${on ? '☑' : '☐'}</span>
|
||||
${statusChip(x.status)}
|
||||
<span class="dep-title">${esc(x.title)}</span>
|
||||
<span class="t-id">${esc(x.id.slice(-6))}</span>
|
||||
</div>`;
|
||||
}).join('') || `<div class="dep-empty">本项目暂无其它可选任务</div>`;
|
||||
return `<div><div class="detail-label">DEPS · 编辑依赖(勾选当前项目其它任务;已排除自身与会成环者)</div>
|
||||
<div class="dep-list dep-pick-list">${rows}</div>
|
||||
<div class="deps-edit-foot">
|
||||
<button class="btn btn-xs btn-accept" data-action="deps-edit-save" data-id="${esc(t.id)}">保存依赖(${sel.size})</button>
|
||||
<button class="btn btn-xs" data-action="deps-edit-cancel" data-id="${esc(t.id)}">取消</button>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
// ── 渲染:事件流 ──
|
||||
function eventDetail(e) {
|
||||
const t = S.tasks.find((x) => x.id === e.taskId);
|
||||
@@ -1235,6 +1289,34 @@ document.addEventListener('click', (ev) => {
|
||||
break;
|
||||
}
|
||||
|
||||
// ── 依赖编辑 ──
|
||||
case 'deps-edit': {
|
||||
const t = S.tasks.find((x) => x.id === id);
|
||||
S.depsEditFor = id;
|
||||
S.depsEditSel = new Set((t && t.deps) || []);
|
||||
renderTree();
|
||||
break;
|
||||
}
|
||||
case 'deps-edit-toggle': {
|
||||
const dep = el.dataset.dep;
|
||||
if (!S.depsEditSel) S.depsEditSel = new Set();
|
||||
S.depsEditSel.has(dep) ? S.depsEditSel.delete(dep) : S.depsEditSel.add(dep);
|
||||
renderTree();
|
||||
break;
|
||||
}
|
||||
case 'deps-edit-cancel':
|
||||
S.depsEditFor = null; S.depsEditSel = null;
|
||||
renderTree();
|
||||
break;
|
||||
case 'deps-edit-save': {
|
||||
const deps = [...(S.depsEditSel || new Set())];
|
||||
S.depsEditFor = null; S.depsEditSel = null;
|
||||
act(() => api(`/api/tasks/${id}`, {
|
||||
method: 'PATCH', body: JSON.stringify({ deps }),
|
||||
}), `依赖已更新(${deps.length} 项,按依赖重算 ready/blocked)`);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── 任务树筛选 ──
|
||||
case 'filter-cplx': {
|
||||
const c = el.dataset.cplx;
|
||||
|
||||
@@ -735,6 +735,22 @@ li.ev-updated { --ev: var(--muted); }
|
||||
.dep-item:hover { background: var(--panel-2); border-color: var(--muted); }
|
||||
.dep-go { margin-left: auto; color: var(--faint); font-size: 12px; }
|
||||
.dep-item:hover .dep-go { color: var(--cyan); }
|
||||
/* 依赖编辑器 */
|
||||
.deps-edit-btn { margin-left: 8px; vertical-align: middle; }
|
||||
.dep-empty { font-size: 12px; color: var(--faint); padding: 4px 10px; }
|
||||
.dep-pick-list { max-height: 240px; overflow-y: auto; }
|
||||
.dep-pick {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 12px; padding: 4px 10px; cursor: pointer;
|
||||
background: var(--bg-deep); border: 1px solid var(--line-soft);
|
||||
border-left: 2px solid var(--line-soft);
|
||||
transition: border-color .12s, background .12s;
|
||||
}
|
||||
.dep-pick:hover { background: var(--panel-2); border-color: var(--muted); }
|
||||
.dep-pick.on { border-left-color: var(--cyan); }
|
||||
.dep-pick.on .dep-check { color: var(--cyan); }
|
||||
.dep-check { font-size: 13px; color: var(--faint); }
|
||||
.deps-edit-foot { display: flex; gap: 8px; margin-top: 6px; }
|
||||
.task-row.flash { animation: locate-flash 1.8s ease-out; }
|
||||
@keyframes locate-flash {
|
||||
0%, 35% { background: var(--amber-dim); box-shadow: inset 2px 0 0 var(--amber); }
|
||||
|
||||
Reference in New Issue
Block a user