feat: Phase2 完整管线——score 调度 + 自动复审 + 模型分级 + 归档与详情

调度:
- model/scoring.ts: score = 自身分(P0=3/P1=2/P2=1) + 已完成依赖分(链条惯性)
  + 等待解锁的 blocked 任务分(解锁加权),编排器与 nextExecutable 同一打分
- createTask 校验 deps 存在且同项目(依赖图天然无环)
- daemon 重启中断自愈: executing 任务标 failed run 后重新入队(reconcileInterrupted)

执行管线:
- executor/cc.ts: 公共 headless CC 执行器(转录/超时/模型回退重试)
- executor/reviewer.ts: 执行后自动复审(只读 CC 审 diff),固定模板 summary
  (做了什么/怎么做/测试/CodeReview/安全Review/结论) + VERDICT 解析
- executor/models.ts: 按复杂度选模型(easy→sonnet/medium→opus/hard→fable5),
  env 可覆盖、project.model 最优先、不可用自动回退链
- runner: 测试/构建命令白名单(npm/go/shellcheck/make/pytest),prompt 要求实跑测试
- TaskResult 加 summary/verdict; RunKind 加 reviewer
- 容器收口: 已拆解 Hard 子任务全 done → 容器自动 done(afterDone 逐级向上)

看板:
- 五徽章组(待审批/待执行/执行中/被阻塞/总量,hover 展开,均不含已完成)
- 归档区: 深度1整树完成沉底,时间倒序分页(10/20/50/100 chip 选择)
- 归档详情对话框: 全属性/执行历史与时长/审批记录/状态流转时间线(含相关人或事)
- Agent 面板显示调度模式 + 各复杂度实际模型
- 结果闸展示复审 summary + 建议通过/拒绝徽章
- 筛选修复(组选与单选分离、已拆解移出进行中)、同步按钮收进配置面板、
  保存配置自动收起、预览全宽、被依赖阻塞→被阻塞
- API: GET /api/tasks/:id/events(任务级事件时间线)、/api/agents 带 scheduling/models

