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:
+294
-28
@@ -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
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user