/* ════════════════════════════════════════════════════════════
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', 'cancelled']],
['完成', ['done']],
];
// ── 全局状态 ──
const S = {
projects: [],
currentProjectId: null,
tasks: [],
approvals: [],
events: [], // 新→旧
collapsed: new Set(),
expanded: new Set(),
rejectOpen: new Set(),
filter: { cplx: new Set(), status: 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, // 预览层内驳回意见框是否展开
};
const $ = (sel) => document.querySelector(sel);
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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)} 天前`;
}
// ── 轻量 Markdown 渲染(零依赖;先整体转义再做结构转换,杜绝注入) ──
function mdToHtml(src) {
const inline = (s) => s
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '$1');
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(`
${para.map(inline).join('
')}
`); para = []; } };
const flushList = () => {
if (list) {
out.push(`<${list.type}>${list.items.map((x) => `${inline(x)}`).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(`${buf.join('\n')}`);
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(`${head.map((h) => `| ${h} | `).join('')}
${
rows.map((r) => `${r.map((c) => `| ${c} | `).join('')}
`).join('')}
`);
continue;
}
let m;
if ((m = line.match(/^(#{1,6})\s+(.*)$/))) { // 标题
flushPara(); flushList();
const lv = m[1].length;
out.push(`${inline(m[2])}
`);
i++; continue;
}
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { flushPara(); flushList(); out.push('
'); i++; continue; }
if ((m = line.match(/^\s*>\s?(.*)$/))) { flushPara(); flushList(); out.push(`${inline(m[1])}
`); 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();
renderPreview();
restoreDrafts(snap);
}
// ── 渲染:侧栏 ──
function renderSidebar() {
const ul = $('#projectList');
if (!S.projects.length) {
ul.innerHTML = `暂无项目
点「+ 新建」登记第一个仓库`;
return;
}
ul.innerHTML = S.projects.map((p) => `
${esc(p.name)}
${esc(p.repoPath)}
`).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}`
: '';
// 徽章组:待审批 / 可执行(叶子+ready+依赖全 done,与编排器领取口径一致)/ 执行中
$('#badgeGroup').hidden = !p;
const hasKids = new Set(S.tasks.filter((x) => x.parentId).map((x) => x.parentId));
const byId = new Map(S.tasks.map((x) => [x.id, x]));
const nReady = S.tasks.filter((x) =>
x.status === 'ready' && !hasKids.has(x.id) &&
(x.deps || []).every((d) => byId.get(d)?.status === 'done'),
).length;
const nRun = S.tasks.filter((x) => x.status === 'executing').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);
// 同步 todo / 项目配置入口
$('#topActions').hidden = !p;
if (p) {
const sb = $('#btnSync');
const noTodo = p.hasTodoJson === false; // 字段未交付(undefined)时不禁用,错误由 toast 兜底
sb.disabled = noTodo;
sb.title = noTodo
? '未发现 todo.json —— 约定路径 /todo/todo.json'
: '从 /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}` : '';
if (!total) {
body.innerHTML = `无 agent 在执行(自动执行将在 Phase 2 启用)
`;
return;
}
const groups = (a.agents || []).filter((g) => g.active && g.active.length);
body.innerHTML = `
${total}ACTIVE
${groups.map((g) => `
${esc(g.projectName)}
并发 ${esc(String(g.concurrency ?? '—'))} · ${esc(AUTONOMY_LABEL[g.autonomy] || g.autonomy || '—')}
${g.active.map((r) => `
${esc(r.taskTitle)}
${esc(r.kind || '')}
${fmtRel(r.startedAt)}开始
`).join('')}
`).join('')}
`;
}
// ── 渲染:审批闸 ──
function gateDocs(t) {
const gate = GATE_OF[t.status];
const blocks = [];
const doc = (label, text) => blocks.push(
`${label}
` +
(text ? `${mdToHtml(text)}
` : `(未填写)
`));
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(`RESULT · 执行结果
${r.branch ? `- 分支
- ${esc(r.branch)}
` : ''}
${r.worktree ? `- worktree
- ${esc(r.worktree)}
` : ''}
${r.prUrl ? `- PR
- ${esc(r.prUrl)}
` : ''}
`);
if (r.diffSummary) blocks.push(`DIFF 摘要
${esc(r.diffSummary)}`);
} else {
blocks.push(`(无执行结果记录)
`);
}
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;
$('#gateList').innerHTML = S.approvals.map((t) => {
const gate = GATE_OF[t.status];
const rejOpen = S.rejectOpen.has(t.id);
return `
${GATE_LABEL[gate]}
${esc(t.title)}
${cplxBadge(t.complexity)}
${statusChip(t.status)}
${gateDocs(t)}
${gate === 'exec' ? `接受 = 认可改动并标记完成(合并不自动执行)` : ''}
${rejOpen ? `
` : ''}
`;
}).join('');
}
// ── 渲染:全局预览(全屏读方案 + 就地裁决) ──
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 = `
${GATE_LABEL[gate]}
${esc(t.title)}
${cplxBadge(t.complexity)}
${statusChip(t.status)}
${gateDocs(t)}
`;
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 `${text}`;
const open = S.cplxMenuFor === taskId;
const pop = open ? `${['hard', 'medium', 'easy'].map((x) =>
``,
).join('')}` : '';
return `
${pop}
`;
}
function statusChip(st) {
return `${STATUS_LABEL[st] || esc(st)}`;
}
function childrenMap() {
const m = new Map();
for (const t of S.tasks) {
const key = t.parentId || '__root__';
if (!m.has(key)) m.set(key, []);
m.get(key).push(t);
}
return m;
}
// ── 筛选 ──
function filterActive() {
const f = S.filter;
return f.cplx.size > 0 || f.status.size > 0 || f.kw.trim() !== '';
}
function matchTask(t) {
const f = S.filter;
if (f.cplx.size && !f.cplx.has(t.complexity)) return false;
if (f.status.size && !f.status.has(t.status)) return false;
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 byId = new Map(S.tasks.map((t) => [t.id, t]));
const visible = new Set();
const matched = new Set();
for (const t of S.tasks) {
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) =>
``,
).join('');
$('#filterStatus').innerHTML = FILTER_GROUPS.map(([name, sts], gi) => {
const sel = sts.filter((s) => f.status.has(s)).length;
const gCls = sel === sts.length ? 'on' : (sel ? 'part' : '');
return `
${sts.map((s) => ``).join('')}
`;
}).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 = `NO PROJECT先在左侧新建或选择一个项目
`;
return;
}
if (!S.tasks.length) {
root.innerHTML = `EMPTY还没有任务 —— 点上方「+ 新建任务」开始
`;
return;
}
const m = childrenMap();
const vis = computeVisible();
if (vis && !vis.visible.size) {
root.innerHTML = `NO MATCH没有任务命中当前筛选 —— 放宽条件或「清除筛选」
`;
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
? `▶`
: `·`;
return `
${caret}
${esc(t.title)}${esc(t.id.slice(-6))}
${(() => {
// 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 `⛓ 等依赖 ${unmet.length}`;
})()}
P${t.priority}
${cplxBadge(t.complexity, t.id)}
${statusChip(t.status)}
${expanded ? renderDetail(t) : ''}
${kids.length && !collapsed ? `
${kids.map(renderNode).join('')}
` : ''}
`;
};
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(`${label}
${mdToHtml(t[f])}
`);
}
// 当前阶段应产出但还没有内容 → 占位提示
if (w && !t[w.field]) {
parts.push(`${w.label}
待 Claude Code 产出(经 MCP 写入并提交评审)
`);
}
// result
if (t.result) {
const r = t.result;
parts.push(`
RESULT · 执行结果
- 分支
- ${esc(r.branch || '—')}
- worktree
- ${esc(r.worktree || '—')}
${r.prUrl ? `- PR
- ${esc(r.prUrl)}
` : ''}
${r.commits && r.commits.length ? `- commits
- ${r.commits.map(esc).join('
')} ` : ''}
${r.diffSummary ? `
${esc(r.diffSummary)}` : ''}
`);
}
// 审批历史
if (t.approvals && t.approvals.length) {
parts.push(`
审批历史
${t.approvals.map((a) => `
${a.action === 'accept' ? '✓ 通过' : '✗ 驳回'}
${GATE_LABEL[a.gate] || esc(a.gate)} · ${esc(a.actor)}
${fmtTime(a.at)}
${a.reason ? `${esc(a.reason)}` : ''}
`).join('')}
`);
}
// 依赖列表:标题 + 状态 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 `
${ok ? '✓' : '⛓'}
${dt ? statusChip(dt.status) : ''}
${esc(dt ? dt.title : d)}
${esc(d.slice(-6))}
↧
`;
}).join('');
parts.push(`DEPS · 依赖(全部完成才可执行)
${items}
`);
}
if (!parts.length) parts.push(`暂无产出与历史 —— 状态:${STATUS_LABEL[t.status]}
`);
parts.push(`id ${esc(t.id)} · 深度 ${t.depth} · 创建 ${fmtTime(t.createdAt)} · 更新 ${fmtTime(t.updatedAt)}
`);
return ``;
}
// ── 渲染:事件流 ──
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 = `暂无事件`;
return;
}
ul.innerHTML = S.events.slice(0, 80).map((e) => `
${EVENT_LABEL[e.type] || esc(e.type)}
${fmtTime(e.at)}
${eventDetail(e)}
`).join('');
}
// ── 渲染:父任务下拉 ──
function renderParentOptions() {
const sel = document.querySelector('#newTaskPanel select[name=parentId]');
const cur = sel.value;
sel.innerHTML = `` +
S.tasks.map((t) => ``).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':
act(() => api(`/api/tasks/${id}/decide`, {
method: 'POST', body: JSON.stringify({ action: 'accept' }),
}), '已接受');
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.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 '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 g = FILTER_GROUPS[Number(el.dataset.gi)];
if (!g) break;
const sts = g[1];
const all = sts.every((s) => S.filter.status.has(s));
sts.forEach((s) => { all ? S.filter.status.delete(s) : S.filter.status.add(s); });
renderTree(); renderFilterBar();
break;
}
case 'filter-clear':
S.filter.cplx.clear();
S.filter.status.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';
}
}
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;
act(async () => {
await api(`/api/projects/${S.currentProjectId}`, {
method: 'PATCH', body: JSON.stringify({ concurrency: cc, autonomy }),
});
await loadProjects(); // 刷新当前值显示
}, '配置已保存');
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') {
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);