feat: Phase1 完整交付——Web 看板(frontend-design) + MCP server(12 工具) + CLI/导入器

- 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>
This commit is contained in:
wangjia
2026-06-12 21:23:08 +08:00
parent 3172718a94
commit c6e66baa19
11 changed files with 3219 additions and 8 deletions
+615
View File
@@ -0,0 +1,615 @@
/* ════════════════════════════════════════════════════════════
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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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);
+122
View File
@@ -0,0 +1,122 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MAESTRO · 任务调度台</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<!-- ════════ 左栏:项目 ════════ -->
<aside id="sidebar">
<div class="logo">
<div class="logo-word">MAESTRO<span class="cursor"></span></div>
<div class="logo-sub">多项目任务调度台</div>
</div>
<div class="side-head">
<span class="head-mark"></span>项目
<button class="btn btn-ghost btn-xs" data-action="open-new-project">+ 新建</button>
</div>
<ul id="projectList" class="project-list"></ul>
<div class="side-foot">
<span id="wsDot" class="ws-dot off"></span>
<span id="wsText">连接中…</span>
</div>
</aside>
<!-- ════════ 中栏:闸 + 任务树 ════════ -->
<main id="main">
<header id="topbar">
<div>
<div id="projTitle" class="proj-title"></div>
<div id="projMeta" class="proj-meta"></div>
</div>
<div class="spacer"></div>
<div id="gateCount" class="gate-count" hidden></div>
</header>
<section id="gateSection" class="gate-section" hidden>
<div class="gate-stripe"></div>
<div class="sec-head gate-head"><span class="head-mark amber"></span>审批闸 · 等待裁决</div>
<div id="gateList"></div>
</section>
<section id="taskSection">
<div class="sec-head">
<span class="head-mark"></span>任务树
<button class="btn btn-ghost btn-xs" data-action="toggle-new-task">+ 新建任务</button>
</div>
<form id="newTaskPanel" class="panel form-panel" hidden>
<div class="form-row">
<label class="grow">标题 <span class="req">*</span>
<input name="title" type="text" placeholder="要做什么" autocomplete="off">
</label>
</div>
<div class="form-row">
<label>复杂度 <span class="req">*</span>
<span class="seg" id="cplxSeg">
<input type="radio" name="complexity" value="hard" id="cx-h"><label for="cx-h" class="seg-h">HARD</label>
<input type="radio" name="complexity" value="medium" id="cx-m" checked><label for="cx-m" class="seg-m">MEDIUM</label>
<input type="radio" name="complexity" value="easy" id="cx-e"><label for="cx-e" class="seg-e">EASY</label>
</span>
</label>
<label>父任务
<select name="parentId"><option value="">(顶层)</option></select>
</label>
<label class="narrow">优先级
<input name="priority" type="number" value="0" step="1">
</label>
</div>
<div class="form-row form-actions">
<button type="submit" class="btn btn-solid">创建任务</button>
<button type="button" class="btn" data-action="toggle-new-task">取消</button>
</div>
</form>
<div id="taskTree"></div>
</section>
</main>
<!-- ════════ 右栏:事件流 ════════ -->
<aside id="eventPanel">
<div class="sec-head"><span class="head-mark"></span>事件流</div>
<ul id="eventList" class="event-list"></ul>
</aside>
</div>
<!-- 新建项目 模态 -->
<div id="modalRoot" class="modal-root" hidden>
<div class="modal-mask" data-action="close-modal"></div>
<form id="newProjectForm" class="modal panel">
<div class="sec-head"><span class="head-mark"></span>新建项目</div>
<label>名称 <span class="req">*</span>
<input name="name" type="text" placeholder="my-project" autocomplete="off">
</label>
<label>仓库路径 <span class="req">*</span>
<input name="repoPath" type="text" placeholder="/path/to/repo" autocomplete="off">
</label>
<label>默认分支
<input name="defaultBranch" type="text" placeholder="main">
</label>
<label>校验命令
<input name="verifyCmd" type="text" placeholder="npm test(可空)">
</label>
<div class="form-row form-actions">
<button type="submit" class="btn btn-solid">创建</button>
<button type="button" class="btn" data-action="close-modal">取消</button>
</div>
</form>
</div>
<div id="toastRoot" class="toast-root"></div>
<script src="app.js"></script>
</body>
</html>
+443
View File
@@ -0,0 +1,443 @@
/* ════════════════════════════════════════════════════════════════
MAESTRO 调度台 · phosphor console
碳绿底 + 磷光绿/琥珀/信号红,等宽中文混排,扫描线质感
════════════════════════════════════════════════════════════════ */
:root {
--bg: #0a0d0b;
--bg-deep: #070908;
--panel: #10140f;
--panel-2: #151b14;
--line: #232d23;
--line-soft: #1a221a;
--ink: #d8e4d4;
--muted: #76876f;
--faint: #4a5747;
--green: #5fdd7d;
--green-dim: #2e6b3d;
--amber: #f0b429;
--amber-dim: #6b5414;
--red: #ff5d5d;
--red-dim: #6b2424;
--cyan: #59c8d8;
--cyan-dim: #1e4e57;
--mono: 'IBM Plex Mono', 'Noto Sans SC', ui-monospace, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--ink);
font-family: var(--mono);
font-size: 13px;
line-height: 1.55;
overflow: hidden;
}
/* 扫描线 + 暗角 氛围层 */
body::before {
content: '';
position: fixed; inset: 0; z-index: 999; pointer-events: none;
background:
repeating-linear-gradient(0deg, rgba(0,0,0,.16) 0 1px, transparent 1px 3px),
radial-gradient(ellipse at 50% 40%, transparent 55%, rgba(0,0,0,.5));
opacity: .5;
}
::selection { background: var(--green-dim); color: #fff; }
/* 滚动条 */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: var(--line); border-radius: 0; }
::-webkit-scrollbar-thumb:hover { background: var(--green-dim); }
/* ── 三栏骨架 ─────────────────────────────────────────────── */
#app {
display: grid;
grid-template-columns: 232px minmax(0,1fr) 320px;
height: 100vh;
}
#sidebar {
background: var(--bg-deep);
border-right: 1px solid var(--line);
display: flex; flex-direction: column;
overflow-y: auto;
}
#main { overflow-y: auto; padding: 0 22px 60px; }
#eventPanel {
background: var(--bg-deep);
border-left: 1px solid var(--line);
overflow-y: auto;
padding-bottom: 30px;
}
@media (max-width: 1100px) {
body { overflow: auto; }
#app { grid-template-columns: 1fr; height: auto; }
#sidebar, #eventPanel { border: none; border-bottom: 1px solid var(--line); }
}
/* ── Logo ────────────────────────────────────────────────── */
.logo { padding: 20px 16px 14px; border-bottom: 1px solid var(--line-soft); }
.logo-word {
font-size: 19px; font-weight: 700; letter-spacing: .28em;
color: var(--green);
text-shadow: 0 0 12px rgba(95,221,125,.45);
}
.cursor { animation: blink 1.1s steps(1) infinite; margin-left: 1px; }
@keyframes blink { 50% { opacity: 0; } }
.logo-sub { margin-top: 3px; font-size: 11px; color: var(--muted); letter-spacing: .35em; }
/* ── 区块标题 ─────────────────────────────────────────────── */
.sec-head {
display: flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 600; letter-spacing: .22em;
color: var(--muted); text-transform: uppercase;
padding: 18px 0 10px;
position: sticky; top: 0; z-index: 5;
background: linear-gradient(var(--bg) 75%, transparent);
}
#sidebar .sec-head, .side-head {
display: flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 600; letter-spacing: .22em; color: var(--muted);
padding: 16px 16px 8px; background: none; position: static;
}
#eventPanel .sec-head { padding: 18px 16px 10px; background: linear-gradient(var(--bg-deep) 75%, transparent); }
.head-mark { color: var(--green); }
.head-mark.amber { color: var(--amber); }
.side-head .btn, .sec-head .btn { margin-left: auto; letter-spacing: .1em; }
/* ── 项目列表 ─────────────────────────────────────────────── */
.project-list { list-style: none; flex: 1; }
.project-list li {
padding: 9px 16px 9px 14px;
border-left: 2px solid transparent;
cursor: pointer;
display: flex; flex-direction: column; gap: 1px;
}
.project-list li:hover { background: var(--panel); }
.project-list li.active {
background: var(--panel-2);
border-left-color: var(--green);
}
.project-list li.active .p-name { color: var(--green); }
.p-name { font-weight: 600; font-size: 13px; }
.p-path { font-size: 10.5px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.p-empty { padding: 14px 16px; color: var(--faint); font-size: 12px; }
.side-foot {
border-top: 1px solid var(--line-soft);
padding: 10px 16px; font-size: 11px; color: var(--muted);
display: flex; align-items: center; gap: 7px;
}
.ws-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.ws-dot.on { background: var(--green); box-shadow: 0 0 8px var(--green); }
.ws-dot.off { background: var(--amber); animation: pulse 1s infinite; }
@keyframes pulse { 50% { opacity: .25; } }
/* ── 顶栏 ────────────────────────────────────────────────── */
#topbar {
display: flex; align-items: flex-end; gap: 14px;
padding: 22px 0 14px;
border-bottom: 1px solid var(--line);
}
.proj-title { font-size: 20px; font-weight: 700; letter-spacing: .04em; }
.proj-meta { font-size: 11px; color: var(--muted); margin-top: 2px; }
.spacer { flex: 1; }
.gate-count {
font-size: 11px; letter-spacing: .12em; color: var(--amber);
border: 1px solid var(--amber-dim); padding: 4px 10px;
animation: pulse 2.2s infinite;
}
/* ── 审批闸 ─────────────────────────────────────────────── */
.gate-section { margin-top: 18px; }
.gate-stripe {
height: 5px;
background: repeating-linear-gradient(-45deg,
var(--amber) 0 9px, transparent 9px 18px);
opacity: .8;
}
.gate-head { padding-top: 12px; position: static; background: none; color: var(--amber); }
.gate-card {
background: var(--panel);
border: 1px solid var(--amber-dim);
margin-bottom: 12px;
animation: rise .25s ease both;
}
@keyframes rise { from { opacity: 0; transform: translateY(5px); } }
.gate-card-head {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 10px 14px;
border-bottom: 1px solid var(--line-soft);
}
.gate-kind {
font-size: 10.5px; font-weight: 700; letter-spacing: .18em;
color: var(--bg); background: var(--amber);
padding: 2px 8px;
}
.gate-title { font-weight: 600; }
.gate-body { padding: 12px 14px; }
.gate-doc-label { font-size: 10.5px; color: var(--muted); letter-spacing: .18em; margin: 8px 0 4px; }
.gate-doc-label:first-child { margin-top: 0; }
pre.doc {
background: var(--bg-deep);
border: 1px solid var(--line-soft);
border-left: 2px solid var(--green-dim);
padding: 10px 12px;
font-family: var(--mono); font-size: 12.5px;
white-space: pre-wrap; word-break: break-word;
max-height: 280px; overflow-y: auto;
color: var(--ink);
}
pre.doc.empty { color: var(--faint); border-left-color: var(--line); }
.gate-actions {
display: flex; gap: 10px; align-items: center;
padding: 10px 14px;
border-top: 1px solid var(--line-soft);
}
.reject-form { padding: 0 14px 12px; display: flex; gap: 10px; align-items: flex-start; }
.reject-form textarea {
flex: 1; min-height: 56px;
border-color: var(--red-dim);
}
.reject-form textarea:focus { border-color: var(--red); }
/* ── 按钮 ────────────────────────────────────────────────── */
.btn {
font-family: var(--mono); font-size: 12px; font-weight: 600;
letter-spacing: .08em;
background: transparent; color: var(--ink);
border: 1px solid var(--line);
padding: 6px 14px; cursor: pointer;
transition: all .12s;
}
.btn:hover { border-color: var(--muted); background: var(--panel-2); }
.btn-xs { padding: 2px 8px; font-size: 11px; }
.btn-ghost { border-color: transparent; color: var(--muted); }
.btn-ghost:hover { color: var(--green); border-color: var(--green-dim); background: transparent; }
.btn-solid {
background: var(--green); color: var(--bg-deep); border-color: var(--green);
}
.btn-solid:hover { background: #79ec94; border-color: #79ec94; box-shadow: 0 0 14px rgba(95,221,125,.35); }
.btn-accept { border-color: var(--green-dim); color: var(--green); }
.btn-accept:hover { background: var(--green); color: var(--bg-deep); border-color: var(--green); box-shadow: 0 0 14px rgba(95,221,125,.3); }
.btn-reject { border-color: var(--red-dim); color: var(--red); }
.btn-reject:hover { background: var(--red); color: var(--bg-deep); border-color: var(--red); box-shadow: 0 0 14px rgba(255,93,93,.3); }
.btn:disabled { opacity: .4; cursor: not-allowed; }
/* ── 表单 ────────────────────────────────────────────────── */
.panel { background: var(--panel); border: 1px solid var(--line); }
.form-panel { padding: 14px; margin-bottom: 14px; }
.form-row { display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap; }
.form-row + .form-row { margin-top: 12px; }
.form-actions { justify-content: flex-end; }
label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: var(--muted); letter-spacing: .08em; }
label.grow { flex: 1; }
label.narrow input { width: 76px; }
.req { color: var(--red); }
input[type=text], input[type=number], select, textarea {
font-family: var(--mono); font-size: 13px;
background: var(--bg-deep); color: var(--ink);
border: 1px solid var(--line);
padding: 7px 10px;
outline: none;
transition: border-color .12s;
}
input:focus, select:focus, textarea:focus { border-color: var(--green-dim); box-shadow: 0 0 0 1px var(--green-dim); }
textarea { resize: vertical; min-height: 72px; width: 100%; }
/* 复杂度分段选择 */
.seg { display: inline-flex; border: 1px solid var(--line); }
.seg input { position: absolute; opacity: 0; pointer-events: none; }
.seg label {
padding: 7px 13px; cursor: pointer;
font-size: 11px; font-weight: 700; letter-spacing: .12em;
color: var(--faint); flex-direction: row;
}
.seg label + input + label, .seg input + label { border-left: 0; }
.seg label:not(:first-of-type) { border-left: 1px solid var(--line); }
.seg input:checked + label.seg-h { background: var(--red-dim); color: var(--red); }
.seg input:checked + label.seg-m { background: var(--amber-dim); color: var(--amber); }
.seg input:checked + label.seg-e { background: var(--green-dim); color: var(--green); }
/* ── 任务树 ─────────────────────────────────────────────── */
.tree-empty {
padding: 46px 0; text-align: center; color: var(--faint);
border: 1px dashed var(--line);
}
.tree-empty b { display: block; font-size: 15px; color: var(--muted); margin-bottom: 6px; letter-spacing: .2em; }
.task-node { border-left: 1px solid var(--line-soft); }
.task-node.depth-1 { border-left: none; }
.task-row {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px 8px 6px;
border-bottom: 1px solid var(--line-soft);
cursor: pointer;
transition: background .1s;
}
.task-row:hover { background: var(--panel); }
.task-row.is-gate { background: rgba(240,180,41,.045); }
.task-row.expanded { background: var(--panel-2); }
.caret {
width: 16px; flex: none; text-align: center;
color: var(--muted); font-size: 10px;
transition: transform .12s; user-select: none;
}
.caret.open { transform: rotate(90deg); color: var(--green); }
.caret.leaf { color: var(--faint); }
.t-title { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.t-title .t-id { color: var(--faint); font-size: 10.5px; margin-left: 6px; letter-spacing: 0; }
.task-row .spacer { flex: 1; min-width: 8px; }
.t-prio { font-size: 10.5px; color: var(--faint); flex: none; }
.t-prio.hot { color: var(--amber); }
/* 复杂度徽章 */
.cplx {
flex: none; font-size: 10px; font-weight: 700; letter-spacing: .14em;
padding: 1px 7px; border: 1px solid;
}
.cplx-hard { color: var(--red); border-color: var(--red-dim); background: rgba(255,93,93,.07); }
.cplx-medium { color: var(--amber); border-color: var(--amber-dim); background: rgba(240,180,41,.07); }
.cplx-easy { color: var(--green); border-color: var(--green-dim); background: rgba(95,221,125,.07); }
/* 状态 chip */
.chip {
flex: none; display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; padding: 1px 8px;
border: 1px solid var(--line); color: var(--muted);
white-space: nowrap;
}
.chip::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.chip-idle { color: var(--muted); }
.chip-work { color: var(--cyan); border-color: var(--cyan-dim); }
.chip-gate { color: var(--amber); border-color: var(--amber-dim); }
.chip-gate::before { animation: pulse 1.4s infinite; }
.chip-container { color: #9bb4c8; border-color: #2c3c4a; }
.chip-go { color: var(--green); border-color: var(--green-dim); }
.chip-run { color: var(--cyan); border-color: var(--cyan); box-shadow: 0 0 10px rgba(89,200,216,.25); }
.chip-run::before { animation: pulse .8s infinite; }
.chip-hold { color: var(--faint); }
.chip-bad { color: var(--red); border-color: var(--red-dim); }
.chip-bad::before { animation: pulse 1s infinite; }
.chip-done { color: var(--bg-deep); background: var(--green); border-color: var(--green); font-weight: 700; }
.chip-dead { color: var(--faint); text-decoration: line-through; }
.chip-dead::before { background: var(--faint); }
/* 子节点缩进 */
.task-children { margin-left: 22px; }
/* ── 任务详情 ─────────────────────────────────────────────── */
.task-detail {
background: var(--panel);
border-bottom: 1px solid var(--line);
border-left: 2px solid var(--green-dim);
padding: 14px 16px;
animation: rise .2s ease both;
}
.detail-grid { display: grid; gap: 14px; }
.detail-label { font-size: 10.5px; color: var(--muted); letter-spacing: .18em; margin-bottom: 4px; }
.approval-item {
display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap;
font-size: 12px; padding: 5px 0;
border-bottom: 1px dashed var(--line-soft);
}
.approval-item:last-child { border-bottom: none; }
.ap-act-accept { color: var(--green); font-weight: 700; }
.ap-act-reject { color: var(--red); font-weight: 700; }
.ap-gate { color: var(--muted); }
.ap-time { color: var(--faint); font-size: 11px; margin-left: auto; }
.ap-reason { width: 100%; color: var(--amber); font-size: 12px; padding-left: 14px; }
.ap-reason::before { content: '↳ '; color: var(--faint); }
.result-kv { font-size: 12px; display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; }
.result-kv dt { color: var(--muted); }
.result-kv dd { word-break: break-all; }
.write-box { margin-top: 4px; }
.write-box .form-actions { margin-top: 8px; }
.detail-actions { display: flex; gap: 10px; }
/* ── 事件流 ─────────────────────────────────────────────── */
.event-list { list-style: none; padding: 0 14px; }
.event-list li {
padding: 7px 0 7px 12px;
border-bottom: 1px solid var(--line-soft);
position: relative;
font-size: 11.5px;
animation: rise .25s ease both;
}
.event-list li::before {
content: ''; position: absolute; left: 0; top: 13px;
width: 5px; height: 5px; background: var(--ev, var(--muted));
}
.ev-head { display: flex; gap: 8px; align-items: baseline; }
.ev-type { font-weight: 600; color: var(--ev, var(--ink)); letter-spacing: .04em; }
.ev-time { color: var(--faint); font-size: 10.5px; margin-left: auto; flex: none; }
.ev-detail { color: var(--muted); margin-top: 1px; word-break: break-word; }
.ev-empty { padding: 30px 0; color: var(--faint); text-align: center; border: none !important; }
.ev-empty::before { display: none; }
li.ev-created { --ev: var(--green); }
li.ev-status { --ev: var(--cyan); }
li.ev-approve { --ev: var(--green); }
li.ev-rejected { --ev: var(--red); }
li.ev-gatewait { --ev: var(--amber); }
li.ev-run { --ev: var(--cyan); }
li.ev-updated { --ev: var(--muted); }
/* ── 模态 ────────────────────────────────────────────────── */
.modal-root { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; }
.modal-root[hidden] { display: none; }
.modal-mask { position: absolute; inset: 0; background: rgba(4,6,5,.78); backdrop-filter: blur(2px); }
.modal {
position: relative; width: min(460px, 92vw);
padding: 18px 20px 20px;
display: flex; flex-direction: column; gap: 12px;
box-shadow: 0 0 0 1px var(--green-dim), 0 18px 60px rgba(0,0,0,.6);
animation: rise .18s ease both;
}
.modal .sec-head { padding: 0 0 4px; position: static; background: none; }
/* ── Toast ───────────────────────────────────────────────── */
.toast-root {
position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%);
z-index: 1200; display: flex; flex-direction: column; gap: 8px; align-items: center;
}
.toast {
font-family: var(--mono); font-size: 12.5px;
background: var(--bg-deep);
border: 1px solid var(--line);
padding: 9px 16px;
max-width: 70vw;
animation: toastIn .18s ease both;
box-shadow: 0 8px 30px rgba(0,0,0,.55);
}
.toast.err { border-color: var(--red); color: var(--red); }
.toast.ok { border-color: var(--green-dim); color: var(--green); }
.toast.out { opacity: 0; transition: opacity .3s; }
@keyframes toastIn { from { opacity: 0; transform: translateY(8px); } }