c6e66baa19
- web/: phosphor 调度台风格纯静态看板,审批闸内联 accept/reject(拒绝必填意见),WS 实时 - src/api/static.ts: 手写静态服务(路径穿越防护),daemon 接入 - src/mcp/: stdio MCP server,@modelcontextprotocol/sdk 1.29.0,12 工具薄封装 REST - src/cli/: 零依赖 CLI(project/task/next/approvals/import-todo),旧 todo.json 导入器 - 实测: pangolin 18 条旧任务导入 45 条;端到端验证通过 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
616 lines
22 KiB
JavaScript
616 lines
22 KiB
JavaScript
/* ════════════════════════════════════════════════════════════
|
||
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': '运行结束',
|
||
};
|
||
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',
|
||
};
|
||
|
||
// ── 全局状态 ──
|
||
const S = {
|
||
projects: [],
|
||
currentProjectId: null,
|
||
tasks: [],
|
||
approvals: [],
|
||
events: [], // 新→旧
|
||
collapsed: new Set(),
|
||
expanded: new Set(),
|
||
rejectOpen: new Set(),
|
||
};
|
||
|
||
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; }
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
|
||
async function refresh() {
|
||
try {
|
||
await loadProjectData();
|
||
renderAll();
|
||
} catch (e) { toast(e.message); }
|
||
}
|
||
|
||
async function fullRefresh() {
|
||
try {
|
||
await loadProjects();
|
||
await loadProjectData();
|
||
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();
|
||
renderGates();
|
||
renderTree();
|
||
renderEvents();
|
||
renderParentOptions();
|
||
restoreDrafts(snap);
|
||
}
|
||
|
||
// ── 渲染:侧栏 ──
|
||
function renderSidebar() {
|
||
const ul = $('#projectList');
|
||
if (!S.projects.length) {
|
||
ul.innerHTML = `<li class="p-empty">暂无项目<br>点「+ 新建」登记第一个仓库</li>`;
|
||
return;
|
||
}
|
||
ul.innerHTML = S.projects.map((p) => `
|
||
<li class="${p.id === S.currentProjectId ? 'active' : ''}" data-action="select-project" data-id="${esc(p.id)}">
|
||
<span class="p-name">${esc(p.name)}</span>
|
||
<span class="p-path">${esc(p.repoPath)}</span>
|
||
</li>`).join('');
|
||
}
|
||
|
||
function renderTopbar() {
|
||
const p = S.projects.find((x) => x.id === S.currentProjectId);
|
||
$('#projTitle').textContent = p ? p.name : '未选择项目';
|
||
$('#projMeta').textContent = p
|
||
? `${p.repoPath} · 分支 ${p.defaultBranch} · ${p.status === 'active' ? '活跃' : '暂停'} · 任务 ${S.tasks.length}`
|
||
: '';
|
||
const gc = $('#gateCount');
|
||
if (S.approvals.length) {
|
||
gc.hidden = false;
|
||
gc.textContent = `⚠ ${S.approvals.length} 项待审批`;
|
||
} else gc.hidden = true;
|
||
}
|
||
|
||
// ── 渲染:审批闸 ──
|
||
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 ? `<pre class="doc">${esc(text)}</pre>` : `<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(`<div class="gate-doc-label">RESULT · 执行结果</div>
|
||
<dl class="result-kv">
|
||
${r.branch ? `<dt>分支</dt><dd>${esc(r.branch)}</dd>` : ''}
|
||
${r.worktree ? `<dt>worktree</dt><dd>${esc(r.worktree)}</dd>` : ''}
|
||
${r.prUrl ? `<dt>PR</dt><dd><a href="${esc(r.prUrl)}" target="_blank">${esc(r.prUrl)}</a></dd>` : ''}
|
||
</dl>`);
|
||
if (r.diffSummary) blocks.push(`<div class="gate-doc-label">DIFF 摘要</div><pre class="doc">${esc(r.diffSummary)}</pre>`);
|
||
} else {
|
||
blocks.push(`<pre class="doc empty">(无执行结果记录)</pre>`);
|
||
}
|
||
if (t.operations) doc('OPERATIONS · 执行的操作', t.operations);
|
||
else if (t.spec) doc('SPEC · 改动方案', t.spec);
|
||
}
|
||
return blocks.join('');
|
||
}
|
||
|
||
function renderGates() {
|
||
const sec = $('#gateSection');
|
||
if (!S.approvals.length) { sec.hidden = true; return; }
|
||
sec.hidden = false;
|
||
$('#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)}
|
||
</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)}">✓ 接受</button>
|
||
<button class="btn btn-reject" data-action="gate-reject-toggle" data-id="${esc(t.id)}">✗ 拒绝</button>
|
||
${gate === 'exec' ? `<span class="proj-meta">接受 = 认可改动并标记完成(合并不自动执行)</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 cplxBadge(c) {
|
||
return `<span class="cplx cplx-${esc(c)}">${CPLX_LABEL[c] || esc(c)}</span>`;
|
||
}
|
||
function statusChip(st) {
|
||
return `<span class="chip chip-${STATUS_GROUP[st] || 'idle'}">${STATUS_LABEL[st] || esc(st)}</span>`;
|
||
}
|
||
|
||
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 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 m = childrenMap();
|
||
const renderNode = (t) => {
|
||
const kids = m.get(t.id) || [];
|
||
const collapsed = S.collapsed.has(t.id);
|
||
const expanded = S.expanded.has(t.id);
|
||
const isGate = !!GATE_OF[t.status];
|
||
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' : ''}" data-action="toggle-detail" data-id="${esc(t.id)}">
|
||
${caret}
|
||
<span class="t-title">${esc(t.title)}<span class="t-id">${esc(t.id.slice(-6))}</span></span>
|
||
<span class="spacer"></span>
|
||
<span class="t-prio ${t.priority > 0 ? 'hot' : ''}">P${t.priority}</span>
|
||
${cplxBadge(t.complexity)}
|
||
${statusChip(t.status)}
|
||
</div>
|
||
${expanded ? renderDetail(t) : ''}
|
||
${kids.length && !collapsed ? `<div class="task-children">${kids.map(renderNode).join('')}</div>` : ''}
|
||
</div>`;
|
||
};
|
||
root.innerHTML = (m.get('__root__') || []).map(renderNode).join('');
|
||
}
|
||
|
||
// ── 渲染:任务详情 ──
|
||
function writableField(t) {
|
||
if (t.status === 'analyzing') return { field: 'plan', label: '分析与拆解(plan)', next: 'plan_review' };
|
||
if (t.status === 'speccing') return { field: 'spec', label: '改动方案(spec)', next: 'spec_review' };
|
||
if (t.status === 'ready') return { field: 'operations', label: '将执行的操作(operations)', next: null };
|
||
return null;
|
||
}
|
||
|
||
function renderDetail(t) {
|
||
const parts = [];
|
||
|
||
// 已有产出
|
||
const docs = [['plan', 'PLAN · 分析拆解'], ['spec', 'SPEC · 方案'], ['operations', 'OPERATIONS · 操作']];
|
||
for (const [f, label] of docs) {
|
||
if (t[f]) parts.push(`<div><div class="detail-label">${label}</div><pre class="doc">${esc(t[f])}</pre></div>`);
|
||
}
|
||
|
||
// 写产出入口
|
||
const w = writableField(t);
|
||
if (w) {
|
||
parts.push(`
|
||
<div class="write-box">
|
||
<div class="detail-label">填写 ${w.label}</div>
|
||
<textarea id="out-${esc(t.id)}" placeholder="多行文本…">${esc(t[w.field] || '')}</textarea>
|
||
<div class="form-actions form-row">
|
||
<button class="btn" data-action="save-output" data-id="${esc(t.id)}" data-field="${w.field}">保存${w.field}</button>
|
||
${w.next ? `<button class="btn btn-solid" data-action="submit-review" data-id="${esc(t.id)}" data-to="${w.next}">提交评审 →</button>` : ''}
|
||
</div>
|
||
</div>`);
|
||
}
|
||
|
||
// result
|
||
if (t.result) {
|
||
const r = t.result;
|
||
parts.push(`
|
||
<div><div class="detail-label">RESULT · 执行结果</div>
|
||
<dl class="result-kv">
|
||
<dt>分支</dt><dd>${esc(r.branch || '—')}</dd>
|
||
<dt>worktree</dt><dd>${esc(r.worktree || '—')}</dd>
|
||
${r.prUrl ? `<dt>PR</dt><dd>${esc(r.prUrl)}</dd>` : ''}
|
||
${r.commits && r.commits.length ? `<dt>commits</dt><dd>${r.commits.map(esc).join('<br>')}</dd>` : ''}
|
||
</dl>
|
||
${r.diffSummary ? `<pre class="doc" style="margin-top:6px">${esc(r.diffSummary)}</pre>` : ''}
|
||
</div>`);
|
||
}
|
||
|
||
// 审批历史
|
||
if (t.approvals && t.approvals.length) {
|
||
parts.push(`
|
||
<div><div class="detail-label">审批历史</div>
|
||
${t.approvals.map((a) => `
|
||
<div class="approval-item">
|
||
<span class="ap-act-${a.action}">${a.action === 'accept' ? '✓ 通过' : '✗ 驳回'}</span>
|
||
<span class="ap-gate">${GATE_LABEL[a.gate] || esc(a.gate)} · ${esc(a.actor)}</span>
|
||
<span class="ap-time">${fmtTime(a.at)}</span>
|
||
${a.reason ? `<span class="ap-reason">${esc(a.reason)}</span>` : ''}
|
||
</div>`).join('')}
|
||
</div>`);
|
||
}
|
||
|
||
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)}${t.deps && t.deps.length ? ' · 依赖 ' + t.deps.map(esc).join(', ') : ''}</div>`);
|
||
|
||
return `<div class="task-detail"><div class="detail-grid">${parts.join('')}</div></div>`;
|
||
}
|
||
|
||
// ── 渲染:事件流 ──
|
||
function eventDetail(e) {
|
||
const t = S.tasks.find((x) => x.id === e.taskId);
|
||
const name = t ? t.title : (e.taskId ? e.taskId.slice(-6) : '');
|
||
const p = e.payload || {};
|
||
if (e.type === 'status.changed') {
|
||
return `${name}:${STATUS_LABEL[p.from] || p.from} → ${STATUS_LABEL[p.to] || p.to}`;
|
||
}
|
||
if (e.type === 'approval.granted' || e.type === 'approval.rejected') {
|
||
const r = p.reason ? `(${p.reason})` : '';
|
||
return `${name} · ${GATE_LABEL[p.gate] || p.gate}${r}`;
|
||
}
|
||
if (e.type === 'task.created') {
|
||
if (p.kind === 'project') return `项目「${p.name || ''}」已登记`;
|
||
return `${p.title || name}(${CPLX_LABEL[p.complexity] || ''})`;
|
||
}
|
||
if (e.type === 'task.updated') return `${name} · 字段 ${p.field || ''}`;
|
||
if (e.type === 'run.started' || e.type === 'run.finished') {
|
||
return `${name} · ${p.kind || ''} ${p.status || ''}`;
|
||
}
|
||
return name;
|
||
}
|
||
|
||
function renderEvents() {
|
||
const ul = $('#eventList');
|
||
if (!S.events.length) {
|
||
ul.innerHTML = `<li class="ev-empty">暂无事件</li>`;
|
||
return;
|
||
}
|
||
ul.innerHTML = S.events.slice(0, 80).map((e) => `
|
||
<li class="${EVENT_CLASS[e.type] || ''}">
|
||
<div class="ev-head">
|
||
<span class="ev-type">${EVENT_LABEL[e.type] || esc(e.type)}</span>
|
||
<span class="ev-time">${fmtTime(e.at)}</span>
|
||
</div>
|
||
<div class="ev-detail">${eventDetail(e)}</div>
|
||
</li>`).join('');
|
||
}
|
||
|
||
// ── 渲染:父任务下拉 ──
|
||
function renderParentOptions() {
|
||
const sel = document.querySelector('#newTaskPanel select[name=parentId]');
|
||
const cur = sel.value;
|
||
sel.innerHTML = `<option value="">(顶层)</option>` +
|
||
S.tasks.map((t) => `<option value="${esc(t.id)}">${esc('· '.repeat(t.depth - 1) + t.title)}</option>`).join('');
|
||
sel.value = cur;
|
||
}
|
||
|
||
// ── 动作 ──
|
||
async function act(fn, okMsg) {
|
||
try {
|
||
await fn();
|
||
if (okMsg) toast(okMsg, 'ok');
|
||
await refresh();
|
||
} catch (e) { toast(e.message); }
|
||
}
|
||
|
||
document.addEventListener('click', (ev) => {
|
||
const el = ev.target.closest('[data-action]');
|
||
if (!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();
|
||
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 'gate-reject-confirm': {
|
||
const ta = document.getElementById(`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 'save-output': {
|
||
const field = el.dataset.field;
|
||
const ta = document.getElementById(`out-${id}`);
|
||
const value = ta ? ta.value.trim() : '';
|
||
if (!value) { toast(`${field} 内容不能为空`); return; }
|
||
act(() => api(`/api/tasks/${id}/${field}`, {
|
||
method: 'POST', body: JSON.stringify({ [field]: value }),
|
||
}), `${field} 已保存`);
|
||
break;
|
||
}
|
||
|
||
case 'submit-review': {
|
||
const to = el.dataset.to;
|
||
const ta = document.getElementById(`out-${id}`);
|
||
const task = S.tasks.find((t) => t.id === id);
|
||
const field = task && writableField(task) ? writableField(task).field : null;
|
||
const value = ta ? ta.value.trim() : '';
|
||
act(async () => {
|
||
// 先保存当前草稿(有内容才保存),再流转进评审闸
|
||
if (field && value) {
|
||
await api(`/api/tasks/${id}/${field}`, {
|
||
method: 'POST', body: JSON.stringify({ [field]: value }),
|
||
});
|
||
}
|
||
await api(`/api/tasks/${id}/transition`, {
|
||
method: 'POST', body: JSON.stringify({ to }),
|
||
});
|
||
}, '已提交评审');
|
||
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 || 0),
|
||
};
|
||
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 = '';
|
||
}, '任务已创建');
|
||
});
|
||
|
||
// Esc 关模态
|
||
document.addEventListener('keydown', (ev) => {
|
||
if (ev.key === 'Escape') $('#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.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 */ } };
|
||
}
|
||
|
||
// ── 启动 ──
|
||
fullRefresh().then(connectWs);
|