/* ════════════════════════════════════════════════════════════ MAESTRO 调度台 · 前端逻辑(无框架,直连 daemon REST + WS) ════════════════════════════════════════════════════════════ */ 'use strict'; // ── 模型常量(与 src/model/status.ts / complexity.ts 对齐) ── const STATUS_LABEL = { init: '新建', analyzing: '分析拆解中', plan_review: '待确认拆解', decomposed: '已拆解', speccing: '写方案中', spec_review: '待确认方案', ready: '可执行', blocked: '被阻塞', queued: '排队中', executing: '执行中', exec_review: '待审/合', failed: '失败', needs_attention: '需人工', done: '完成', paused: '暂停', cancelled: '取消', }; const STATUS_GROUP = { init: 'idle', analyzing: 'work', speccing: 'work', plan_review: 'gate', spec_review: 'gate', exec_review: 'gate', decomposed: 'container', ready: 'go', queued: 'go', executing: 'run', blocked: 'hold', paused: 'hold', failed: 'bad', needs_attention: 'bad', done: 'done', cancelled: 'dead', }; const GATE_OF = { plan_review: 'plan', spec_review: 'spec', exec_review: 'exec' }; const GATE_LABEL = { plan: '拆解评审', spec: '方案评审', exec: '结果评审' }; const CPLX_LABEL = { hard: 'HARD', medium: 'MED', easy: 'EASY' }; const EVENT_LABEL = { 'task.created': '任务创建', 'task.updated': '任务更新', 'status.changed': '状态变更', 'approval.requested': '请求审批', 'approval.granted': '审批通过', 'approval.rejected': '审批驳回', 'run.started': '运行开始', 'run.finished': '运行结束', 'project.synced': 'todo 同步', }; const EVENT_CLASS = { 'task.created': 'ev-created', 'task.updated': 'ev-updated', 'status.changed': 'ev-status', 'approval.requested': 'ev-gatewait', 'approval.granted': 'ev-approve', 'approval.rejected': 'ev-rejected', 'run.started': 'ev-run', 'run.finished': 'ev-run', 'project.synced': 'ev-created', }; const AUTONOMY_LABEL = { manual: '手动', 'auto-easy': '自动执行 Easy', 'auto-approved': '自动执行已批准', }; // 任务树筛选:状态按状态机分组(组可整组开关,组内可单选) const FILTER_GROUPS = [ ['待办', ['init', 'ready', 'blocked']], ['进行中', ['analyzing', 'speccing', 'queued', 'executing']], // 已拆解是容器态,不算进行中 ['容器', ['decomposed']], ['待审批', ['plan_review', 'spec_review', 'exec_review']], ['异常', ['failed', 'needs_attention']], ['挂起', ['paused']], ]; // 归档态:不进任务树,沉到页面底部的归档区(时间倒序分页) const ARCHIVED = new Set(['done', 'cancelled']); // ── 全局状态 ── const S = { projects: [], currentProjectId: null, tasks: [], approvals: [], events: [], // 新→旧 collapsed: new Set(), expanded: new Set(), rejectOpen: new Set(), filter: { cplx: new Set(), status: new Set(), statusGroups: new Set(), kw: '' },// 任务树筛选(组选与单选分离) matchCount: 0, agents: null, // GET /api/agents 结果(404 时为 null) cplxMenuFor: null, // 复杂度下拉打开的任务 id syncReqAt: 0, // 本端发起 sync 的时间(避免 WS 重复 toast) previewId: null, // 全局预览中的任务 id previewReject: false, // 预览层内驳回意见框是否展开 archive: { page: 1, size: 20 }, // 归档区分页 confirmCancel: null, // 取消确认中的任务 id confirmDelete: null, // 删除确认中的任务 id }; 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)} 天前`; } /** 模型名缩写显示:claude-fable-5 → fable-5 */ function shortModel(m) { return String(m || '—').replace(/^claude-/, ''); } /** fmtRel 的未来版:到 iso 还有多久("3h 33m" / "42m" / "<1m";已过/无效 → '') */ function fmtUntil(iso) { if (!iso) return ''; const ms = new Date(iso).getTime() - Date.now(); if (!Number.isFinite(ms) || ms <= 0) return ''; const m = Math.ceil(ms / 60000); if (m < 1) return '<1m'; if (m < 60) return `${m}m`; const h = Math.floor(m / 60); if (h < 24) return m % 60 ? `${h}h ${m % 60}m` : `${h}h`; const d = Math.floor(h / 24); return h % 24 ? `${d}d ${h % 24}h` : `${d}d`; } /** 字符进度条:8 格 █/░ */ function usageBar(pct) { const filled = Math.round(Math.min(100, Math.max(0, pct)) / 100 * 8); return '█'.repeat(filled) + '░'.repeat(8 - filled); } /** 单个额度窗口(5h / 周)的展示段;>80% 琥珀、>95% 红 */ function usageSeg(label, w) { if (!w || !Number.isFinite(Number(w.percent))) { return `${label} `; } const pct = Number(w.percent); const lvl = pct > 95 ? ' crit' : pct > 80 ? ' warn' : ''; const until = fmtUntil(w.resetsAt); return `${label} ` + `${usageBar(pct)} ${pct}%` + `${until ? `(${until} 后重置)` : ''}`; } /** Agent 面板顶部的订阅额度行(执行 agent 烧的就是这个池子);usage=null → 灰字降级 */ function usageLine(usage) { if (!usage || (!usage.session && !usage.weekly)) { return `
额度信息不可用
`; } return `
额度 ${usageSeg('5h', usage.session)} · ${usageSeg('周', usage.weekly)}
`; } // ── 轻量 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 = 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) => ``).join('')}${ rows.map((r) => `${r.map((c) => ``).join('')}`).join('')}
    ${h}
    ${c}
    `); 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(); renderArchive(); 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}` : ''; // 徽章组(全部不含已完成: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 —— 约定路径 /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}` : ''; const usage = usageLine(a ? a.usage : null); if (!total) { body.innerHTML = `${usage}
    无 agent 在执行(自动执行将在 Phase 2 启用)
    `; return; } const groups = (a.agents || []).filter((g) => g.active && g.active.length); body.innerHTML = ` ${usage}
    ${total}ACTIVE
    ${groups.map((g) => `
    ${esc(g.projectName)} 并发 ${esc(String(g.concurrency ?? '—'))} · ${esc(AUTONOMY_LABEL[g.autonomy] || g.autonomy || '—')} 调度 score 优先 ${g.models ? `模型 E:${esc(shortModel(g.models.easy))} M:${esc(shortModel(g.models.medium))} H:${esc(shortModel(g.models.hard))}` : ''}
    ${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(reviewReportBlocks(r)); // CODE REVIEW + 安全审计 两节(各带 verdict 徽章),置于 DIFF 摘要上方 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; 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 `
    ${GATE_LABEL[gate]} ${esc(t.title)} ${cplxBadge(t.complexity)} ${statusChip(t.status)}
    ${gateDocs(t)}
    ${gate === 'exec' ? `` : ''} ${gate === 'exec' ? `通过 = 自动把任务分支合并到 ${esc(defBranch)}(仅本地合并,不 push)` : ''}
    ${rejOpen ? `
    ` : ''}
    `; }).join(''); } // ── 渲染:归档区(done/取消,updatedAt 倒序,分页) ── function renderArchive() { const sec = $('#archiveSection'); if (!S.currentProjectId) { sec.hidden = true; return; } // 只列深度 1 的归档树根,行内标注子任务数 const m = childrenMap(S.tasks); const arch = archivedIdSet(); const subCount = (t) => { let n = 0; const stack = [...(m.get(t.id) || [])]; while (stack.length) { const x = stack.pop(); n++; for (const c of m.get(x.id) || []) stack.push(c); } return n; }; const items = (m.get('__root__') || []) .filter((t) => arch.has(t.id)) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); sec.hidden = items.length === 0; if (sec.hidden) return; const a = S.archive; const pages = Math.max(1, Math.ceil(items.length / a.size)); if (a.page > pages) a.page = pages; const slice = items.slice((a.page - 1) * a.size, a.page * a.size); $('#archiveCount').textContent = items.length; $('#archiveList').innerHTML = slice.map((t) => { const n = subCount(t); return `
    ${esc(t.title)}${esc(t.id.slice(-6))} ${n ? `含 ${n} 子任务` : ''} ${cplxBadge(t.complexity)} ${statusChip(t.status)} ${fmtRel(t.updatedAt)}
    `; }).join(''); // 底部:翻页 + 每页条数 chip 组(替代原生下拉) const sizeChips = [10, 20, 50, 100].map((n) => ``, ).join(''); $('#archivePager').innerHTML = ` ${pages > 1 ? ` 第 ${a.page} / ${pages} 页 ` : ''} 每页${sizeChips}`; } // ── 归档详情对话框(只读:全属性/执行历史/审批/状态流转时间线) ── 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}
    ${dd}
    `; const attrs = `
    ${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))}
    `; const docs = [['plan', 'PLAN · 分析拆解'], ['spec', 'SPEC · 方案'], ['operations', 'OPERATIONS · 操作']] .filter(([f]) => t[f]) .map(([f, label]) => `
    ${label}
    ${mdToHtml(t[f])}
    `) .join(''); const runRows = runs.length ? `
    执行历史(${runs.length} 次 run)
    ${runs.map((x) => kv( `${RUN_KIND[x.kind] || esc(x.kind)} · ${RUN_ST[x.status] || esc(x.status)}`, `${fmtFull(x.startedAt)} → ${x.endedAt ? fmtFull(x.endedAt) : '—'} · 时长 ${fmtDuration(x.startedAt, x.endedAt)}` + (x.error ? `
    错误:${esc(x.error)}` : '') + (x.transcriptRef ? `
    转录 ${esc(x.transcriptRef)}` : ''), )).join('')}
    ` : ''; const resultBlock = r ? `${reviewReportBlocks(r)}
    执行结果
    ${r.branch ? kv('分支', esc(r.branch)) : ''} ${r.worktree ? kv('worktree', esc(r.worktree)) : ''} ${r.prUrl ? kv('合并', esc(r.prUrl)) : ''} ${r.commits && r.commits.length ? kv('commits', r.commits.map(esc).join('
    ')) : ''}
    ${r.diffSummary ? `
    ${esc(r.diffSummary)}
    ` : ''}` : ''; const approvalRows = t.approvals && t.approvals.length ? `
    审批记录
    ${t.approvals.map((a) => kv( `${GATE_LABEL[a.gate] || esc(a.gate)} · ${a.action === 'accept' ? '接受' : '驳回'}`, `审批人 ${esc(a.actor)} · ${fmtFull(a.at)}${a.reason ? `
    意见:${esc(a.reason)}` : ''}`, )).join('')}
    ` : ''; const timeline = events.length ? `
    状态流转时间线
    ${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 `
    ${fmtFull(e.at)} ${esc(text)} ${who ? `${esc(who)}` : ''}
    `; }).join('')}
    ` : ''; root.hidden = false; root.innerHTML = `
    归档详情 ${esc(t.title)} ${cplxBadge(t.complexity)} ${statusChip(t.status)}
    ${attrs}${docs}${runRows}${resultBlock}${approvalRows}${timeline}
    `; } // ── 渲染:全局预览(全屏读方案 + 就地裁决) ── 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)}
    ${gate === 'exec' ? `` : ''} ${gate === 'exec' ? (() => { const pp = S.projects.find((x) => x.id === S.currentProjectId); return `通过 = 合并到 ${esc(pp ? pp.defaultBranch : 'main')}(仅本地,不 push)`; })() : ''} id ${esc(t.id)} · 更新 ${fmtTime(t.updatedAt)} ${S.previewReject ? `
    ` : ''}
    `; 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)}`; } // 复审结论徽章:approve=建议通过(绿) / reject=建议拒绝(红) / 其他=未复审(灰) function verdictBadge(v) { if (v === 'approve') return `建议通过`; if (v === 'reject') return `建议拒绝`; return `未复审`; } // 复审报告区块:「CODE REVIEW」「安全审计」两节,各自 verdict 徽章(通过绿/拒绝红/未复审灰) function reviewReportBlocks(r) { if (!r) return ''; const sec = (label, summary, verdict) => { const head = `
    ${label} ${verdictBadge(verdict)}
    `; if (summary) return `${head}
    ${mdToHtml(summary)}
    `; return `${head}
    (无复审记录)
    `; }; 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) => ``, ).join(''); $('#filterStatus').innerHTML = FILTER_GROUPS.map(([name, sts], gi) => { // 组选与单选分离:组 chip 只反映组选择,成员 chip 只反映单选;单状态组只渲染状态 chip 本身 const members = sts.map((s) => ``, ).join(''); if (sts.length === 1) return `${members}`; const gCls = f.statusGroups.has(gi) ? 'on' : ''; return ` ${members} `; }).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 alive = aliveTasks(); if (!alive.length) { root.innerHTML = `
    ALL DONE全部任务已完成归档(见页面底部归档区)
    `; return; } const m = childrenMap(alive); 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)} ${!ARCHIVED.has(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(`
    ${reviewReportBlocks(r)}
    RESULT · 执行结果
    分支
    ${esc(r.branch || '—')}
    worktree
    ${esc(r.worktree || '—')}
    ${r.prUrl ? `
    合并
    ${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)}
    `); // ── 取消 / 删除操作区 ── const m = childrenMap(S.tasks); const childCount = (m.get(t.id) || []).length; const cancelSection = !ARCHIVED.has(t.status) ? (() => { if (S.confirmCancel === t.id) { return `
    确定取消任务「${esc(t.title)}」?(将进入归档,子任务保持原状)
    `; } return ``; })() : ''; const deleteSection = (() => { if (S.confirmDelete === t.id) { const cascadeNote = childCount ? `(含 ${childCount} 个直接子任务,及其全部子孙)` : ''; return `
    彻底删除「${esc(t.title)}」${esc(cascadeNote)}?此操作不可恢复!
    `; } const cascadeHint = childCount ? `(含 ${childCount} 子任务)` : ''; return ``; })(); if (cancelSection || deleteSection) { parts.push(`
    ${cancelSection}${deleteSection}
    `); } return `
    ${parts.join('')}
    `; } // ── 渲染:事件流 ── 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; 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 '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 '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'; } } 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(); // 刷新当前值显示 $('#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); // ── 表单提交 ── $('#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);