Files
maestro/web/app.js
T
wangjia f2c2d9aebd feat(ui): 配置面板补 verifyCmd / 项目级 model 输入项
在 #configPanel 新增一行 form-row,包含「校验命令」(cfgVerifyCmd) 和
「项目 Model」(cfgModel) 两个文本输入;打开面板时从 project 读取当前值,
保存时通过 PATCH /api/projects/:id 写入(空白 → null 清空)。

任务 ID:tsk_kmdMuWSn0HUS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 03:32:23 +08:00

1370 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ════════════════════════════════════════════════════════════
MAESTRO 调度台 · 前端逻辑(无框架,直连 daemon REST + WS
════════════════════════════════════════════════════════════ */
'use strict';
// ── 模型常量(与 src/model/status.ts / complexity.ts 对齐) ──
const STATUS_LABEL = {
init: '新建', analyzing: '分析拆解中', plan_review: '待确认拆解',
decomposed: '已拆解', speccing: '写方案中', spec_review: '待确认方案',
ready: '可执行', blocked: '被阻塞', queued: '排队中',
executing: '执行中', exec_review: '待审/合', failed: '失败',
needs_attention: '需人工', done: '完成', paused: '暂停', cancelled: '取消',
};
const STATUS_GROUP = {
init: 'idle', analyzing: 'work', speccing: 'work',
plan_review: 'gate', spec_review: 'gate', exec_review: 'gate',
decomposed: 'container', ready: 'go', queued: 'go', executing: 'run',
blocked: 'hold', paused: 'hold', failed: 'bad', needs_attention: 'bad',
done: 'done', cancelled: 'dead',
};
const GATE_OF = { plan_review: 'plan', spec_review: 'spec', exec_review: 'exec' };
const GATE_LABEL = { plan: '拆解评审', spec: '方案评审', exec: '结果评审' };
const CPLX_LABEL = { hard: 'HARD', medium: 'MED', easy: 'EASY' };
const EVENT_LABEL = {
'task.created': '任务创建', 'task.updated': '任务更新', 'status.changed': '状态变更',
'approval.requested': '请求审批', 'approval.granted': '审批通过', 'approval.rejected': '审批驳回',
'run.started': '运行开始', 'run.finished': '运行结束', 'project.synced': 'todo 同步',
};
const EVENT_CLASS = {
'task.created': 'ev-created', 'task.updated': 'ev-updated', 'status.changed': 'ev-status',
'approval.requested': 'ev-gatewait', 'approval.granted': 'ev-approve',
'approval.rejected': 'ev-rejected', 'run.started': 'ev-run', 'run.finished': 'ev-run',
'project.synced': 'ev-created',
};
const AUTONOMY_LABEL = {
manual: '手动', 'auto-easy': '自动执行 Easy', 'auto-approved': '自动执行已批准',
};
// 任务树筛选:状态按状态机分组(组可整组开关,组内可单选)
const FILTER_GROUPS = [
['待办', ['init', 'ready', 'blocked']],
['进行中', ['analyzing', 'speccing', 'queued', 'executing']], // 已拆解是容器态,不算进行中
['容器', ['decomposed']],
['待审批', ['plan_review', 'spec_review', 'exec_review']],
['异常', ['failed', 'needs_attention']],
['挂起', ['paused']],
];
// 归档态:不进任务树,沉到页面底部的归档区(时间倒序分页)
const ARCHIVED = new Set(['done', 'cancelled']);
// ── 全局状态 ──
const S = {
projects: [],
currentProjectId: null,
tasks: [],
approvals: [],
events: [], // 新→旧
collapsed: new Set(),
expanded: new Set(),
rejectOpen: new Set(),
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);
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
}
function fmtTime(iso) {
try { return new Date(iso).toLocaleTimeString('zh-CN', { hour12: false }); }
catch { return iso; }
}
function fmtRel(iso) {
if (!iso) return '';
const ms = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(ms)) return String(iso);
const s = Math.floor(ms / 1000);
if (s < 60) return '刚刚';
const m = Math.floor(s / 60);
if (m < 60) return `${m} 分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} 小时前`;
return `${Math.floor(h / 24)} 天前`;
}
/** 模型名缩写显示:claude-fable-5 → fable-5 */
function shortModel(m) {
return String(m || '—').replace(/^claude-/, '');
}
/** fmtRel 的未来版:到 iso 还有多久("3h 33m" / "42m" / "<1m";已过/无效 → '' */
function fmtUntil(iso) {
if (!iso) return '';
const ms = new Date(iso).getTime() - Date.now();
if (!Number.isFinite(ms) || ms <= 0) return '';
const m = Math.ceil(ms / 60000);
if (m < 1) return '<1m';
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return m % 60 ? `${h}h ${m % 60}m` : `${h}h`;
const d = Math.floor(h / 24);
return h % 24 ? `${d}d ${h % 24}h` : `${d}d`;
}
/** 字符进度条:8 格 █/░ */
function usageBar(pct) {
const filled = Math.round(Math.min(100, Math.max(0, pct)) / 100 * 8);
return '█'.repeat(filled) + '░'.repeat(8 - filled);
}
/** 单个额度窗口(5h / 周)的展示段;>80% 琥珀、>95% 红 */
function usageSeg(label, w) {
if (!w || !Number.isFinite(Number(w.percent))) {
return `<span class="usage-seg"><span class="usage-label">${label}</span> <span class="usage-na">—</span></span>`;
}
const pct = Number(w.percent);
const lvl = pct > 95 ? ' crit' : pct > 80 ? ' warn' : '';
const until = fmtUntil(w.resetsAt);
return `<span class="usage-seg${lvl}"><span class="usage-label">${label}</span> ` +
`<span class="usage-bar">${usageBar(pct)}</span> ${pct}%` +
`${until ? `<span class="usage-reset">${until} 后重置)</span>` : ''}</span>`;
}
/** Agent 面板顶部的订阅额度行(执行 agent 烧的就是这个池子);usage=null → 灰字降级 */
function usageLine(usage) {
if (!usage || (!usage.session && !usage.weekly)) {
return `<div class="agent-usage unavail">额度信息不可用</div>`;
}
return `<div class="agent-usage">额度 ${usageSeg('5h', usage.session)} · ${usageSeg('周', usage.weekly)}</div>`;
}
// ── 轻量 Markdown 渲染(零依赖;先整体转义再做结构转换,杜绝注入) ──
function mdToHtml(src) {
const inline = (s) => s
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
const lines = esc(src).split('\n');
const out = [];
let i = 0;
let para = [];
let list = null; // {type:'ul'|'ol', items:[]}
const flushPara = () => { if (para.length) { out.push(`<p>${para.map(inline).join('<br>')}</p>`); para = []; } };
const flushList = () => {
if (list) {
out.push(`<${list.type}>${list.items.map((x) => `<li>${inline(x)}</li>`).join('')}</${list.type}>`);
list = null;
}
};
while (i < lines.length) {
const line = lines[i];
if (/^```/.test(line)) { // 围栏代码块
flushPara(); flushList();
const buf = []; i++;
while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; }
i++;
out.push(`<pre class="codeblock">${buf.join('\n')}</pre>`);
continue;
}
if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s\-:|]+\|\s*$/.test(lines[i + 1])) {
flushPara(); flushList(); // 表格
const cells = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => inline(c.trim()));
const head = cells(line);
i += 2;
const rows = [];
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(cells(lines[i])); i++; }
out.push(`<table><thead><tr>${head.map((h) => `<th>${h}</th>`).join('')}</tr></thead><tbody>${
rows.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`);
continue;
}
let m;
if ((m = line.match(/^(#{1,6})\s+(.*)$/))) { // 标题
flushPara(); flushList();
const lv = m[1].length;
out.push(`<div class="md-h md-h${lv}">${inline(m[2])}</div>`);
i++; continue;
}
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { flushPara(); flushList(); out.push('<hr>'); i++; continue; }
if ((m = line.match(/^\s*&gt;\s?(.*)$/))) { flushPara(); flushList(); out.push(`<blockquote>${inline(m[1])}</blockquote>`); i++; continue; }
if ((m = line.match(/^\s*[-*]\s+(.*)$/))) {
flushPara();
if (!list || list.type !== 'ul') { flushList(); list = { type: 'ul', items: [] }; }
list.items.push(m[1]); i++; continue;
}
if ((m = line.match(/^\s*\d+[.)]\s+(.*)$/))) {
flushPara();
if (!list || list.type !== 'ol') { flushList(); list = { type: 'ol', items: [] }; }
list.items.push(m[1]); i++; continue;
}
if (/^\s*$/.test(line)) { flushPara(); flushList(); i++; continue; }
para.push(line); i++;
}
flushPara(); flushList();
return out.join('\n');
}
// ── API ──
async function api(path, opts = {}) {
let res;
try {
res = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
} catch (e) {
throw new Error('无法连接 daemon' + e.message);
}
let data = null;
try { data = await res.json(); } catch { /* 非 JSON 响应 */ }
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
return data;
}
// ── Toast ──
function toast(msg, kind = 'err') {
const el = document.createElement('div');
el.className = `toast ${kind}`;
el.textContent = msg;
$('#toastRoot').appendChild(el);
setTimeout(() => { el.classList.add('out'); setTimeout(() => el.remove(), 350); }, 4200);
}
// ── 数据加载 ──
async function loadProjects() {
S.projects = await api('/api/projects');
if (!S.currentProjectId && S.projects.length) S.currentProjectId = S.projects[0].id;
if (S.currentProjectId && !S.projects.some((p) => p.id === S.currentProjectId)) {
S.currentProjectId = S.projects.length ? S.projects[0].id : null;
}
}
async function loadProjectData() {
const pid = S.currentProjectId;
if (!pid) { S.tasks = []; S.events = []; S.approvals = []; return; }
const [tasks, events, approvals] = await Promise.all([
api(`/api/projects/${pid}/tasks`),
api(`/api/projects/${pid}/events`),
api(`/api/approvals?projectId=${encodeURIComponent(pid)}`),
]);
S.tasks = tasks;
S.events = events.slice().reverse(); // 接口升序 → 展示新在前
S.approvals = approvals;
}
// agents 端点未就绪(404)时静默降级为空态,不打扰用户
async function loadAgents() {
try { S.agents = await api('/api/agents'); }
catch { S.agents = null; }
}
async function refresh() {
try {
await loadProjectData();
renderAll();
} catch (e) { toast(e.message); }
}
async function fullRefresh() {
try {
await loadProjects();
await Promise.all([loadProjectData(), loadAgents()]);
renderAll();
} catch (e) { toast(e.message); }
}
// ── 渲染:草稿保护(重绘前保存输入框内容与焦点) ──
function snapshotDrafts() {
const map = new Map();
document.querySelectorAll('textarea[id], input[id][type=text]').forEach((el) => {
if (el.value) map.set(el.id, el.value);
});
const focusId = document.activeElement && document.activeElement.id;
return { map, focusId };
}
function restoreDrafts(snap) {
snap.map.forEach((v, id) => {
const el = document.getElementById(id);
if (el && !el.value) el.value = v;
});
if (snap.focusId) {
const el = document.getElementById(snap.focusId);
if (el) { el.focus(); try { el.selectionStart = el.value.length; } catch { /* 非文本 */ } }
}
}
function renderAll() {
const snap = snapshotDrafts();
renderSidebar();
renderTopbar();
renderAgents();
renderGates();
renderTree();
renderFilterBar();
renderEvents();
renderParentOptions();
renderArchive();
renderPreview();
restoreDrafts(snap);
}
// ── 渲染:侧栏 ──
function renderSidebar() {
const ul = $('#projectList');
if (!S.projects.length) {
ul.innerHTML = `<li class="p-empty">暂无项目<br>点「+ 新建」登记第一个仓库</li>`;
return;
}
ul.innerHTML = S.projects.map((p) => `
<li class="${p.id === S.currentProjectId ? 'active' : ''}" data-action="select-project" data-id="${esc(p.id)}">
<span class="p-name">${esc(p.name)}</span>
<span class="p-path">${esc(p.repoPath)}</span>
</li>`).join('');
}
function renderTopbar() {
const p = S.projects.find((x) => x.id === S.currentProjectId);
$('#projTitle').textContent = p ? p.name : '未选择项目';
$('#projMeta').textContent = p
? `${p.repoPath} · 分支 ${p.defaultBranch} · ${p.status === 'active' ? '活跃' : '暂停'} · 任务 ${S.tasks.length}`
: '';
// 徽章组(全部不含已完成: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 = 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 = 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;
el.classList.toggle('zero', n === 0);
};
setBdg('#bdgGate', S.approvals.length);
setBdg('#bdgReady', nReady);
setBdg('#bdgRun', nRun);
setBdg('#bdgBlocked', nBlocked);
setBdg('#bdgTotal', alive.length);
// 同步 todo / 项目配置入口
$('#topActions').hidden = !p;
if (p) {
const sb = $('#btnSync');
const noTodo = p.hasTodoJson === false; // 字段未交付(undefined)时不禁用,错误由 toast 兜底
sb.disabled = noTodo;
sb.title = noTodo
? '未发现 todo.json —— 约定路径 <repo>/todo/todo.json'
: '从 <repo>/todo/todo.json 同步任务';
$('#syncMeta').textContent = p.lastSyncAt ? `${fmtRel(p.lastSyncAt)}同步` : '';
$('#cfgCurrent').textContent =
`当前:并发 ${p.concurrency ?? '—'} · 模式 ${AUTONOMY_LABEL[p.autonomy] || p.autonomy || '—'}`;
} else {
$('#configPanel').hidden = true;
}
}
// ── 渲染:Agent 执行面板 ──
function renderAgents() {
const body = $('#agentBody');
const a = S.agents;
const total = a && Number(a.totalActive) > 0 ? Number(a.totalActive) : 0;
$('#agentTotal').textContent = total ? `· ${total}` : '';
const usage = usageLine(a ? a.usage : null);
if (!total) {
body.innerHTML = `${usage}<div class="agent-empty">无 agent 在执行(自动执行将在 Phase 2 启用)</div>`;
return;
}
const groups = (a.agents || []).filter((g) => g.active && g.active.length);
body.innerHTML = `
${usage}
<div class="agent-flex">
<div class="agent-big">${total}<span class="agent-big-sub">ACTIVE</span></div>
<div class="agent-groups">
${groups.map((g) => `
<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">
<span class="agent-run-dot"></span>
<span class="agent-run-title">${esc(r.taskTitle)}</span>
<span class="agent-run-kind">${esc(r.kind || '')}</span>
<span class="agent-run-time">${fmtRel(r.startedAt)}开始</span>
</div>`).join('')}
</div>`).join('')}
</div>
</div>`;
}
// ── 渲染:审批闸 ──
function gateDocs(t) {
const gate = GATE_OF[t.status];
const blocks = [];
const doc = (label, text) => blocks.push(
`<div class="gate-doc-label">${label}</div>` +
(text ? `<div class="doc md">${mdToHtml(text)}</div>` : `<pre class="doc empty">(未填写)</pre>`));
if (gate === 'plan') doc('PLAN · 分析与拆解', t.plan);
if (gate === 'spec') doc('SPEC · 改动方案', t.spec);
if (gate === 'exec') {
if (t.result) {
const r = t.result;
blocks.push(reviewReportBlocks(r)); // CODE REVIEW + 安全审计 两节(各带 verdict 徽章),置于 DIFF 摘要上方
blocks.push(`<div class="gate-doc-label">RESULT · 执行结果</div>
<dl class="result-kv">
${r.branch ? `<dt>分支</dt><dd>${esc(r.branch)}</dd>` : ''}
${r.worktree ? `<dt>worktree</dt><dd>${esc(r.worktree)}</dd>` : ''}
${r.prUrl ? `<dt>PR</dt><dd><a href="${esc(r.prUrl)}" target="_blank">${esc(r.prUrl)}</a></dd>` : ''}
</dl>`);
if (r.diffSummary) blocks.push(`<div class="gate-doc-label">DIFF 摘要</div><pre class="doc">${esc(r.diffSummary)}</pre>`);
} else {
blocks.push(`<pre class="doc empty">(无执行结果记录)</pre>`);
}
if (t.operations) doc('OPERATIONS · 执行的操作', t.operations);
else if (t.spec) doc('SPEC · 改动方案', t.spec);
}
return blocks.join('');
}
function renderGates() {
const sec = $('#gateSection');
if (!S.approvals.length) { sec.hidden = true; return; }
sec.hidden = false;
const proj = S.projects.find((x) => x.id === S.currentProjectId);
const defBranch = proj ? proj.defaultBranch : 'main';
$('#gateList').innerHTML = S.approvals.map((t) => {
const gate = GATE_OF[t.status];
const rejOpen = S.rejectOpen.has(t.id);
return `
<div class="gate-card">
<div class="gate-card-head">
<span class="gate-kind">${GATE_LABEL[gate]}</span>
<span class="gate-title">${esc(t.title)}</span>
${cplxBadge(t.complexity)}
${statusChip(t.status)}
<button class="btn btn-ghost btn-xs gate-preview-btn" data-action="preview-open" data-id="${esc(t.id)}" title="全屏预览方案">⛶ 预览</button>
</div>
<div class="gate-body">${gateDocs(t)}</div>
<div class="gate-actions">
<button class="btn btn-accept" data-action="gate-accept" data-id="${esc(t.id)}">${gate === 'exec' ? '✓ 通过并合并' : '✓ 接受'}</button>${gate === 'exec' ? `<button class="btn btn-ghost btn-xs" data-action="gate-accept" data-merge="false" data-id="${esc(t.id)}" title="仅标记通过,不执行本地合并(适用于目标分支被工作区检出等场景)">仅通过</button>` : ''}
<button class="btn btn-reject" data-action="gate-reject-toggle" data-id="${esc(t.id)}">✗ 拒绝</button>
${gate === 'exec' ? `<span class="proj-meta">通过 = 自动把任务分支合并到 ${esc(defBranch)}(仅本地合并,不 push</span>` : ''}
</div>
${rejOpen ? `
<div class="reject-form">
<textarea id="rej-${esc(t.id)}" placeholder="驳回意见(必填)——说明问题与改进方向"></textarea>
<button class="btn btn-reject" data-action="gate-reject-confirm" data-id="${esc(t.id)}">确认驳回</button>
<button class="btn" data-action="gate-reject-toggle" data-id="${esc(t.id)}">收起</button>
</div>` : ''}
</div>`;
}).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: 'code review', security: '安全审计', 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 ? `${reviewReportBlocks(r)}
<div class="gate-doc-label">执行结果</div>
<dl class="result-kv">
${r.branch ? kv('分支', esc(r.branch)) : ''}
${r.worktree ? kv('worktree', esc(r.worktree)) : ''}
${r.prUrl ? kv('合并', esc(r.prUrl)) : ''}
${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');
if (!S.previewId) { root.hidden = true; root.innerHTML = ''; return; }
const t = S.approvals.find((x) => x.id === S.previewId) || S.tasks.find((x) => x.id === S.previewId);
// 任务已离开闸(被裁决/状态变化)→ 自动关闭
if (!t || !GATE_OF[t.status]) { S.previewId = null; S.previewReject = false; root.hidden = true; root.innerHTML = ''; return; }
const gate = GATE_OF[t.status];
root.hidden = false;
root.innerHTML = `
<div class="preview-mask" data-action="preview-close"></div>
<div class="preview-panel">
<div class="preview-head">
<span class="gate-kind">${GATE_LABEL[gate]}</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="preview-close" title="关闭(Esc">✕</button>
</div>
<div class="preview-body">${gateDocs(t)}</div>
<div class="preview-foot">
<button class="btn btn-accept" data-action="gate-accept" data-id="${esc(t.id)}">${gate === 'exec' ? '✓ 通过并合并' : '✓ 接受'}</button>${gate === 'exec' ? `<button class="btn btn-ghost btn-xs" data-action="gate-accept" data-merge="false" data-id="${esc(t.id)}" title="仅标记通过,不执行本地合并(适用于目标分支被工作区检出等场景)">仅通过</button>` : ''}
<button class="btn btn-reject" data-action="preview-reject-toggle" data-id="${esc(t.id)}">✗ 拒绝</button>
${gate === 'exec' ? (() => {
const pp = S.projects.find((x) => x.id === S.currentProjectId);
return `<span class="proj-meta">通过 = 合并到 ${esc(pp ? pp.defaultBranch : 'main')}(仅本地,不 push</span>`;
})() : ''}
<span class="proj-meta">id ${esc(t.id)} · 更新 ${fmtTime(t.updatedAt)}</span>
${S.previewReject ? `
<div class="reject-form preview-reject">
<textarea id="prev-rej-${esc(t.id)}" placeholder="驳回意见(必填)——说明问题与改进方向"></textarea>
<button class="btn btn-reject" data-action="gate-reject-confirm" data-id="${esc(t.id)}" data-ta="prev-rej-${esc(t.id)}">确认驳回</button>
<button class="btn" data-action="preview-reject-toggle" data-id="${esc(t.id)}">收起</button>
</div>` : ''}
</div>
</div>`;
if (S.previewReject) {
const ta = document.getElementById(`prev-rej-${t.id}`);
if (ta) ta.focus();
}
}
// ── 渲染:任务树 ──
// taskId 传入时徽章可点击 → 弹出复杂度选择(审批闸卡片不传,保持只读)
function cplxBadge(c, taskId) {
const text = CPLX_LABEL[c] || esc(c);
if (!taskId) return `<span class="cplx cplx-${esc(c)}">${text}</span>`;
const open = S.cplxMenuFor === taskId;
const pop = open ? `<span class="cplx-pop">${['hard', 'medium', 'easy'].map((x) =>
`<button type="button" class="cplx cplx-${x} ${x === c ? 'cur' : ''}" data-action="cplx-set" data-id="${esc(taskId)}" data-cplx="${x}">${CPLX_LABEL[x]}</button>`,
).join('')}</span>` : '';
return `<span class="cplx-wrap">
<button type="button" class="cplx cplx-${esc(c)} cplx-btn" data-action="cplx-menu" data-id="${esc(taskId)}" title="点击修改复杂度(状态会按新复杂度重置)">${text}</button>
${pop}
</span>`;
}
function statusChip(st) {
return `<span class="chip chip-${STATUS_GROUP[st] || 'idle'}">${STATUS_LABEL[st] || esc(st)}</span>`;
}
// 复审结论徽章: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>`;
}
// 复审报告区块:「CODE REVIEW」「安全审计」两节,各自 verdict 徽章(通过绿/拒绝红/未复审灰)
function reviewReportBlocks(r) {
if (!r) return '';
const sec = (label, summary, verdict) => {
const head = `<div class="gate-doc-label">${label} ${verdictBadge(verdict)}</div>`;
if (summary) return `${head}<div class="doc md review-summary">${mdToHtml(summary)}</div>`;
return `${head}<pre class="doc empty">(无复审记录)</pre>`;
};
return sec('CODE REVIEW', r.summary, r.verdict) + sec('安全审计', r.securitySummary, r.securityVerdict);
}
function childrenMap(list = S.tasks) {
const m = new Map();
for (const t of list) {
const key = t.parentId || '__root__';
if (!m.has(key)) m.set(key, []);
m.get(key).push(t);
}
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.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.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;
}
// 命中节点 + 其全部祖先(保留父链以维持树形)
function computeVisible() {
S.matchCount = 0;
if (!filterActive()) return null;
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 alive) {
if (!matchTask(t)) continue;
matched.add(t.id);
let cur = t;
while (cur && !visible.has(cur.id)) {
visible.add(cur.id);
cur = cur.parentId ? byId.get(cur.parentId) : null;
}
}
S.matchCount = matched.size;
return { visible, matched };
}
function renderFilterBar() {
const bar = $('#filterBar');
bar.hidden = !S.currentProjectId;
if (bar.hidden) return;
const f = S.filter;
$('#filterCplx').innerHTML = ['hard', 'medium', 'easy'].map((c) =>
`<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) => {
// 组选与单选分离:组 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}" title="按整组筛选">${name}</button>
${members}
</span>`;
}).join('');
const active = filterActive();
$('#filterClear').hidden = !active;
$('#filterCount').textContent = active ? `命中 ${S.matchCount} / ${S.tasks.length}` : '';
}
function renderTree() {
const root = $('#taskTree');
if (!S.currentProjectId) {
root.innerHTML = `<div class="tree-empty"><b>NO PROJECT</b>先在左侧新建或选择一个项目</div>`;
return;
}
if (!S.tasks.length) {
root.innerHTML = `<div class="tree-empty"><b>EMPTY</b>还没有任务 —— 点上方「+ 新建任务」开始</div>`;
return;
}
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>`;
return;
}
const byId = new Map(S.tasks.map((x) => [x.id, x]));
const renderNode = (t) => {
if (vis && !vis.visible.has(t.id)) return '';
const kids = m.get(t.id) || [];
const collapsed = !vis && S.collapsed.has(t.id); // 筛选时强制展开,保证命中可见
const expanded = S.expanded.has(t.id);
const isGate = !!GATE_OF[t.status];
const isCtx = vis && !vis.matched.has(t.id); // 仅作为父链保留的节点,弱化显示
const caret = kids.length
? `<span class="caret ${collapsed ? '' : 'open'}" data-action="toggle-collapse" data-id="${esc(t.id)}">▶</span>`
: `<span class="caret leaf">·</span>`;
return `
<div class="task-node depth-${t.depth}">
<div class="task-row ${isGate ? 'is-gate' : ''} ${expanded ? 'expanded' : ''} ${isCtx ? 'filter-ctx' : ''}" data-action="toggle-detail" data-id="${esc(t.id)}">
${caret}
<span class="t-title">${esc(t.title)}<span class="t-id">${esc(t.id.slice(-6))}</span></span>
<span class="spacer"></span>
${(() => {
// blocked(系统按依赖自动落位)→ 显示在等哪几条
if (t.status !== 'blocked' || !(t.deps || []).length) return '';
const unmet = t.deps.filter((d) => byId.get(d)?.status !== 'done');
if (!unmet.length) return '';
return `<span class="t-deps-wait" title="等待依赖完成:${unmet.map((d) => esc(byId.get(d)?.title || d)).join('、')}">⛓ 等依赖 ${unmet.length}</span>`;
})()}
<span class="t-prio ${t.priority === 0 ? 'hot' : ''}">P${t.priority}</span>
${cplxBadge(t.complexity, t.id)}
${statusChip(t.status)}
</div>
${expanded ? renderDetail(t) : ''}
${kids.length && !collapsed ? `<div class="task-children">${kids.map(renderNode).join('')}</div>` : ''}
</div>`;
};
root.innerHTML = (m.get('__root__') || []).map(renderNode).join('');
}
// ── 渲染:任务详情 ──
function writableField(t) {
if (t.status === 'analyzing') return { field: 'plan', label: '分析与拆解(plan', next: 'plan_review' };
if (t.status === 'speccing') return { field: 'spec', label: '改动方案(spec', next: 'spec_review' };
if (t.status === 'ready') return { field: 'operations', label: '将执行的操作(operations', next: null };
return null;
}
function renderDetail(t) {
const parts = [];
const w = writableField(t);
// 产出全部只读展示:写产出与提交评审由 Claude Code 经 MCP 完成,看板只留用户动作(审批/配置/同步)
const docs = [['plan', 'PLAN · 分析拆解'], ['spec', 'SPEC · 方案'], ['operations', 'OPERATIONS · 操作']];
for (const [f, label] of docs) {
if (!t[f]) continue;
parts.push(`<div><div class="detail-label">${label}</div><div class="doc md">${mdToHtml(t[f])}</div></div>`);
}
// 当前阶段应产出但还没有内容 → 占位提示
if (w && !t[w.field]) {
parts.push(`<div><div class="detail-label">${w.label}</div><div class="doc doc-pending">待 Claude Code 产出(经 MCP 写入并提交评审)</div></div>`);
}
// result
if (t.result) {
const r = t.result;
parts.push(`
<div>${reviewReportBlocks(r)}
<div class="detail-label">RESULT · 执行结果</div>
<dl class="result-kv">
<dt>分支</dt><dd>${esc(r.branch || '—')}</dd>
<dt>worktree</dt><dd>${esc(r.worktree || '—')}</dd>
${r.prUrl ? `<dt>合并</dt><dd>${esc(r.prUrl)}</dd>` : ''}
${r.commits && r.commits.length ? `<dt>commits</dt><dd>${r.commits.map(esc).join('<br>')}</dd>` : ''}
</dl>
${r.diffSummary ? `<pre class="doc" style="margin-top:6px">${esc(r.diffSummary)}</pre>` : ''}
</div>`);
}
// 审批历史
if (t.approvals && t.approvals.length) {
parts.push(`
<div><div class="detail-label">审批历史</div>
${t.approvals.map((a) => `
<div class="approval-item">
<span class="ap-act-${a.action}">${a.action === 'accept' ? '✓ 通过' : '✗ 驳回'}</span>
<span class="ap-gate">${GATE_LABEL[a.gate] || esc(a.gate)} · ${esc(a.actor)}</span>
<span class="ap-time">${fmtTime(a.at)}</span>
${a.reason ? `<span class="ap-reason">${esc(a.reason)}</span>` : ''}
</div>`).join('')}
</div>`);
}
// 依赖列表:标题 + 状态 chip,未完成的高亮(全部 done 本任务才可被领取)
if (t.deps && t.deps.length) {
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="点击定位到该任务">
<span class="dep-mark">${ok ? '✓' : '⛓'}</span>
${dt ? statusChip(dt.status) : ''}
<span class="dep-title">${esc(dt ? dt.title : d)}</span>
<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>`);
}
if (!parts.length) parts.push(`<div class="proj-meta">暂无产出与历史 —— 状态:${STATUS_LABEL[t.status]}</div>`);
parts.push(`<div class="proj-meta">id ${esc(t.id)} · 深度 ${t.depth} · 创建 ${fmtTime(t.createdAt)} · 更新 ${fmtTime(t.updatedAt)}</div>`);
return `<div class="task-detail"><div class="detail-grid">${parts.join('')}</div></div>`;
}
// ── 渲染:事件流 ──
function eventDetail(e) {
const t = S.tasks.find((x) => x.id === e.taskId);
const name = t ? t.title : (e.taskId ? e.taskId.slice(-6) : '');
const p = e.payload || {};
if (e.type === 'status.changed') {
return `${name}${STATUS_LABEL[p.from] || p.from}${STATUS_LABEL[p.to] || p.to}`;
}
if (e.type === 'approval.granted' || e.type === 'approval.rejected') {
const r = p.reason ? `${p.reason}` : '';
return `${name} · ${GATE_LABEL[p.gate] || p.gate}${r}`;
}
if (e.type === 'task.created') {
if (p.kind === 'project') return `项目「${p.name || ''}」已登记`;
return `${p.title || name}${CPLX_LABEL[p.complexity] || ''}`;
}
if (e.type === 'task.updated') return `${name} · 字段 ${p.field || ''}`;
if (e.type === 'project.synced') {
return `新建 ${p.created ?? 0} · 推进 done ${p.doneAdvanced ?? 0} · 跳过 ${p.skipped ?? 0}`;
}
if (e.type === 'run.started' || e.type === 'run.finished') {
return `${name} · ${p.kind || ''} ${p.status || ''}`;
}
return name;
}
function renderEvents() {
const ul = $('#eventList');
if (!S.events.length) {
ul.innerHTML = `<li class="ev-empty">暂无事件</li>`;
return;
}
ul.innerHTML = S.events.slice(0, 80).map((e) => `
<li class="${EVENT_CLASS[e.type] || ''}">
<div class="ev-head">
<span class="ev-type">${EVENT_LABEL[e.type] || esc(e.type)}</span>
<span class="ev-time">${fmtTime(e.at)}</span>
</div>
<div class="ev-detail">${eventDetail(e)}</div>
</li>`).join('');
}
// ── 渲染:父任务下拉 ──
function renderParentOptions() {
const sel = document.querySelector('#newTaskPanel select[name=parentId]');
const cur = sel.value;
sel.innerHTML = `<option value="">(顶层)</option>` +
S.tasks.map((t) => `<option value="${esc(t.id)}">${esc('· '.repeat(t.depth - 1) + t.title)}</option>`).join('');
sel.value = cur;
}
// ── 动作 ──
async function act(fn, okMsg) {
try {
await fn();
if (okMsg) toast(okMsg, 'ok');
await refresh();
} catch (e) { toast(e.message); }
}
document.addEventListener('click', (ev) => {
const el = ev.target.closest('[data-action]');
// 复杂度下拉:点击弹层外任意处关闭
if (S.cplxMenuFor && !ev.target.closest('.cplx-wrap')) {
S.cplxMenuFor = null;
renderTree();
}
if (!el) return;
const action = el.dataset.action;
const id = el.dataset.id;
switch (action) {
case 'select-project':
if (S.currentProjectId !== id) {
S.currentProjectId = id;
S.expanded.clear(); S.collapsed.clear(); S.rejectOpen.clear();
S.cplxMenuFor = null;
$('#configPanel').hidden = true;
refresh();
}
break;
case 'open-new-project':
$('#modalRoot').hidden = false;
document.querySelector('#newProjectForm input[name=name]').focus();
break;
case 'close-modal':
$('#modalRoot').hidden = true;
break;
case 'toggle-new-task': {
const p = $('#newTaskPanel');
p.hidden = !p.hidden;
if (!p.hidden) p.querySelector('input[name=title]').focus();
break;
}
case 'toggle-collapse':
ev.stopPropagation();
S.collapsed.has(id) ? S.collapsed.delete(id) : S.collapsed.add(id);
renderTree();
break;
case 'toggle-detail': {
// 点的是 caret 时由上面分支处理(stopPropagation);输入区内点击不折叠
if (ev.target.closest('.task-detail')) break;
S.expanded.has(id) ? S.expanded.delete(id) : S.expanded.add(id);
renderTree();
break;
}
case 'gate-accept': {
const noMerge = el.dataset.merge === 'false';
act(() => api(`/api/tasks/${id}/decide`, {
method: 'POST', body: JSON.stringify(noMerge ? { action: 'accept', merge: false } : { action: 'accept' }),
}), noMerge ? '已通过(未合并,分支保留)' : '已接受');
break;
}
case 'gate-reject-toggle':
S.rejectOpen.has(id) ? S.rejectOpen.delete(id) : S.rejectOpen.add(id);
renderGates();
if (S.rejectOpen.has(id)) {
const ta = document.getElementById(`rej-${id}`);
if (ta) ta.focus();
}
break;
case 'preview-open':
S.previewId = id;
S.previewReject = false;
renderPreview();
break;
case 'preview-close':
S.previewId = null;
S.previewReject = false;
renderPreview();
break;
case 'preview-reject-toggle':
S.previewReject = !S.previewReject;
renderPreview();
break;
case 'gate-reject-confirm': {
const ta = document.getElementById(el.dataset.ta || `rej-${id}`);
const reason = ta ? ta.value.trim() : '';
if (!reason) { toast('驳回意见不能为空'); if (ta) ta.focus(); return; }
S.rejectOpen.delete(id);
act(() => api(`/api/tasks/${id}/decide`, {
method: 'POST', body: JSON.stringify({ action: 'reject', reason }),
}), '已驳回,任务退回返工');
break;
}
case 'goto-task': {
const target = S.tasks.find((x) => x.id === id);
if (!target) { toast('任务不在当前项目'); break; }
// 被筛选隐藏时清除筛选,保证可见
const vis = computeVisible();
if (vis && !vis.visible.has(id)) {
S.filter.cplx.clear(); S.filter.status.clear(); S.filter.statusGroups.clear(); S.filter.kw = '';
renderFilterBar();
}
// 展开全部祖先 + 展开目标详情
let cur = target;
while (cur && cur.parentId) {
S.collapsed.delete(cur.parentId);
cur = S.tasks.find((x) => x.id === cur.parentId);
}
S.expanded.add(id);
renderTree();
const row = document.querySelector(`.task-row[data-id="${CSS.escape(id)}"]`);
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
row.classList.add('flash');
setTimeout(() => row.classList.remove('flash'), 1800);
}
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();
break;
case 'cplx-set': {
const c = el.dataset.cplx;
S.cplxMenuFor = null;
act(() => api(`/api/tasks/${id}`, {
method: 'PATCH', body: JSON.stringify({ complexity: c }),
}), `复杂度已改为 ${CPLX_LABEL[c]}(状态按新复杂度重置)`);
break;
}
// ── 任务树筛选 ──
case 'filter-cplx': {
const c = el.dataset.cplx;
S.filter.cplx.has(c) ? S.filter.cplx.delete(c) : S.filter.cplx.add(c);
renderTree(); renderFilterBar();
break;
}
case 'filter-status': {
const st = el.dataset.st;
S.filter.status.has(st) ? S.filter.status.delete(st) : S.filter.status.add(st);
renderTree(); renderFilterBar();
break;
}
case 'filter-group': {
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();
break;
// ── 同步 todo ──
case 'sync-todo': {
if (!S.currentProjectId) break;
el.disabled = true;
S.syncReqAt = Date.now();
api(`/api/projects/${S.currentProjectId}/sync`, { method: 'POST', body: JSON.stringify({}) })
.then((r) => {
toast(`同步完成:新建 ${r.created ?? 0} · 推进 done ${r.doneAdvanced ?? 0} · 跳过 ${r.skipped ?? 0}`, 'ok');
(r.warnings || []).forEach((wmsg) => toast(String(wmsg), 'warn'));
return fullRefresh();
})
.catch((e) => { toast(e.message); el.disabled = false; });
break;
}
// ── Agent 配置 ──
case 'toggle-config': {
const panel = $('#configPanel');
panel.hidden = !panel.hidden;
if (!panel.hidden) {
const p = S.projects.find((x) => x.id === S.currentProjectId);
if (p) {
$('#cfgConcurrency').value = p.concurrency ?? 1;
$('#cfgAutonomy').value = AUTONOMY_LABEL[p.autonomy] ? p.autonomy : 'manual';
$('#cfgVerifyCmd').value = p.verifyCmd ?? '';
$('#cfgModel').value = p.model ?? '';
}
}
break;
}
case 'save-config': {
if (!S.currentProjectId) break;
const cc = Number($('#cfgConcurrency').value);
if (!Number.isInteger(cc) || cc < 1) { toast('最大并发必须是 ≥1 的整数'); break; }
const autonomy = $('#cfgAutonomy').value;
const verifyCmd = $('#cfgVerifyCmd').value.trim() || null;
const model = $('#cfgModel').value.trim() || null;
act(async () => {
await api(`/api/projects/${S.currentProjectId}`, {
method: 'PATCH', body: JSON.stringify({ concurrency: cc, autonomy, verifyCmd, model }),
});
await loadProjects(); // 刷新当前值显示
$('#configPanel').hidden = true; // 保存成功自动收起
}, '配置已保存');
break;
}
}
});
// 阻止详情区域内点击冒泡触发折叠
document.addEventListener('click', (ev) => {
if (ev.target.closest('.task-detail') && !ev.target.closest('[data-action]')) {
ev.stopPropagation();
}
}, true);
// ── 表单提交 ──
$('#newProjectForm').addEventListener('submit', (ev) => {
ev.preventDefault();
const f = ev.target;
const name = f.name.value.trim();
const repoPath = f.repoPath.value.trim();
if (!name || !repoPath) { toast('名称与仓库路径必填'); return; }
const body = { name, repoPath };
if (f.defaultBranch.value.trim()) body.defaultBranch = f.defaultBranch.value.trim();
if (f.verifyCmd.value.trim()) body.verifyCmd = f.verifyCmd.value.trim();
act(async () => {
const p = await api('/api/projects', { method: 'POST', body: JSON.stringify(body) });
S.currentProjectId = p.id;
f.reset();
$('#modalRoot').hidden = true;
await loadProjects();
}, `项目「${name}」已创建`);
});
$('#newTaskPanel').addEventListener('submit', (ev) => {
ev.preventDefault();
const f = ev.target;
if (!S.currentProjectId) { toast('请先选择项目'); return; }
const title = f.title.value.trim();
if (!title) { toast('标题必填'); return; }
const body = {
title,
complexity: f.complexity.value,
priority: Number(f.priority.value ?? 1),
};
if (f.parentId.value) body.parentId = f.parentId.value;
act(async () => {
await api(`/api/projects/${S.currentProjectId}/tasks`, {
method: 'POST', body: JSON.stringify(body),
});
f.title.value = '';
}, '任务已创建');
});
// 关键字筛选(轻防抖)
let kwTimer = null;
$('#filterKw').addEventListener('input', (ev) => {
S.filter.kw = ev.target.value;
clearTimeout(kwTimer);
kwTimer = setTimeout(() => { renderTree(); renderFilterBar(); }, 150);
});
// 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;
}
});
// ── WebSocket 实时刷新(指数退避重连) ──
let wsAttempt = 0;
let refreshTimer = null;
function setWsState(on, text) {
$('#wsDot').className = `ws-dot ${on ? 'on' : 'off'}`;
$('#wsText').textContent = text;
}
function scheduleRefresh() {
if (refreshTimer) return;
refreshTimer = setTimeout(() => { refreshTimer = null; refresh(); }, 200);
}
function connectWs() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => {
wsAttempt = 0;
setWsState(true, '实时连接');
fullRefresh(); // 重连后补齐错过的状态
};
ws.onmessage = (msg) => {
let evt;
try { evt = JSON.parse(msg.data); } catch { return; }
if (evt.type === 'project.synced') {
// 本端刚发起的 sync 由 POST 响应负责 toast,避免重复
if (Date.now() - S.syncReqAt > 3000) {
const p = evt.payload || {};
toast(`todo 同步:新建 ${p.created ?? 0} · 推进 done ${p.doneAdvanced ?? 0} · 跳过 ${p.skipped ?? 0}`, 'ok');
}
fullRefresh();
return;
}
if (evt.type === 'run.started' || evt.type === 'run.finished') {
loadAgents().then(renderAgents).catch(() => {});
}
if (evt.projectId === S.currentProjectId) {
scheduleRefresh();
} else if (evt.type === 'task.created' && evt.payload && evt.payload.kind === 'project') {
loadProjects().then(renderSidebar).catch(() => {});
}
};
const retry = () => {
wsAttempt += 1;
const delay = Math.min(30000, 1000 * Math.pow(2, wsAttempt - 1));
setWsState(false, `已断开 · ${Math.round(delay / 1000)}s 后重连`);
setTimeout(connectWs, delay);
};
ws.onclose = retry;
ws.onerror = () => { try { ws.close(); } catch { /* noop */ } };
}
// 相对时间(agent 开始时间 / 上次同步)定期重绘
setInterval(() => { renderAgents(); renderTopbar(); }, 30000);
// ── 启动 ──
fullRefresh().then(connectWs);