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:
+615
@@ -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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
@@ -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
@@ -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); } }
|
||||
Reference in New Issue
Block a user