Files
maestro/web/app.js
T
wangjia 1c4a81b3f3 feat(web): SVG 图标集替换 Unicode —— tsk_3uhHOGU0qzj0
用 design/assets/icons/ 的 15 个双色 SVG 图标替换现网纯 Unicode 图标:

• 拷贝 design/assets/icons/*.svg → web/assets/icons/(15 个)
• app.js 顶部新增 ICONS 对象 + icon() 内联 helper
• index.html:徽章组(gate/ready/running/deps)、配置按钮(config)、
  同步按钮(sync)、搜索输入框(search + filter-search-wrap 包装)、
  清除筛选按钮(close)、「新建」/「新建任务」按钮(add)
  各区块头加语义图标:project/task/gate/running/flow
• app.js 模板字符串:⛓→deps、✕→close(任务行取消/预览关闭/归档关闭)、
  依赖等待标签、ap-reason→reason SVG、分支显示→git、事件项 ev-type→event
• style.css:新增 svg.icon 基础尺寸规则、filter-search-wrap 布局、
  各语义上下文 --icon-accent 注入(暗/浅主题均用 CSS 变量自动适配)、
  .ap-reason::before 移除 content:'↳' 改用 SVG
• 品牌字符保留不动:▍▮→·— 及状态 CSS 圆点

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

1818 lines
81 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';
// ── SVG 图标集(v1.1 双色调,24×242px 圆头;currentColor=基础描边,--icon-accent=信号色) ──
const ICONS = {
task: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="2.5"/><polyline points="8 12 11 15 16 9" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
project: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 3h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg>`,
gate: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3L22 21L2 21Z"/><line x1="12" y1="10" x2="12" y2="14" stroke="var(--icon-accent,#b88ef5)"/><line x1="12" y1="17.5" x2="12" y2="17.51" stroke="var(--icon-accent,#b88ef5)"/></svg>`,
ready: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 4L19 12L7 20Z" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
running: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3" fill="var(--icon-accent,#59c8d8)" stroke="none"/></svg>`,
sync: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
config: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="12" y2="6"/><circle cx="15" cy="6" r="2.5" stroke="var(--icon-accent,#5fdd7d)"/><line x1="18" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="5.5" y2="12"/><circle cx="9" cy="12" r="2.5" stroke="var(--icon-accent,#5fdd7d)"/><line x1="12.5" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="12.5" y2="18"/><circle cx="16" cy="18" r="2.5" stroke="var(--icon-accent,#5fdd7d)"/><line x1="19.5" y1="18" x2="21" y2="18"/></svg>`,
search: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><line x1="15.5" y1="15.5" x2="21" y2="21" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
git: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
deps: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><line x1="10" y1="10" x2="14" y2="14" stroke="var(--icon-accent,#f0b429)"/></svg>`,
reason: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="5 4 5 14 18 14"/><polyline points="13 9 18 14 13 19" stroke="var(--icon-accent,#f0b429)"/></svg>`,
event: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="2 12 7 12 10 5 14 19 17 12 22 12" stroke="var(--icon-accent,#59c8d8)"/></svg>`,
flow: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="12" x2="19" y2="12"/><polyline points="13 6 19 12 13 18" stroke="var(--icon-accent,#59c8d8)"/></svg>`,
add: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="4" x2="12" y2="20" stroke="var(--icon-accent,#5fdd7d)"/><line x1="4" y1="12" x2="20" y2="12" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
close: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="5" x2="19" y2="19"/><line x1="19" y1="5" x2="5" y2="19"/></svg>`,
};
/** 返回指定图标的内联 SVG HTML(name 不存在时返回空串) */
function icon(name) { return ICONS[name] || ''; }
// ── 模型常量(与 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']);
// 允许编辑 complexity / deps 的状态(与 store.ts 的 COMPLEXITY_EDITABLE 对齐:尚未进入执行链路)
const STRUCT_EDITABLE = new Set(['init', 'analyzing', 'speccing', 'ready', 'plan_review', 'spec_review', 'blocked']);
// ── 全局状态 ──
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
metrics: null, // GET /api/metrics 结果(失败时为 null
cplxMenuFor: null, // 复杂度下拉打开的任务 id
syncReqAt: 0, // 本端发起 sync 的时间(避免 WS 重复 toast)
previewId: null, // 全局预览中的任务 id
previewReject: false, // 预览层内驳回意见框是否展开
archive: { page: 1, size: 20 }, // 归档区分页
confirmCancel: null, // 取消确认中的任务 id
confirmDelete: null, // 删除确认中的任务 id
depsEditFor: null, // 正在编辑依赖的任务 id
depsEditSel: null, // 编辑中已选依赖 id 集合(Set)
};
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-/, '');
}
/** 毫秒 → 紧凑时长("45s" / "1m 23s" / "2h 5m");无效 → '—' */
function fmtDur(ms) {
const n = Number(ms);
if (!Number.isFinite(n) || n <= 0) return '—';
const s = Math.round(n / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return s % 60 ? `${m}m ${s % 60}s` : `${m}m`;
const h = Math.floor(m / 60);
return m % 60 ? `${h}h ${m % 60}m` : `${h}h`;
}
/** 0-1 比率 → 百分比文本;null/无效 → '—' */
function fmtRate(rate) {
return rate == null || !Number.isFinite(Number(rate)) ? '—' : `${Math.round(Number(rate) * 100)}%`;
}
/** 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; }
}
// 指标聚合(当前项目);失败静默降级为 null(面板隐藏)
async function loadMetrics() {
const pid = S.currentProjectId;
if (!pid) { S.metrics = null; return; }
try { S.metrics = await api(`/api/metrics?project=${encodeURIComponent(pid)}`); }
catch { S.metrics = null; }
}
async function refresh() {
try {
await Promise.all([loadProjectData(), loadMetrics()]);
renderAll();
} catch (e) { toast(e.message); }
}
async function fullRefresh() {
try {
await loadProjects();
await Promise.all([loadProjectData(), loadAgents(), loadMetrics()]);
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();
renderMetrics();
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) => {
const ch = (p.name || '?').trim().charAt(0).toUpperCase();
const hue = [...(p.name || '')].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 360, 7);
return `
<li class="${p.id === S.currentProjectId ? 'active' : ''}" data-action="select-project" data-id="${esc(p.id)}" draggable="true">
<span class="p-logo-wrap">
<span class="p-logo-fb" style="--h:${hue}">${esc(ch)}</span>
<img class="p-logo" src="/api/projects/${esc(p.id)}/logo?v=${esc(p.logo || '')}" alt="" onload="this.classList.add('ok')" onerror="this.remove()">
</span>
<span class="p-meta">
<span class="p-name">${esc(p.name)}</span>
<span class="p-path">${esc(p.repoPath)}</span>
</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>${icon('git')} 分支</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('');
}
// ── 渲染:指标面板(数字卡 + 按模型额度分布) ──
function metricCard(label, value, sub, cls) {
return `<div class="metric-card${cls ? ' ' + cls : ''}">
<div class="metric-val">${value}</div>
<div class="metric-label">${esc(label)}</div>
${sub ? `<div class="metric-sub">${sub}</div>` : ''}
</div>`;
}
function renderMetrics() {
const sec = $('#metricsSection');
const m = S.metrics;
// 无项目或无任何任务数据 → 隐藏面板(不打扰空项目)
if (!m || !S.currentProjectId || (m.taskCount === 0 && m.runs.total === 0 && m.duration.count === 0)) {
sec.hidden = true;
return;
}
sec.hidden = false;
$('#metricsScope').textContent = `· ${m.taskCount} 任务`;
const d = m.duration;
const r = m.runs;
const rv = m.review;
const rt = m.retry;
// 成功率配色:≥80% 绿、≥50% 琥珀、其余红
const rateCls = (rate) => {
if (rate == null) return '';
const v = Number(rate);
return v >= 0.8 ? 'ok' : v >= 0.5 ? 'warn' : 'crit';
};
const cards = [
metricCard('平均执行时长', fmtDur(d.avgMs),
d.count ? `P50 ${fmtDur(d.p50Ms)} · P95 ${fmtDur(d.p95Ms)} · n=${d.count}` : '暂无已结束执行'),
metricCard('执行成功率', fmtRate(r.successRate),
r.total ? `${r.succeeded}/${r.total} 成功 · 失败 ${r.failed}` : '暂无执行', rateCls(r.successRate)),
metricCard('复审通过率', fmtRate(rv.reviewRate),
`复审 ${rv.reviewApprove}/${rv.reviewTotal} · 安全 ${fmtRate(rv.securityRate)} (${rv.securityApprove}/${rv.securityTotal})`,
rateCls(rv.reviewRate)),
metricCard('重试率', fmtRate(rt.retryRate),
`重试 ${rt.retries} 次 · 需人工 ${rt.needsAttention}`, rt.needsAttention > 0 ? 'warn' : ''),
];
// 按模型额度分布(估算)
const totalUnits = (m.byModel || []).reduce((a, x) => a + Number(x.estCostUnits || 0), 0);
const totalDur = (m.byModel || []).reduce((a, x) => a + Number(x.durationMs || 0), 0);
cards.push(metricCard('额度消耗', `${totalUnits.toFixed(1)}<span class="metric-unit">u</span>`,
`执行 ${fmtDur(totalDur)} · <span class="metric-est">估算</span>`, 'est'));
const dist = (m.byModel || []).length
? `<div class="metric-models">
<div class="metric-models-head">按模型分布 <span class="metric-est">(时长×档位 粗估)</span></div>
${m.byModel.map((x) => {
const w = totalUnits > 0 ? Math.round(Number(x.estCostUnits) / totalUnits * 100) : 0;
return `<div class="metric-model-row">
<span class="metric-model-name">${esc(shortModel(x.model))}</span>
<span class="metric-model-bar"><span class="metric-model-fill" style="width:${w}%"></span></span>
<span class="metric-model-num">${Number(x.estCostUnits).toFixed(1)}u · ${x.runs} run · ${fmtDur(x.durationMs)}</span>
</div>`;
}).join('')}
</div>`
: '';
$('#metricsBody').innerHTML = `<div class="metric-cards">${cards.join('')}</div>${dist}`;
}
// ── 渲染:归档区(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>
<div class="run-list">${runs.map((x) => `
<div class="run-item">
<div class="run-head">
<span class="run-kind">${RUN_KIND[x.kind] || esc(x.kind)}</span>
<span class="run-st run-st-${esc(x.status)}">${RUN_ST[x.status] || esc(x.status)}</span>
<span class="run-time">${fmtFull(x.startedAt)}${x.endedAt ? fmtFull(x.endedAt) : '—'} · ${fmtDuration(x.startedAt, x.endedAt)}</span>
<span class="spacer"></span>
${x.transcriptRef ? `<button class="btn btn-xs btn-ghost" data-action="run-transcript" data-id="${esc(x.id)}">▶ 查看转录</button>` : ''}
</div>
${x.error ? `<div class="run-err">错误:${esc(x.error)}</div>` : ''}
${x.transcriptRef ? `<div class="transcript" id="transcript-${esc(x.id)}" hidden></div>` : ''}
</div>`).join('')}</div>` : '';
const resultBlock = r ? `${reviewReportBlocks(r)}
<div class="gate-doc-label">执行结果</div>
<dl class="result-kv">
${r.branch ? kv(`${icon('git')} 分支`, 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>${icon('reason')} 意见:${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">${icon('close')}</button>
</div>
<div class="preview-body">
${attrs}${docs}${runRows}${resultBlock}${approvalRows}${timeline}
</div>
<div class="preview-foot archive-modal-actions">
<button class="btn btn-xs btn-reject" data-action="archive-delete" data-id="${esc(t.id)}" title="彻底删除(不可恢复)">🗑 删除归档</button>
<button class="btn btn-ghost" data-action="archive-modal-close">关闭</button>
</div>
</div>`;
}
// 过长内容折叠为 <details>,短内容直接 <pre>
function foldable(text, truncated, threshold = 400) {
const body = esc(text) + (truncated ? esc('\n…(已截断)') : '');
if ((text || '').length <= threshold) return `<pre class="t-pre">${body}</pre>`;
return `<details class="t-fold"><summary>展开(${(text || '').length} 字符)</summary><pre class="t-pre">${body}</pre></details>`;
}
// 结构化转录消息流 → HTML(按 type 分类;文本/结论复用 mdToHtml,工具调用/结果折叠)
function renderTranscript(messages) {
if (!messages || !messages.length) return '<div class="transcript-empty">(无可展示的转录内容)</div>';
return `<div class="t-stream">${messages.map((m) => {
if (m.type === 'text') {
return `<div class="t-msg t-text"><span class="t-badge t-badge-a">assistant</span>
<div class="doc md">${mdToHtml(m.text || '')}</div>${m.truncated ? '<div class="t-trunc">…(已截断)</div>' : ''}</div>`;
}
if (m.type === 'result') {
return `<div class="t-msg t-result"><span class="t-badge t-badge-r">结论</span>
<div class="doc md">${mdToHtml(m.text || '')}</div>${m.truncated ? '<div class="t-trunc"></div>' : ''}</div>`;
}
if (m.type === 'fallback') {
return `<div class="t-msg t-fallback"><span class="t-badge t-badge-w">模型回退</span>
<span class="t-fallback-txt">${esc(m.from)}${esc(m.to)}</span></div>`;
}
if (m.type === 'tool_use') {
return `<div class="t-msg t-tool"><span class="t-badge t-badge-t">工具调用</span>
<span class="t-tool-name">${esc(m.name)}</span>${m.input ? foldable(m.input, m.truncated) : ''}</div>`;
}
if (m.type === 'tool_result') {
return `<div class="t-msg t-tool-result${m.isError ? ' is-err' : ''}">
<span class="t-badge t-badge-tr">工具结果${m.isError ? ' · 报错' : ''}</span>${foldable(m.text || '', m.truncated)}</div>`;
}
return '';
}).join('')}</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">${icon('close')}</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(String(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('、')}">${icon('deps')} 等依赖 ${unmet.length}</span>`;
})()}
<span class="t-prio ${t.priority === 0 ? 'hot' : ''}">P${t.priority}</span>
${cplxBadge(t.complexity, t.id)}
${statusChip(t.status)}
${!ARCHIVED.has(t.status) ? `<button class="btn btn-ghost btn-xs task-row-cancel" data-action="row-cancel" data-id="${esc(t.id)}" title="取消任务(进入归档)">${icon('close')}</button>` : ''}
<button class="btn btn-ghost btn-xs task-row-delete" data-action="row-delete" data-id="${esc(t.id)}" title="彻底删除任务(不可恢复)">🗑</button>
</div>
${expanded ? renderDetail(t) : ''}
${kids.length && !collapsed ? `<div class="task-children">${kids.map(renderNode).join('')}</div>` : ''}
</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>${icon('git')} 分支</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">${icon('reason')}<span>${esc(a.reason)}</span></span>` : ''}
</div>`).join('')}
</div>`);
}
// 依赖列表:标题 + 状态 chip,未完成的高亮(全部 done 本任务才可被领取);可编辑状态支持增删
const depsEditable = STRUCT_EDITABLE.has(t.status);
if (S.depsEditFor === t.id) {
parts.push(depsEditorBlock(t));
} else if ((t.deps && t.deps.length) || depsEditable) {
const items = (t.deps || []).map((d) => {
const dt = S.tasks.find((x) => x.id === d);
const ok = dt && dt.status === 'done';
return `<div class="dep-item ${ok ? 'ok' : 'wait'}" data-action="goto-task" data-id="${esc(d)}" title="点击定位到该任务">
<span class="dep-mark">${ok ? '✓' : icon('deps')}</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('') || `<div class="dep-empty">暂无依赖</div>`;
const editBtn = depsEditable
? `<button class="btn btn-xs deps-edit-btn" data-action="deps-edit" data-id="${esc(t.id)}" title="增删本任务的依赖">✎ 编辑</button>`
: '';
parts.push(`<div><div class="detail-label">DEPS · 依赖(全部完成才可执行)${editBtn}</div><div class="dep-list">${items}</div></div>`);
}
if (!parts.length) parts.push(`<div class="proj-meta">暂无产出与历史 —— 状态:${STATUS_LABEL[t.status]}</div>`);
parts.push(`<div class="proj-meta">id ${esc(t.id)} · 深度 ${t.depth} · 创建 ${fmtTime(t.createdAt)} · 更新 ${fmtTime(t.updatedAt)}</div>`);
// ── 取消 / 删除操作区 ──
const m = childrenMap(S.tasks);
const childCount = (m.get(t.id) || []).length;
const cancelSection = !ARCHIVED.has(t.status) ? (() => {
if (S.confirmCancel === t.id) {
return `<div class="detail-action-confirm">
<span class="detail-confirm-msg">确定取消任务「${esc(t.title)}」?(将进入归档,子任务保持原状)</span>
<button class="btn btn-xs btn-reject" data-action="task-cancel-confirm" data-id="${esc(t.id)}">确认取消</button>
<button class="btn btn-xs" data-action="task-cancel-abort" data-id="${esc(t.id)}">算了</button>
</div>`;
}
return `<button class="btn btn-xs btn-reject" data-action="task-cancel" data-id="${esc(t.id)}" title="取消任务(进入 cancelled 状态,计入归档)">${icon('close')} 取消任务</button>`;
})() : '';
const deleteSection = (() => {
if (S.confirmDelete === t.id) {
const cascadeNote = childCount ? `(含 ${childCount} 个直接子任务,及其全部子孙)` : '';
return `<div class="detail-action-confirm detail-action-confirm-del">
<span class="detail-confirm-msg">彻底删除「${esc(t.title)}${esc(cascadeNote)}?此操作不可恢复!</span>
<button class="btn btn-xs btn-reject" data-action="task-delete-confirm" data-id="${esc(t.id)}">确认删除</button>
<button class="btn btn-xs" data-action="task-delete-abort" data-id="${esc(t.id)}">算了</button>
</div>`;
}
const cascadeHint = childCount ? `(含 ${childCount} 子任务)` : '';
return `<button class="btn btn-xs" data-action="task-delete" data-id="${esc(t.id)}" title="彻底删除任务(不可恢复)">🗑 删除${cascadeHint}</button>`;
})();
if (cancelSection || deleteSection) {
parts.push(`<div class="detail-actions">${cancelSection}${deleteSection}</div>`);
}
return `<div class="task-detail"><div class="detail-grid">${parts.join('')}</div></div>`;
}
/** 选某任务为 t 的依赖会成环的候选集合:所有(传递)依赖 t 的任务 id */
function depsWouldCycle(t) {
const byId = new Map(S.tasks.map((x) => [x.id, x]));
const reaches = (id, target, seen) => {
const node = byId.get(id);
if (!node) return false;
for (const d of (node.deps || [])) {
if (d === target) return true;
if (!seen.has(d)) { seen.add(d); if (reaches(d, target, seen)) return true; }
}
return false;
};
const bad = new Set();
for (const x of S.tasks) {
if (x.id === t.id) continue;
if (reaches(x.id, t.id, new Set())) bad.add(x.id);
}
return bad;
}
/** 依赖编辑器:多选当前项目其它任务(排除自己与会成环者),保存走 PATCH deps */
function depsEditorBlock(t) {
const sel = S.depsEditSel || new Set();
const bad = depsWouldCycle(t);
const candidates = S.tasks
.filter((x) => x.id !== t.id && !ARCHIVED.has(x.status) && !bad.has(x.id))
.sort((a, b) => (a.depth - b.depth) || a.title.localeCompare(b.title));
const rows = candidates.map((x) => {
const on = sel.has(x.id);
return `<div class="dep-pick ${on ? 'on' : ''}" data-action="deps-edit-toggle" data-id="${esc(t.id)}" data-dep="${esc(x.id)}">
<span class="dep-check">${on ? '☑' : '☐'}</span>
${statusChip(x.status)}
<span class="dep-title">${esc(x.title)}</span>
<span class="t-id">${esc(x.id.slice(-6))}</span>
</div>`;
}).join('') || `<div class="dep-empty">本项目暂无其它可选任务</div>`;
return `<div><div class="detail-label">DEPS · 编辑依赖(勾选当前项目其它任务;已排除自身与会成环者)</div>
<div class="dep-list dep-pick-list">${rows}</div>
<div class="deps-edit-foot">
<button class="btn btn-xs btn-accept" data-action="deps-edit-save" data-id="${esc(t.id)}">保存依赖(${sel.size}</button>
<button class="btn btn-xs" data-action="deps-edit-cancel" data-id="${esc(t.id)}">取消</button>
</div></div>`;
}
// ── 渲染:事件流 ──
function eventDetail(e) {
const t = S.tasks.find((x) => x.id === e.taskId);
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">${icon('event')}${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(Math.max(0, (t.depth || 1) - 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; S.confirmCancel = null; S.confirmDelete = 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;
if (S.expanded.has(id)) {
S.expanded.delete(id);
// 折叠时清除行内确认状态
if (S.confirmCancel === id) S.confirmCancel = null;
if (S.confirmDelete === id) S.confirmDelete = null;
} else {
S.expanded.add(id);
}
renderTree();
break;
}
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 'run-transcript': {
ev.stopPropagation();
const box = document.getElementById(`transcript-${id}`);
if (!box) break;
if (!box.hidden) { box.hidden = true; el.textContent = '▶ 查看转录'; break; }
box.hidden = false;
el.textContent = '▼ 收起转录';
if (!box.dataset.loaded) {
box.innerHTML = '<div class="transcript-loading">加载中…</div>';
api(`/api/runs/${id}/transcript`)
.then((data) => { box.dataset.loaded = '1'; box.innerHTML = renderTranscript(data.messages); })
.catch((e) => { box.innerHTML = `<div class="transcript-err">${esc(e.message)}</div>`; });
}
break;
}
case 'archive-modal-close':
$('#archiveModalRoot').hidden = true;
$('#archiveModalRoot').innerHTML = '';
break;
case 'archive-delete': {
const t = S.tasks.find((x) => x.id === id);
const m = childrenMap(S.tasks);
let descCount = 0;
const countDesc = (tid) => { for (const c of m.get(tid) || []) { descCount++; countDesc(c.id); } };
countDesc(id);
const title = t ? t.title : id;
const hint = descCount ? `及其 ${descCount} 个子孙任务` : '';
if (!confirm(`彻底删除「${title}${hint}\n此操作不可恢复,所有相关数据将一并删除。`)) break;
$('#archiveModalRoot').hidden = true;
$('#archiveModalRoot').innerHTML = '';
act(async () => {
await api(`/api/tasks/${id}`, { method: 'DELETE' });
S.expanded.delete(id);
}, `归档任务已删除${descCount ? `(含 ${descCount} 子孙)` : ''}`);
break;
}
case 'archive-size':
S.archive.size = Number(el.dataset.size) || 20;
S.archive.page = 1;
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 'deps-edit': {
const t = S.tasks.find((x) => x.id === id);
S.depsEditFor = id;
S.depsEditSel = new Set((t && t.deps) || []);
renderTree();
break;
}
case 'deps-edit-toggle': {
const dep = el.dataset.dep;
if (!S.depsEditSel) S.depsEditSel = new Set();
S.depsEditSel.has(dep) ? S.depsEditSel.delete(dep) : S.depsEditSel.add(dep);
renderTree();
break;
}
case 'deps-edit-cancel':
S.depsEditFor = null; S.depsEditSel = null;
renderTree();
break;
case 'deps-edit-save': {
const deps = [...(S.depsEditSel || new Set())];
S.depsEditFor = null; S.depsEditSel = null;
act(() => api(`/api/tasks/${id}`, {
method: 'PATCH', body: JSON.stringify({ deps }),
}), `依赖已更新(${deps.length} 项,按依赖重算 ready/blocked`);
break;
}
// ── 任务树筛选 ──
case 'filter-cplx': {
const c = el.dataset.cplx;
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';
$('#cfgLogo').value = p.logo ?? '';
$('#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 logo = $('#cfgLogo').value.trim();
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, logo: logo || null, verifyCmd, model }),
});
await loadProjects(); // 刷新当前值显示 + logo
renderSidebar();
$('#configPanel').hidden = true; // 保存成功自动收起
}, '配置已保存');
break;
}
// ── 任务行:取消(带确认) ──
case 'row-cancel': {
ev.stopPropagation();
const t = S.tasks.find((x) => x.id === id);
if (!t) break;
const m = childrenMap(S.tasks);
const childCount = (m.get(id) || []).length;
const hint = childCount ? `(含 ${childCount} 个直接子任务,子任务保持原状)` : '';
if (!confirm(`确定取消任务「${t.title}${hint}\n取消后任务进入归档,不影响子任务执行。`)) break;
act(() => api(`/api/tasks/${id}/cancel`, { method: 'POST', body: '{}' }), '任务已取消');
break;
}
// ── 任务行:删除(带级联确认) ──
case 'row-delete': {
ev.stopPropagation();
const t = S.tasks.find((x) => x.id === id);
const m = childrenMap(S.tasks);
// 递归统计子孙数
let descCount = 0;
const countDesc = (tid) => { for (const c of m.get(tid) || []) { descCount++; countDesc(c.id); } };
countDesc(id);
const title = t ? t.title : id;
const hint = descCount ? `及其 ${descCount} 个子孙任务` : '';
if (!confirm(`彻底删除「${title}${hint}\n此操作不可恢复,所有相关数据(执行历史、审批记录)将一并删除。`)) break;
act(async () => {
await api(`/api/tasks/${id}`, { method: 'DELETE' });
S.expanded.delete(id);
S.collapsed.delete(id);
}, `任务已删除${descCount ? `(含 ${descCount} 子孙)` : ''}`);
break;
}
// ── 详情:取消 — 显示行内确认 ──
case 'task-cancel': {
ev.stopPropagation();
S.confirmCancel = id;
S.confirmDelete = null;
renderTree();
break;
}
case 'task-cancel-abort': {
ev.stopPropagation();
S.confirmCancel = null;
renderTree();
break;
}
case 'task-cancel-confirm': {
ev.stopPropagation();
S.confirmCancel = null;
act(() => api(`/api/tasks/${id}/cancel`, { method: 'POST', body: '{}' }), '任务已取消');
break;
}
// ── 详情:删除 — 显示行内确认 ──
case 'task-delete': {
ev.stopPropagation();
S.confirmDelete = id;
S.confirmCancel = null;
renderTree();
break;
}
case 'task-delete-abort': {
ev.stopPropagation();
S.confirmDelete = null;
renderTree();
break;
}
case 'task-delete-confirm': {
ev.stopPropagation();
const delId = id;
S.confirmDelete = null;
act(async () => {
await api(`/api/tasks/${delId}`, { method: 'DELETE' });
S.expanded.delete(delId);
S.collapsed.delete(delId);
}, '任务已删除');
break;
}
}
});
// 阻止详情区域内点击冒泡触发折叠
document.addEventListener('click', (ev) => {
if (ev.target.closest('.task-detail') && !ev.target.closest('[data-action]')) {
ev.stopPropagation();
}
}, true);
// ── 项目列表拖动排序 ──
let dragId = null;
$('#projectList').addEventListener('dragstart', (ev) => {
const li = ev.target.closest('li[data-id]');
if (!li) return;
dragId = li.dataset.id;
li.classList.add('dragging');
ev.dataTransfer.effectAllowed = 'move';
});
$('#projectList').addEventListener('dragend', (ev) => {
const li = ev.target.closest('li');
if (li) li.classList.remove('dragging');
$('#projectList').querySelectorAll('.drop-over').forEach((x) => x.classList.remove('drop-over'));
dragId = null;
});
$('#projectList').addEventListener('dragover', (ev) => {
ev.preventDefault();
const li = ev.target.closest('li[data-id]');
$('#projectList').querySelectorAll('.drop-over').forEach((x) => x.classList.remove('drop-over'));
if (li && li.dataset.id !== dragId) li.classList.add('drop-over');
});
$('#projectList').addEventListener('drop', (ev) => {
ev.preventDefault();
const li = ev.target.closest('li[data-id]');
if (!li || !dragId || li.dataset.id === dragId) return;
const ids = S.projects.map((p) => p.id);
const from = ids.indexOf(dragId);
const to = ids.indexOf(li.dataset.id);
if (from < 0 || to < 0) return;
ids.splice(to, 0, ids.splice(from, 1)[0]); // 插到目标位置
S.projects.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); // 乐观重排
renderSidebar();
act(async () => {
await api('/api/projects/reorder', { method: 'POST', body: JSON.stringify({ order: ids }) });
await loadProjects();
renderSidebar();
}, '项目顺序已保存');
});
// ── 表单提交 ──
$('#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);