测试: 49/49(新增 scoring/复审/模型/容器收口/deps 校验/中断恢复)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 02:46:37 +08:00
parent fa472a06a7
commit f18db021c3
25 changed files with 2215 additions and 56 deletions
+294 -28
View File
@@ -7,7 +7,7 @@
const STATUS_LABEL = {
init: '新建', analyzing: '分析拆解中', plan_review: '待确认拆解',
decomposed: '已拆解', speccing: '写方案中', spec_review: '待确认方案',
ready: '可执行', blocked: '被依赖阻塞', queued: '排队中',
ready: '可执行', blocked: '被阻塞', queued: '排队中',
executing: '执行中', exec_review: '待审/合', failed: '失败',
needs_attention: '需人工', done: '完成', paused: '暂停', cancelled: '取消',
};
@@ -38,12 +38,14 @@ const AUTONOMY_LABEL = {
// 任务树筛选:状态按状态机分组(组可整组开关,组内可单选)
const FILTER_GROUPS = [
['待办', ['init', 'ready', 'blocked']],
['进行中', ['analyzing', 'speccing', 'queued', 'executing', 'decomposed']],
['进行中', ['analyzing', 'speccing', 'queued', 'executing']], // 已拆解是容器态,不算进行中
['容器', ['decomposed']],
['待审批', ['plan_review', 'spec_review', 'exec_review']],
['异常', ['failed', 'needs_attention']],
['挂起', ['paused', 'cancelled']],
['完成', ['done']],
['挂起', ['paused']],
];
// 归档态:不进任务树,沉到页面底部的归档区(时间倒序分页)
const ARCHIVED = new Set(['done', 'cancelled']);
// ── 全局状态 ──
const S = {
@@ -55,13 +57,14 @@ const S = {
collapsed: new Set(),
expanded: new Set(),
rejectOpen: new Set(),
filter: { cplx: new Set(), status: new Set(), kw: '' },// 任务树筛选(内存态
filter: { cplx: new Set(), status: new Set(), statusGroups: new Set(), kw: '' },// 任务树筛选(组选与单选分离
matchCount: 0,
agents: null, // GET /api/agents 结果(404 时为 null
cplxMenuFor: null, // 复杂度下拉打开的任务 id
syncReqAt: 0, // 本端发起 sync 的时间(避免 WS 重复 toast)
previewId: null, // 全局预览中的任务 id
previewReject: false, // 预览层内驳回意见框是否展开
archive: { page: 1, size: 20 }, // 归档区分页
};
const $ = (sel) => document.querySelector(sel);
@@ -87,6 +90,11 @@ function fmtRel(iso) {
return `${Math.floor(h / 24)} 天前`;
}
/** 模型名缩写显示:claude-fable-5 → fable-5 */
function shortModel(m) {
return String(m || '—').replace(/^claude-/, '');
}
// ── 轻量 Markdown 渲染(零依赖;先整体转义再做结构转换,杜绝注入) ──
function mdToHtml(src) {
const inline = (s) => s
@@ -251,6 +259,7 @@ function renderAll() {
renderFilterBar();
renderEvents();
renderParentOptions();
renderArchive();
renderPreview();
restoreDrafts(snap);
}
@@ -275,15 +284,20 @@ function renderTopbar() {
$('#projMeta').textContent = p
? `${p.repoPath} · 分支 ${p.defaultBranch} · ${p.status === 'active' ? '活跃' : '暂停'} · 任务 ${S.tasks.length}`
: '';
// 徽章组:待审批 / 可执行(叶子+ready+依赖全 done,与编排器领取口径一致)/ 执行中
// 徽章组(全部不含已完成:done / 已拆解容器 / 取消):
// 待审批 / 待执行(ready 叶子依赖满足 + 排队中) / 执行中 / 被阻塞 / 总量
$('#badgeGroup').hidden = !p;
const FINISHED = new Set(['done', 'decomposed', 'cancelled']);
const alive = S.tasks.filter((x) => !FINISHED.has(x.status));
const hasKids = new Set(S.tasks.filter((x) => x.parentId).map((x) => x.parentId));
const byId = new Map(S.tasks.map((x) => [x.id, x]));
const nReady = S.tasks.filter((x) =>
x.status === 'ready' && !hasKids.has(x.id) &&
(x.deps || []).every((d) => byId.get(d)?.status === 'done'),
const nReady = alive.filter((x) =>
(x.status === 'ready' && !hasKids.has(x.id) &&
(x.deps || []).every((d) => byId.get(d)?.status === 'done')) ||
x.status === 'queued',
).length;
const nRun = S.tasks.filter((x) => x.status === 'executing').length;
const nRun = alive.filter((x) => x.status === 'executing').length;
const nBlocked = alive.filter((x) => x.status === 'blocked').length;
const setBdg = (sel, n) => {
const el = $(sel);
el.querySelector('.bdg-n').textContent = n;
@@ -292,6 +306,8 @@ function renderTopbar() {
setBdg('#bdgGate', S.approvals.length);
setBdg('#bdgReady', nReady);
setBdg('#bdgRun', nRun);
setBdg('#bdgBlocked', nBlocked);
setBdg('#bdgTotal', alive.length);
// 同步 todo / 项目配置入口
$('#topActions').hidden = !p;
@@ -329,6 +345,8 @@ function renderAgents() {
<div class="agent-proj">
<div class="agent-proj-head"><b>${esc(g.projectName)}</b>
<span class="agent-proj-meta">并发 ${esc(String(g.concurrency ?? '—'))} · ${esc(AUTONOMY_LABEL[g.autonomy] || g.autonomy || '—')}</span>
<span class="agent-proj-meta" title="调度:score 优先 = 自身分(P0=3/P1=2/P2=1) + 已完成依赖分(链条惯性) + 等我解锁的任务分(解锁加权),同分先进先出">调度 score 优先</span>
${g.models ? `<span class="agent-proj-meta" title="各复杂度任务实际使用的执行模型(Hard 不可用自动回退)">模型 E:${esc(shortModel(g.models.easy))} M:${esc(shortModel(g.models.medium))} H:${esc(shortModel(g.models.hard))}</span>` : ''}
</div>
${g.active.map((r) => `
<div class="agent-run">
@@ -354,6 +372,7 @@ function gateDocs(t) {
if (gate === 'exec') {
if (t.result) {
const r = t.result;
blocks.push(reviewSummaryBlock(r)); // 复审 SUMMARY + verdict 徽章,置于 DIFF 摘要上方
blocks.push(`<div class="gate-doc-label">RESULT · 执行结果</div>
<dl class="result-kv">
${r.branch ? `<dt>分支</dt><dd>${esc(r.branch)}</dd>` : ''}
@@ -402,6 +421,171 @@ function renderGates() {
}).join('');
}
// ── 渲染:归档区(done/取消,updatedAt 倒序,分页) ──
function renderArchive() {
const sec = $('#archiveSection');
if (!S.currentProjectId) { sec.hidden = true; return; }
// 只列深度 1 的归档树根,行内标注子任务数
const m = childrenMap(S.tasks);
const arch = archivedIdSet();
const subCount = (t) => {
let n = 0;
const stack = [...(m.get(t.id) || [])];
while (stack.length) { const x = stack.pop(); n++; for (const c of m.get(x.id) || []) stack.push(c); }
return n;
};
const items = (m.get('__root__') || [])
.filter((t) => arch.has(t.id))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
sec.hidden = items.length === 0;
if (sec.hidden) return;
const a = S.archive;
const pages = Math.max(1, Math.ceil(items.length / a.size));
if (a.page > pages) a.page = pages;
const slice = items.slice((a.page - 1) * a.size, a.page * a.size);
$('#archiveCount').textContent = items.length;
$('#archiveList').innerHTML = slice.map((t) => {
const n = subCount(t);
return `
<div class="archive-row" data-action="archive-detail" data-id="${esc(t.id)}" title="点击查看完整详情">
<span class="t-title">${esc(t.title)}<span class="t-id">${esc(t.id.slice(-6))}</span></span>
${n ? `<span class="archive-subs">含 ${n} 子任务</span>` : ''}
<span class="spacer"></span>
${cplxBadge(t.complexity)}
${statusChip(t.status)}
<span class="archive-time" title="${esc(t.updatedAt)}">${fmtRel(t.updatedAt)}</span>
</div>`;
}).join('');
// 底部:翻页 + 每页条数 chip 组(替代原生下拉)
const sizeChips = [10, 20, 50, 100].map((n) =>
`<button type="button" class="fchip ${a.size === n ? 'on' : ''}" data-action="archive-size" data-size="${n}">${n}</button>`,
).join('');
$('#archivePager').innerHTML = `
${pages > 1 ? `
<button class="btn btn-xs" data-action="archive-prev" ${a.page <= 1 ? 'disabled' : ''}> 上一页</button>
<span class="archive-page">第 ${a.page} / ${pages} 页</span>
<button class="btn btn-xs" data-action="archive-next" ${a.page >= pages ? 'disabled' : ''}>下一页 </button>` : ''}
<span class="archive-size-chips"><span class="archive-size-label">每页</span>${sizeChips}</span>`;
}
// ── 归档详情对话框(只读:全属性/执行历史/审批/状态流转时间线) ──
function fmtFull(iso) {
try { return new Date(iso).toLocaleString('zh-CN', { hour12: false }); } catch { return iso || '—'; }
}
function fmtDuration(startIso, endIso) {
if (!startIso || !endIso) return '—';
const ms = new Date(endIso) - new Date(startIso);
if (!Number.isFinite(ms) || ms < 0) return '—';
const s = Math.round(ms / 1000);
if (s < 60) return `${s}`;
return `${Math.floor(s / 60)}${s % 60}`;
}
/** 事件 → 时间线条目(人或事:by/auto/actor/原因) */
function tlActor(p) {
const who = [];
if (p.by) who.push(p.by === 'orchestrator' ? '编排器' : p.by);
if (p.actor) who.push(p.actor === 'importer' ? '导入器' : p.actor);
if (p.auto) who.push({ 'deps-met': '依赖满足自动放行', 'deps-reconcile': '启动对账', 'deps-unmet': '依赖未满足落位',
'children-done': '子任务全完成自动收口', 'import-done': '导入历史完成', 'daemon-restart': '重启恢复' }[p.auto] || p.auto);
return who.join(' · ');
}
async function openArchiveDetail(id) {
try {
const [task, runs, events] = await Promise.all([
api(`/api/tasks/${id}`), api(`/api/tasks/${id}/runs`), api(`/api/tasks/${id}/events`),
]);
renderArchiveModal(task, runs, events);
} catch (e) { toast(e.message); }
}
function renderArchiveModal(t, runs, events) {
const root = $('#archiveModalRoot');
const byId = new Map(S.tasks.map((x) => [x.id, x]));
const parent = t.parentId ? byId.get(t.parentId) : null;
const r = t.result;
const RUN_KIND = { executor: '执行', reviewer: '复审', planner: '规划' };
const RUN_ST = { succeeded: '成功', failed: '失败', started: '进行中', cancelled: '取消' };
const kv = (dt, dd) => `<dt>${dt}</dt><dd>${dd}</dd>`;
const attrs = `<dl class="result-kv">
${kv('id', esc(t.id))}
${kv('复杂度', `${CPLX_LABEL[t.complexity] || esc(t.complexity)}`)}
${kv('优先级', `P${esc(String(t.priority))}`)}
${kv('状态', STATUS_LABEL[t.status] || esc(t.status))}
${kv('深度', esc(String(t.depth)))}
${parent ? kv('父任务', esc(parent.title)) : ''}
${t.deps && t.deps.length ? kv('依赖', t.deps.map((d) => esc(byId.get(d)?.title || d)).join('、')) : ''}
${kv('创建时间', fmtFull(t.createdAt))}
${kv('最后更新', fmtFull(t.updatedAt))}
</dl>`;
const docs = [['plan', 'PLAN · 分析拆解'], ['spec', 'SPEC · 方案'], ['operations', 'OPERATIONS · 操作']]
.filter(([f]) => t[f])
.map(([f, label]) => `<div class="gate-doc-label">${label}</div><div class="doc md">${mdToHtml(t[f])}</div>`)
.join('');
const runRows = runs.length ? `<div class="gate-doc-label">执行历史(${runs.length} 次 run</div>
<dl class="result-kv">${runs.map((x) => kv(
`${RUN_KIND[x.kind] || esc(x.kind)} · ${RUN_ST[x.status] || esc(x.status)}`,
`${fmtFull(x.startedAt)}${x.endedAt ? fmtFull(x.endedAt) : '—'} · 时长 ${fmtDuration(x.startedAt, x.endedAt)}` +
(x.error ? `<br>错误:${esc(x.error)}` : '') +
(x.transcriptRef ? `<br><span class="t-id">转录 ${esc(x.transcriptRef)}</span>` : ''),
)).join('')}</dl>` : '';
const resultBlock = r ? `<div class="gate-doc-label">执行结果</div>
${r.summary ? `<div class="doc md">${mdToHtml(r.summary)}</div>` : ''}
<dl class="result-kv">
${r.verdict ? kv('复审建议', r.verdict === 'approve' ? '建议通过' : '建议拒绝') : ''}
${r.branch ? kv('分支', esc(r.branch)) : ''}
${r.worktree ? kv('worktree', esc(r.worktree)) : ''}
${r.commits && r.commits.length ? kv('commits', r.commits.map(esc).join('<br>')) : ''}
</dl>
${r.diffSummary ? `<pre class="doc">${esc(r.diffSummary)}</pre>` : ''}` : '';
const approvalRows = t.approvals && t.approvals.length ? `<div class="gate-doc-label">审批记录</div>
<dl class="result-kv">${t.approvals.map((a) => kv(
`${GATE_LABEL[a.gate] || esc(a.gate)} · ${a.action === 'accept' ? '接受' : '驳回'}`,
`审批人 ${esc(a.actor)} · ${fmtFull(a.at)}${a.reason ? `<br>意见:${esc(a.reason)}` : ''}`,
)).join('')}</dl>` : '';
const timeline = events.length ? `<div class="gate-doc-label">状态流转时间线</div>
<div class="tl">${events.map((e) => {
const p = e.payload || {};
let text = EVENT_LABEL[e.type] || e.type;
if (e.type === 'status.changed') text = `${STATUS_LABEL[p.from] || p.from} ${STATUS_LABEL[p.to] || p.to}`;
if (e.type === 'approval.granted') text = `审批通过${GATE_LABEL[p.gate] || p.gate}`;
if (e.type === 'approval.rejected') text = `审批驳回${GATE_LABEL[p.gate] || p.gate}${p.reason ? '' + p.reason : ''}`;
if (e.type === 'run.started') text = `run 开始${RUN_KIND[p.kind] || p.kind || ''}`;
if (e.type === 'run.finished') text = `run 结束${RUN_ST[p.status] || p.status || ''}`;
const who = tlActor(p);
return `<div class="tl-item">
<span class="tl-time">${fmtFull(e.at)}</span>
<span class="tl-text">${esc(text)}</span>
${who ? `<span class="tl-who">${esc(who)}</span>` : ''}
</div>`;
}).join('')}</div>` : '';
root.hidden = false;
root.innerHTML = `
<div class="preview-mask" data-action="archive-modal-close"></div>
<div class="preview-panel">
<div class="preview-head">
<span class="gate-kind">归档详情</span>
<span class="preview-title">${esc(t.title)}</span>
${cplxBadge(t.complexity)}
${statusChip(t.status)}
<span class="spacer"></span>
<button class="btn btn-ghost" data-action="archive-modal-close" title="关闭(Esc">✕</button>
</div>
<div class="preview-body">
${attrs}${docs}${runRows}${resultBlock}${approvalRows}${timeline}
</div>
</div>`;
}
// ── 渲染:全局预览(全屏读方案 + 就地裁决) ──
function renderPreview() {
const root = $('#previewRoot');
@@ -459,9 +643,24 @@ function statusChip(st) {
return `<span class="chip chip-${STATUS_GROUP[st] || 'idle'}">${STATUS_LABEL[st] || esc(st)}</span>`;
}
function childrenMap() {
// 复审结论徽章:approve=建议通过(绿) / reject=建议拒绝(红) / 其他=未复审(灰)
function verdictBadge(v) {
if (v === 'approve') return `<span class="verdict verdict-approve">建议通过</span>`;
if (v === 'reject') return `<span class="verdict verdict-reject">建议拒绝</span>`;
return `<span class="verdict verdict-none">未复审</span>`;
}
// 复审 SUMMARY 区块(label + verdict 徽章 + markdown 渲染),无 summary 且未复审时返回空
function reviewSummaryBlock(r) {
if (!r) return '';
const label = `<div class="gate-doc-label">复审 SUMMARY ${verdictBadge(r.verdict)}</div>`;
if (r.summary) return `${label}<div class="doc md review-summary">${mdToHtml(r.summary)}</div>`;
return `${label}<pre class="doc empty">(无复审记录)</pre>`;
}
function childrenMap(list = S.tasks) {
const m = new Map();
for (const t of S.tasks) {
for (const t of list) {
const key = t.parentId || '__root__';
if (!m.has(key)) m.set(key, []);
m.get(key).push(t);
@@ -469,15 +668,46 @@ function childrenMap() {
return m;
}
/**
* 归档单位 = 深度 1 任务整树:自身与全部子孙都 done/取消 才算「完成」。
* 返回归档任务 id 集合(含整树)。子任务先完成但兄弟未完时整树留在任务树里展示进度。
*/
function archivedIdSet() {
const m = childrenMap(S.tasks);
const subtreeDone = (t) => ARCHIVED.has(t.status) && (m.get(t.id) || []).every(subtreeDone);
const ids = new Set();
for (const r of m.get('__root__') || []) {
if (!subtreeDone(r)) continue;
const stack = [r];
while (stack.length) {
const x = stack.pop();
ids.add(x.id);
for (const c of m.get(x.id) || []) stack.push(c);
}
}
return ids;
}
/** 任务树里显示的任务(已归档整树沉到底部归档区) */
function aliveTasks() {
const arch = archivedIdSet();
return S.tasks.filter((t) => !arch.has(t.id));
}
// ── 筛选 ──
function filterActive() {
const f = S.filter;
return f.cplx.size > 0 || f.status.size > 0 || f.kw.trim() !== '';
return f.cplx.size > 0 || f.status.size > 0 || f.statusGroups.size > 0 || f.kw.trim() !== '';
}
function matchTask(t) {
const f = S.filter;
if (f.cplx.size && !f.cplx.has(t.complexity)) return false;
if (f.status.size && !f.status.has(t.status)) return false;
// 状态条件 = 单选状态 ∪ 已选组的全部成员(任一命中即过)
if (f.status.size || f.statusGroups.size) {
const inStatus = f.status.has(t.status);
const inGroup = [...f.statusGroups].some((gi) => FILTER_GROUPS[gi]?.[1].includes(t.status));
if (!inStatus && !inGroup) return false;
}
const kw = f.kw.trim().toLowerCase();
if (kw && !String(t.title).toLowerCase().includes(kw)) return false;
return true;
@@ -486,10 +716,11 @@ function matchTask(t) {
function computeVisible() {
S.matchCount = 0;
if (!filterActive()) return null;
const byId = new Map(S.tasks.map((t) => [t.id, t]));
const alive = aliveTasks();
const byId = new Map(alive.map((t) => [t.id, t]));
const visible = new Set();
const matched = new Set();
for (const t of S.tasks) {
for (const t of alive) {
if (!matchTask(t)) continue;
matched.add(t.id);
let cur = t;
@@ -511,11 +742,15 @@ function renderFilterBar() {
`<button type="button" class="fchip fc-${c} ${f.cplx.has(c) ? 'on' : ''}" data-action="filter-cplx" data-cplx="${c}">${CPLX_LABEL[c]}</button>`,
).join('');
$('#filterStatus').innerHTML = FILTER_GROUPS.map(([name, sts], gi) => {
const sel = sts.filter((s) => f.status.has(s)).length;
const gCls = sel === sts.length ? 'on' : (sel ? 'part' : '');
// 组选与单选分离:组 chip 只反映组选择,成员 chip 只反映单选;单状态组只渲染状态 chip 本身
const members = sts.map((s) =>
`<button type="button" class="fchip fst ${f.status.has(s) ? 'on' : ''}" data-action="filter-status" data-st="${s}">${STATUS_LABEL[s]}</button>`,
).join('');
if (sts.length === 1) return `<span class="fgroup">${members}</span>`;
const gCls = f.statusGroups.has(gi) ? 'on' : '';
return `<span class="fgroup">
<button type="button" class="fchip fgroup-chip ${gCls}" data-action="filter-group" data-gi="${gi}">${name}</button>
${sts.map((s) => `<button type="button" class="fchip fst ${f.status.has(s) ? 'on' : ''}" data-action="filter-status" data-st="${s}">${STATUS_LABEL[s]}</button>`).join('')}
<button type="button" class="fchip fgroup-chip ${gCls}" data-action="filter-group" data-gi="${gi}" title="按整组筛选">${name}</button>
${members}
</span>`;
}).join('');
const active = filterActive();
@@ -533,7 +768,12 @@ function renderTree() {
root.innerHTML = `<div class="tree-empty"><b>EMPTY</b>还没有任务 —— 点上方「+ 新建任务」开始</div>`;
return;
}
const m = childrenMap();
const alive = aliveTasks();
if (!alive.length) {
root.innerHTML = `<div class="tree-empty"><b>ALL DONE</b>全部任务已完成归档(见页面底部归档区)</div>`;
return;
}
const m = childrenMap(alive);
const vis = computeVisible();
if (vis && !vis.visible.size) {
root.innerHTML = `<div class="tree-empty"><b>NO MATCH</b>没有任务命中当前筛选 —— 放宽条件或「清除筛选」</div>`;
@@ -602,7 +842,8 @@ function renderDetail(t) {
if (t.result) {
const r = t.result;
parts.push(`
<div><div class="detail-label">RESULT · 执行结果</div>
<div><div class="detail-label">RESULT · 执行结果 ${verdictBadge(r.verdict)}</div>
${r.summary ? `<div class="doc md review-summary">${mdToHtml(r.summary)}</div>` : ''}
<dl class="result-kv">
<dt>分支</dt><dd>${esc(r.branch || '—')}</dd>
<dt>worktree</dt><dd>${esc(r.worktree || '—')}</dd>
@@ -811,7 +1052,7 @@ document.addEventListener('click', (ev) => {
// 被筛选隐藏时清除筛选,保证可见
const vis = computeVisible();
if (vis && !vis.visible.has(id)) {
S.filter.cplx.clear(); S.filter.status.clear(); S.filter.kw = '';
S.filter.cplx.clear(); S.filter.status.clear(); S.filter.statusGroups.clear(); S.filter.kw = '';
renderFilterBar();
}
// 展开全部祖先 + 展开目标详情
@@ -831,6 +1072,29 @@ document.addEventListener('click', (ev) => {
break;
}
case 'archive-detail':
openArchiveDetail(id);
break;
case 'archive-modal-close':
$('#archiveModalRoot').hidden = true;
$('#archiveModalRoot').innerHTML = '';
break;
case 'archive-size':
S.archive.size = Number(el.dataset.size) || 20;
S.archive.page = 1;
renderArchive();
break;
case 'archive-prev':
S.archive.page = Math.max(1, S.archive.page - 1);
renderArchive();
break;
case 'archive-next':
S.archive.page += 1;
renderArchive();
break;
case 'cplx-menu':
S.cplxMenuFor = S.cplxMenuFor === id ? null : id;
renderTree();
@@ -859,17 +1123,16 @@ document.addEventListener('click', (ev) => {
break;
}
case 'filter-group': {
const g = FILTER_GROUPS[Number(el.dataset.gi)];
if (!g) break;
const sts = g[1];
const all = sts.every((s) => S.filter.status.has(s));
sts.forEach((s) => { all ? S.filter.status.delete(s) : S.filter.status.add(s); });
const gi = Number(el.dataset.gi);
if (!FILTER_GROUPS[gi]) break;
S.filter.statusGroups.has(gi) ? S.filter.statusGroups.delete(gi) : S.filter.statusGroups.add(gi);
renderTree(); renderFilterBar();
break;
}
case 'filter-clear':
S.filter.cplx.clear();
S.filter.status.clear();
S.filter.statusGroups.clear();
S.filter.kw = '';
$('#filterKw').value = '';
renderTree(); renderFilterBar();
@@ -913,6 +1176,7 @@ document.addEventListener('click', (ev) => {
method: 'PATCH', body: JSON.stringify({ concurrency: cc, autonomy }),
});
await loadProjects(); // 刷新当前值显示
$('#configPanel').hidden = true; // 保存成功自动收起
}, '配置已保存');
break;
}
@@ -977,6 +1241,8 @@ $('#filterKw').addEventListener('input', (ev) => {
// Esc 关模态
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') {
const am = $('#archiveModalRoot');
if (!am.hidden) { am.hidden = true; am.innerHTML = ''; return; }
if (S.previewId) { S.previewId = null; S.previewReject = false; renderPreview(); return; }
$('#modalRoot').hidden = true;
}
+22 -6
View File
@@ -41,19 +41,23 @@
<div class="spacer"></div>
<div id="badgeGroup" class="badge-group" hidden>
<span id="bdgGate" class="bdg bdg-gate" title="等待你裁决的审批闸">
<span class="bdg-ico"></span><span class="bdg-n">0</span><span class="bdg-label">待审批</span>
<span class="bdg-ico"></span><span class="bdg-n">0</span><span class="bdg-label">待审批</span>
</span>
<span id="bdgReady" class="bdg bdg-ready" title="叶子任务 · 可执行 · 依赖全部完成">
<span class="bdg-ico"></span><span class="bdg-n">0</span><span class="bdg-label">执行</span>
<span id="bdgReady" class="bdg bdg-ready" title="可执行(依赖已满足)+ 排队中">
<span class="bdg-ico"></span><span class="bdg-n">0</span><span class="bdg-label">执行</span>
</span>
<span id="bdgRun" class="bdg bdg-run" title="agent 正在执行">
<span class="bdg-ico"></span><span class="bdg-n">0</span><span class="bdg-label">执行中</span>
</span>
<span id="bdgBlocked" class="bdg bdg-blocked" title="被依赖阻塞(依赖完成后自动放行)">
<span class="bdg-ico"></span><span class="bdg-n">0</span><span class="bdg-label">被阻塞</span>
</span>
<span id="bdgTotal" class="bdg bdg-total" title="未完成任务总量(不含 done / 已拆解容器 / 取消)">
<span class="bdg-ico">Σ</span><span class="bdg-n">0</span><span class="bdg-label">总量</span>
</span>
</div>
<div id="topActions" class="top-actions" hidden>
<span id="syncMeta" class="sync-meta"></span>
<button id="btnSync" class="btn btn-xs" data-action="sync-todo">⟳ 同步 todo</button>
<button class="btn btn-xs" data-action="toggle-config" title="项目配置(并发 / 工作模式)">⚙ 配置</button>
<button class="btn btn-xs" data-action="toggle-config" title="项目配置(并发 / 工作模式 / todo 同步)">⚙ 配置</button>
</div>
</header>
@@ -73,6 +77,10 @@
<button class="btn btn-solid" data-action="save-config">保存配置</button>
<button class="btn" data-action="toggle-config">收起</button>
</div>
<div class="form-row cfg-sync-row">
<span id="syncMeta" class="sync-meta"></span>
<button id="btnSync" class="btn btn-xs" data-action="sync-todo">⟳ 同步 todo</button>
</div>
</section>
<section id="agentSection" class="agent-section">
@@ -136,6 +144,13 @@
<div id="taskTree"></div>
</section>
<!-- ════════ 已归档(done / 取消,时间倒序分页) ════════ -->
<section id="archiveSection" hidden>
<div class="sec-head"><span class="head-mark"></span>已归档 · <span id="archiveCount">0</span></div>
<div id="archiveList"></div>
<div id="archivePager" class="archive-pager"></div>
</section>
</main>
<!-- ════════ 右栏:事件流 ════════ -->
@@ -170,6 +185,7 @@
</div>
<div id="previewRoot" class="preview-root" hidden></div>
<div id="archiveModalRoot" class="preview-root" hidden></div>
<div id="toastRoot" class="toast-root"></div>
<script src="app.js"></script>
</body>
+61
View File
@@ -415,6 +415,19 @@ textarea { resize: vertical; min-height: 72px; width: 100%; }
.cplx-medium { color: var(--amber); border-color: var(--amber-dim); background: rgba(240,180,41,.07); }
.cplx-easy { color: var(--green); border-color: var(--green-dim); background: rgba(95,221,125,.07); }
/* 复审结论徽章(exec_review 闸 / RESULT 区块) */
.verdict {
display: inline-block; vertical-align: 1px; flex: none;
font-size: 10px; font-weight: 700; letter-spacing: .14em;
padding: 1px 7px; margin-left: 6px; border: 1px solid;
}
.verdict-approve { color: var(--green); border-color: var(--green-dim); background: rgba(95,221,125,.1); }
.verdict-reject { color: var(--red); border-color: var(--red-dim); background: rgba(255,93,93,.1); }
.verdict-none { color: var(--muted); border-color: var(--line); background: rgba(140,150,140,.08); }
/* 复审 SUMMARY 区块:略加边框突出 */
.review-summary { border-left: 2px solid var(--line); padding-left: 10px; }
/* 复杂度徽章可点 + 下拉 */
.cplx-wrap { position: relative; display: inline-flex; flex: none; }
button.cplx { font-family: var(--mono); cursor: default; }
@@ -693,3 +706,51 @@ li.ev-updated { --ev: var(--muted); }
0%, 35% { background: var(--amber-dim); box-shadow: inset 2px 0 0 var(--amber); }
100% { background: transparent; box-shadow: none; }
}
/* 徽章组扩展:被阻塞 / 总量 */
.bdg-blocked { color: var(--amber); border: 1px dashed var(--amber-dim); }
.bdg-total { color: var(--ink); border: 1px solid var(--line); }
/* ── 归档区 ── */
#archiveSection { margin-top: 28px; opacity: .82; }
.archive-row {
display: flex; align-items: center; gap: 10px;
padding: 7px 12px; font-size: 12.5px;
border-bottom: 1px dashed var(--line-soft);
}
.archive-row:hover { background: var(--panel); }
.archive-row .t-title { color: var(--muted); }
.archive-subs { flex: none; font-size: 10.5px; color: var(--faint); }
.archive-time { flex: none; font-size: 11px; color: var(--faint); }
.archive-size { font-size: 11px; color: var(--muted); display: flex; gap: 6px; align-items: center; }
.archive-size select { background: var(--panel-2); color: var(--ink); border: 1px solid var(--line); padding: 2px 6px; font-family: var(--mono); }
.archive-pager { display: flex; align-items: center; gap: 12px; padding: 10px 0; justify-content: center; }
.archive-page { font-size: 11px; color: var(--muted); }
/* 归档详情:状态流转时间线 */
.tl { display: grid; gap: 0; margin-top: 4px; }
.tl-item {
display: flex; gap: 12px; align-items: baseline;
padding: 5px 0 5px 14px; font-size: 12px;
border-left: 2px solid var(--line);
position: relative;
}
.tl-item::before {
content: ''; position: absolute; left: -4px; top: 11px;
width: 6px; height: 6px; border-radius: 50%; background: var(--green-dim);
}
.tl-time { flex: none; color: var(--faint); font-size: 11px; min-width: 150px; }
.tl-text { color: var(--ink); }
.tl-who { color: var(--muted); font-size: 11px; }
.archive-row { cursor: pointer; }
/* 顶栏动作不换行(同步 todo / 配置 按钮文字曾被挤成两行) */
.top-actions { flex-wrap: nowrap; }
.top-actions .btn, .sync-meta { white-space: nowrap; }
/* 归档分页底栏:尺寸 chip 组替代原生下拉 */
.archive-size-chips { display: inline-flex; align-items: center; gap: 6px; margin-left: 18px; }
.archive-size-label { font-size: 11px; color: var(--faint); margin-right: 2px; }
/* 配置面板内的同步行 */
.cfg-sync-row { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--line-soft); align-items: center; gap: 10px; }