feat: 同步引擎+Agent配置+依赖自动落位+看板大改(预览/md渲染/筛选/徽章组)
后端: - src/sync/todo-sync.ts: todo.json 单向同步引擎(导入=首次同步,幂等,source_ref 映射, subs 复杂度修正为 easy,旧侧 done 历史事实优先 forceDone) - 依赖自动落位:ready 意图按 deps 落位 blocked,依赖全 done 自动放行, daemon 启动 reconcileDeps 对账,手动绕过会弹回 - 新 API: PATCH projects/:id(autonomy/concurrency)、POST :id/sync、GET /api/agents、 PATCH tasks/:id(title/priority/complexity 重置) - daemon 定时同步(MAESTRO_SYNC_INTERVAL 默认 300s) + project.synced 事件 - priority 语义翻转: P0 最高/P1 默认/P2 最低,取值限 0..2,排序/映射/MCP/CLI 全跟进 - 静态服务发 no-cache 头(修浏览器吃旧 CSS/JS) - schema 迁移: tasks.source_ref / projects.last_sync_at(ensureColumn 平滑升级旧库) 看板: - 全屏预览模式(94vh 读完整方案+就地裁决,Esc/遮罩/裁决自动关闭) - 产出 markdown 渲染为 HTML(零依赖渲染器,转义优先) - 任务树筛选(复杂度/状态分组/关键字)+ 顶栏徽章组(待审批/可执行/执行中,hover 展开) - 依赖可视化:详情 DEPS 区块 + 行内⛓等依赖 + 锚点跳转定位 - 按钮收敛:提交评审/编辑产出移除(CC 经 MCP 操作),界面只留用户动作 - Agent 执行面板 + 项目配置(并发/工作模式)+ 同步按钮 测试:21 个全过(新增 sync 幂等/迁移/patch/依赖落位/对账幂等) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+487
-57
@@ -24,13 +24,26 @@ 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': '运行结束',
|
||||
'run.started': '运行开始', 'run.finished': '运行结束', 'project.synced': 'todo 同步',
|
||||
};
|
||||
const EVENT_CLASS = {
|
||||
'task.created': 'ev-created', 'task.updated': 'ev-updated', 'status.changed': 'ev-status',
|
||||
'approval.requested': 'ev-gatewait', 'approval.granted': 'ev-approve',
|
||||
'approval.rejected': 'ev-rejected', 'run.started': 'ev-run', 'run.finished': 'ev-run',
|
||||
'project.synced': 'ev-created',
|
||||
};
|
||||
const AUTONOMY_LABEL = {
|
||||
manual: '手动', 'auto-easy': '自动执行 Easy', 'auto-approved': '自动执行已批准',
|
||||
};
|
||||
// 任务树筛选:状态按状态机分组(组可整组开关,组内可单选)
|
||||
const FILTER_GROUPS = [
|
||||
['待办', ['init', 'ready', 'blocked']],
|
||||
['进行中', ['analyzing', 'speccing', 'queued', 'executing', 'decomposed']],
|
||||
['待审批', ['plan_review', 'spec_review', 'exec_review']],
|
||||
['异常', ['failed', 'needs_attention']],
|
||||
['挂起', ['paused', 'cancelled']],
|
||||
['完成', ['done']],
|
||||
];
|
||||
|
||||
// ── 全局状态 ──
|
||||
const S = {
|
||||
@@ -42,6 +55,13 @@ const S = {
|
||||
collapsed: new Set(),
|
||||
expanded: new Set(),
|
||||
rejectOpen: new Set(),
|
||||
filter: { cplx: new Set(), status: new Set(), kw: '' },// 任务树筛选(内存态)
|
||||
matchCount: 0,
|
||||
agents: null, // GET /api/agents 结果(404 时为 null)
|
||||
cplxMenuFor: null, // 复杂度下拉打开的任务 id
|
||||
syncReqAt: 0, // 本端发起 sync 的时间(避免 WS 重复 toast)
|
||||
previewId: null, // 全局预览中的任务 id
|
||||
previewReject: false, // 预览层内驳回意见框是否展开
|
||||
};
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
@@ -54,6 +74,83 @@ function fmtTime(iso) {
|
||||
try { return new Date(iso).toLocaleTimeString('zh-CN', { hour12: false }); }
|
||||
catch { return iso; }
|
||||
}
|
||||
function fmtRel(iso) {
|
||||
if (!iso) return '';
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (!Number.isFinite(ms)) return String(iso);
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return '刚刚';
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m} 分钟前`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h} 小时前`;
|
||||
return `${Math.floor(h / 24)} 天前`;
|
||||
}
|
||||
|
||||
// ── 轻量 Markdown 渲染(零依赖;先整体转义再做结构转换,杜绝注入) ──
|
||||
function mdToHtml(src) {
|
||||
const inline = (s) => s
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
||||
const lines = esc(src).split('\n');
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let para = [];
|
||||
let list = null; // {type:'ul'|'ol', items:[]}
|
||||
const flushPara = () => { if (para.length) { out.push(`<p>${para.map(inline).join('<br>')}</p>`); para = []; } };
|
||||
const flushList = () => {
|
||||
if (list) {
|
||||
out.push(`<${list.type}>${list.items.map((x) => `<li>${inline(x)}</li>`).join('')}</${list.type}>`);
|
||||
list = null;
|
||||
}
|
||||
};
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (/^```/.test(line)) { // 围栏代码块
|
||||
flushPara(); flushList();
|
||||
const buf = []; i++;
|
||||
while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; }
|
||||
i++;
|
||||
out.push(`<pre class="codeblock">${buf.join('\n')}</pre>`);
|
||||
continue;
|
||||
}
|
||||
if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s\-:|]+\|\s*$/.test(lines[i + 1])) {
|
||||
flushPara(); flushList(); // 表格
|
||||
const cells = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => inline(c.trim()));
|
||||
const head = cells(line);
|
||||
i += 2;
|
||||
const rows = [];
|
||||
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(cells(lines[i])); i++; }
|
||||
out.push(`<table><thead><tr>${head.map((h) => `<th>${h}</th>`).join('')}</tr></thead><tbody>${
|
||||
rows.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`);
|
||||
continue;
|
||||
}
|
||||
let m;
|
||||
if ((m = line.match(/^(#{1,6})\s+(.*)$/))) { // 标题
|
||||
flushPara(); flushList();
|
||||
const lv = m[1].length;
|
||||
out.push(`<div class="md-h md-h${lv}">${inline(m[2])}</div>`);
|
||||
i++; continue;
|
||||
}
|
||||
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { flushPara(); flushList(); out.push('<hr>'); i++; continue; }
|
||||
if ((m = line.match(/^\s*>\s?(.*)$/))) { flushPara(); flushList(); out.push(`<blockquote>${inline(m[1])}</blockquote>`); i++; continue; }
|
||||
if ((m = line.match(/^\s*[-*]\s+(.*)$/))) {
|
||||
flushPara();
|
||||
if (!list || list.type !== 'ul') { flushList(); list = { type: 'ul', items: [] }; }
|
||||
list.items.push(m[1]); i++; continue;
|
||||
}
|
||||
if ((m = line.match(/^\s*\d+[.)]\s+(.*)$/))) {
|
||||
flushPara();
|
||||
if (!list || list.type !== 'ol') { flushList(); list = { type: 'ol', items: [] }; }
|
||||
list.items.push(m[1]); i++; continue;
|
||||
}
|
||||
if (/^\s*$/.test(line)) { flushPara(); flushList(); i++; continue; }
|
||||
para.push(line); i++;
|
||||
}
|
||||
flushPara(); flushList();
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// ── API ──
|
||||
async function api(path, opts = {}) {
|
||||
@@ -103,6 +200,12 @@ async function loadProjectData() {
|
||||
S.approvals = approvals;
|
||||
}
|
||||
|
||||
// agents 端点未就绪(404)时静默降级为空态,不打扰用户
|
||||
async function loadAgents() {
|
||||
try { S.agents = await api('/api/agents'); }
|
||||
catch { S.agents = null; }
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
await loadProjectData();
|
||||
@@ -113,7 +216,7 @@ async function refresh() {
|
||||
async function fullRefresh() {
|
||||
try {
|
||||
await loadProjects();
|
||||
await loadProjectData();
|
||||
await Promise.all([loadProjectData(), loadAgents()]);
|
||||
renderAll();
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
@@ -142,10 +245,13 @@ function renderAll() {
|
||||
const snap = snapshotDrafts();
|
||||
renderSidebar();
|
||||
renderTopbar();
|
||||
renderAgents();
|
||||
renderGates();
|
||||
renderTree();
|
||||
renderFilterBar();
|
||||
renderEvents();
|
||||
renderParentOptions();
|
||||
renderPreview();
|
||||
restoreDrafts(snap);
|
||||
}
|
||||
|
||||
@@ -169,11 +275,71 @@ function renderTopbar() {
|
||||
$('#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;
|
||||
// 徽章组:待审批 / 可执行(叶子+ready+依赖全 done,与编排器领取口径一致)/ 执行中
|
||||
$('#badgeGroup').hidden = !p;
|
||||
const hasKids = new Set(S.tasks.filter((x) => x.parentId).map((x) => x.parentId));
|
||||
const byId = new Map(S.tasks.map((x) => [x.id, x]));
|
||||
const nReady = S.tasks.filter((x) =>
|
||||
x.status === 'ready' && !hasKids.has(x.id) &&
|
||||
(x.deps || []).every((d) => byId.get(d)?.status === 'done'),
|
||||
).length;
|
||||
const nRun = S.tasks.filter((x) => x.status === 'executing').length;
|
||||
const setBdg = (sel, n) => {
|
||||
const el = $(sel);
|
||||
el.querySelector('.bdg-n').textContent = n;
|
||||
el.classList.toggle('zero', n === 0);
|
||||
};
|
||||
setBdg('#bdgGate', S.approvals.length);
|
||||
setBdg('#bdgReady', nReady);
|
||||
setBdg('#bdgRun', nRun);
|
||||
|
||||
// 同步 todo / 项目配置入口
|
||||
$('#topActions').hidden = !p;
|
||||
if (p) {
|
||||
const sb = $('#btnSync');
|
||||
const noTodo = p.hasTodoJson === false; // 字段未交付(undefined)时不禁用,错误由 toast 兜底
|
||||
sb.disabled = noTodo;
|
||||
sb.title = noTodo
|
||||
? '未发现 todo.json —— 约定路径 <repo>/todo/todo.json'
|
||||
: '从 <repo>/todo/todo.json 同步任务';
|
||||
$('#syncMeta').textContent = p.lastSyncAt ? `${fmtRel(p.lastSyncAt)}同步` : '';
|
||||
$('#cfgCurrent').textContent =
|
||||
`当前:并发 ${p.concurrency ?? '—'} · 模式 ${AUTONOMY_LABEL[p.autonomy] || p.autonomy || '—'}`;
|
||||
} else {
|
||||
$('#configPanel').hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 渲染:Agent 执行面板 ──
|
||||
function renderAgents() {
|
||||
const body = $('#agentBody');
|
||||
const a = S.agents;
|
||||
const total = a && Number(a.totalActive) > 0 ? Number(a.totalActive) : 0;
|
||||
$('#agentTotal').textContent = total ? `· ${total}` : '';
|
||||
if (!total) {
|
||||
body.innerHTML = `<div class="agent-empty">无 agent 在执行(自动执行将在 Phase 2 启用)</div>`;
|
||||
return;
|
||||
}
|
||||
const groups = (a.agents || []).filter((g) => g.active && g.active.length);
|
||||
body.innerHTML = `
|
||||
<div class="agent-flex">
|
||||
<div class="agent-big">${total}<span class="agent-big-sub">ACTIVE</span></div>
|
||||
<div class="agent-groups">
|
||||
${groups.map((g) => `
|
||||
<div class="agent-proj">
|
||||
<div class="agent-proj-head"><b>${esc(g.projectName)}</b>
|
||||
<span class="agent-proj-meta">并发 ${esc(String(g.concurrency ?? '—'))} · ${esc(AUTONOMY_LABEL[g.autonomy] || g.autonomy || '—')}</span>
|
||||
</div>
|
||||
${g.active.map((r) => `
|
||||
<div class="agent-run">
|
||||
<span class="agent-run-dot"></span>
|
||||
<span class="agent-run-title">${esc(r.taskTitle)}</span>
|
||||
<span class="agent-run-kind">${esc(r.kind || '')}</span>
|
||||
<span class="agent-run-time">${fmtRel(r.startedAt)}开始</span>
|
||||
</div>`).join('')}
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── 渲染:审批闸 ──
|
||||
@@ -182,7 +348,7 @@ function gateDocs(t) {
|
||||
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>`));
|
||||
(text ? `<div class="doc md">${mdToHtml(text)}</div>` : `<pre class="doc empty">(未填写)</pre>`));
|
||||
if (gate === 'plan') doc('PLAN · 分析与拆解', t.plan);
|
||||
if (gate === 'spec') doc('SPEC · 改动方案', t.spec);
|
||||
if (gate === 'exec') {
|
||||
@@ -218,6 +384,7 @@ function renderGates() {
|
||||
<span class="gate-title">${esc(t.title)}</span>
|
||||
${cplxBadge(t.complexity)}
|
||||
${statusChip(t.status)}
|
||||
<button class="btn btn-ghost btn-xs gate-preview-btn" data-action="preview-open" data-id="${esc(t.id)}" title="全屏预览方案">⛶ 预览</button>
|
||||
</div>
|
||||
<div class="gate-body">${gateDocs(t)}</div>
|
||||
<div class="gate-actions">
|
||||
@@ -235,9 +402,58 @@ function renderGates() {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── 渲染:全局预览(全屏读方案 + 就地裁决) ──
|
||||
function renderPreview() {
|
||||
const root = $('#previewRoot');
|
||||
if (!S.previewId) { root.hidden = true; root.innerHTML = ''; return; }
|
||||
const t = S.approvals.find((x) => x.id === S.previewId) || S.tasks.find((x) => x.id === S.previewId);
|
||||
// 任务已离开闸(被裁决/状态变化)→ 自动关闭
|
||||
if (!t || !GATE_OF[t.status]) { S.previewId = null; S.previewReject = false; root.hidden = true; root.innerHTML = ''; return; }
|
||||
const gate = GATE_OF[t.status];
|
||||
root.hidden = false;
|
||||
root.innerHTML = `
|
||||
<div class="preview-mask" data-action="preview-close"></div>
|
||||
<div class="preview-panel">
|
||||
<div class="preview-head">
|
||||
<span class="gate-kind">${GATE_LABEL[gate]}</span>
|
||||
<span class="preview-title">${esc(t.title)}</span>
|
||||
${cplxBadge(t.complexity)}
|
||||
${statusChip(t.status)}
|
||||
<span class="spacer"></span>
|
||||
<button class="btn btn-ghost" data-action="preview-close" title="关闭(Esc)">✕</button>
|
||||
</div>
|
||||
<div class="preview-body">${gateDocs(t)}</div>
|
||||
<div class="preview-foot">
|
||||
<button class="btn btn-accept" data-action="gate-accept" data-id="${esc(t.id)}">✓ 接受</button>
|
||||
<button class="btn btn-reject" data-action="preview-reject-toggle" data-id="${esc(t.id)}">✗ 拒绝</button>
|
||||
<span class="proj-meta">id ${esc(t.id)} · 更新 ${fmtTime(t.updatedAt)}</span>
|
||||
${S.previewReject ? `
|
||||
<div class="reject-form preview-reject">
|
||||
<textarea id="prev-rej-${esc(t.id)}" placeholder="驳回意见(必填)——说明问题与改进方向"></textarea>
|
||||
<button class="btn btn-reject" data-action="gate-reject-confirm" data-id="${esc(t.id)}" data-ta="prev-rej-${esc(t.id)}">确认驳回</button>
|
||||
<button class="btn" data-action="preview-reject-toggle" data-id="${esc(t.id)}">收起</button>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
if (S.previewReject) {
|
||||
const ta = document.getElementById(`prev-rej-${t.id}`);
|
||||
if (ta) ta.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 渲染:任务树 ──
|
||||
function cplxBadge(c) {
|
||||
return `<span class="cplx cplx-${esc(c)}">${CPLX_LABEL[c] || esc(c)}</span>`;
|
||||
// taskId 传入时徽章可点击 → 弹出复杂度选择(审批闸卡片不传,保持只读)
|
||||
function cplxBadge(c, taskId) {
|
||||
const text = CPLX_LABEL[c] || esc(c);
|
||||
if (!taskId) return `<span class="cplx cplx-${esc(c)}">${text}</span>`;
|
||||
const open = S.cplxMenuFor === taskId;
|
||||
const pop = open ? `<span class="cplx-pop">${['hard', 'medium', 'easy'].map((x) =>
|
||||
`<button type="button" class="cplx cplx-${x} ${x === c ? 'cur' : ''}" data-action="cplx-set" data-id="${esc(taskId)}" data-cplx="${x}">${CPLX_LABEL[x]}</button>`,
|
||||
).join('')}</span>` : '';
|
||||
return `<span class="cplx-wrap">
|
||||
<button type="button" class="cplx cplx-${esc(c)} cplx-btn" data-action="cplx-menu" data-id="${esc(taskId)}" title="点击修改复杂度(状态会按新复杂度重置)">${text}</button>
|
||||
${pop}
|
||||
</span>`;
|
||||
}
|
||||
function statusChip(st) {
|
||||
return `<span class="chip chip-${STATUS_GROUP[st] || 'idle'}">${STATUS_LABEL[st] || esc(st)}</span>`;
|
||||
@@ -253,6 +469,60 @@ function childrenMap() {
|
||||
return m;
|
||||
}
|
||||
|
||||
// ── 筛选 ──
|
||||
function filterActive() {
|
||||
const f = S.filter;
|
||||
return f.cplx.size > 0 || f.status.size > 0 || f.kw.trim() !== '';
|
||||
}
|
||||
function matchTask(t) {
|
||||
const f = S.filter;
|
||||
if (f.cplx.size && !f.cplx.has(t.complexity)) return false;
|
||||
if (f.status.size && !f.status.has(t.status)) return false;
|
||||
const kw = f.kw.trim().toLowerCase();
|
||||
if (kw && !String(t.title).toLowerCase().includes(kw)) return false;
|
||||
return true;
|
||||
}
|
||||
// 命中节点 + 其全部祖先(保留父链以维持树形)
|
||||
function computeVisible() {
|
||||
S.matchCount = 0;
|
||||
if (!filterActive()) return null;
|
||||
const byId = new Map(S.tasks.map((t) => [t.id, t]));
|
||||
const visible = new Set();
|
||||
const matched = new Set();
|
||||
for (const t of S.tasks) {
|
||||
if (!matchTask(t)) continue;
|
||||
matched.add(t.id);
|
||||
let cur = t;
|
||||
while (cur && !visible.has(cur.id)) {
|
||||
visible.add(cur.id);
|
||||
cur = cur.parentId ? byId.get(cur.parentId) : null;
|
||||
}
|
||||
}
|
||||
S.matchCount = matched.size;
|
||||
return { visible, matched };
|
||||
}
|
||||
|
||||
function renderFilterBar() {
|
||||
const bar = $('#filterBar');
|
||||
bar.hidden = !S.currentProjectId;
|
||||
if (bar.hidden) return;
|
||||
const f = S.filter;
|
||||
$('#filterCplx').innerHTML = ['hard', 'medium', 'easy'].map((c) =>
|
||||
`<button type="button" class="fchip fc-${c} ${f.cplx.has(c) ? 'on' : ''}" data-action="filter-cplx" data-cplx="${c}">${CPLX_LABEL[c]}</button>`,
|
||||
).join('');
|
||||
$('#filterStatus').innerHTML = FILTER_GROUPS.map(([name, sts], gi) => {
|
||||
const sel = sts.filter((s) => f.status.has(s)).length;
|
||||
const gCls = sel === sts.length ? 'on' : (sel ? 'part' : '');
|
||||
return `<span class="fgroup">
|
||||
<button type="button" class="fchip fgroup-chip ${gCls}" data-action="filter-group" data-gi="${gi}">${name}</button>
|
||||
${sts.map((s) => `<button type="button" class="fchip fst ${f.status.has(s) ? 'on' : ''}" data-action="filter-status" data-st="${s}">${STATUS_LABEL[s]}</button>`).join('')}
|
||||
</span>`;
|
||||
}).join('');
|
||||
const active = filterActive();
|
||||
$('#filterClear').hidden = !active;
|
||||
$('#filterCount').textContent = active ? `命中 ${S.matchCount} / ${S.tasks.length}` : '';
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
const root = $('#taskTree');
|
||||
if (!S.currentProjectId) {
|
||||
@@ -264,22 +534,37 @@ function renderTree() {
|
||||
return;
|
||||
}
|
||||
const m = childrenMap();
|
||||
const vis = computeVisible();
|
||||
if (vis && !vis.visible.size) {
|
||||
root.innerHTML = `<div class="tree-empty"><b>NO MATCH</b>没有任务命中当前筛选 —— 放宽条件或「清除筛选」</div>`;
|
||||
return;
|
||||
}
|
||||
const byId = new Map(S.tasks.map((x) => [x.id, x]));
|
||||
const renderNode = (t) => {
|
||||
if (vis && !vis.visible.has(t.id)) return '';
|
||||
const kids = m.get(t.id) || [];
|
||||
const collapsed = S.collapsed.has(t.id);
|
||||
const collapsed = !vis && S.collapsed.has(t.id); // 筛选时强制展开,保证命中可见
|
||||
const expanded = S.expanded.has(t.id);
|
||||
const isGate = !!GATE_OF[t.status];
|
||||
const isCtx = vis && !vis.matched.has(t.id); // 仅作为父链保留的节点,弱化显示
|
||||
const caret = kids.length
|
||||
? `<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)}">
|
||||
<div class="task-row ${isGate ? 'is-gate' : ''} ${expanded ? 'expanded' : ''} ${isCtx ? 'filter-ctx' : ''}" 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)}
|
||||
${(() => {
|
||||
// blocked(系统按依赖自动落位)→ 显示在等哪几条
|
||||
if (t.status !== 'blocked' || !(t.deps || []).length) return '';
|
||||
const unmet = t.deps.filter((d) => byId.get(d)?.status !== 'done');
|
||||
if (!unmet.length) return '';
|
||||
return `<span class="t-deps-wait" title="等待依赖完成:${unmet.map((d) => esc(byId.get(d)?.title || d)).join('、')}">⛓ 等依赖 ${unmet.length}</span>`;
|
||||
})()}
|
||||
<span class="t-prio ${t.priority === 0 ? 'hot' : ''}">P${t.priority}</span>
|
||||
${cplxBadge(t.complexity, t.id)}
|
||||
${statusChip(t.status)}
|
||||
</div>
|
||||
${expanded ? renderDetail(t) : ''}
|
||||
@@ -299,25 +584,18 @@ function writableField(t) {
|
||||
|
||||
function renderDetail(t) {
|
||||
const parts = [];
|
||||
const w = writableField(t);
|
||||
|
||||
// 已有产出
|
||||
// 产出全部只读展示:写产出与提交评审由 Claude Code 经 MCP 完成,看板只留用户动作(审批/配置/同步)
|
||||
const docs = [['plan', 'PLAN · 分析拆解'], ['spec', 'SPEC · 方案'], ['operations', 'OPERATIONS · 操作']];
|
||||
for (const [f, label] of docs) {
|
||||
if (t[f]) parts.push(`<div><div class="detail-label">${label}</div><pre class="doc">${esc(t[f])}</pre></div>`);
|
||||
if (!t[f]) continue;
|
||||
parts.push(`<div><div class="detail-label">${label}</div><div class="doc md">${mdToHtml(t[f])}</div></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>`);
|
||||
// 当前阶段应产出但还没有内容 → 占位提示
|
||||
if (w && !t[w.field]) {
|
||||
parts.push(`<div><div class="detail-label">${w.label}</div><div class="doc doc-pending">待 Claude Code 产出(经 MCP 写入并提交评审)</div></div>`);
|
||||
}
|
||||
|
||||
// result
|
||||
@@ -349,9 +627,25 @@ function renderDetail(t) {
|
||||
</div>`);
|
||||
}
|
||||
|
||||
// 依赖列表:标题 + 状态 chip,未完成的高亮(全部 done 本任务才可被领取)
|
||||
if (t.deps && t.deps.length) {
|
||||
const items = t.deps.map((d) => {
|
||||
const dt = S.tasks.find((x) => x.id === d);
|
||||
const ok = dt && dt.status === 'done';
|
||||
return `<div class="dep-item ${ok ? 'ok' : 'wait'}" data-action="goto-task" data-id="${esc(d)}" title="点击定位到该任务">
|
||||
<span class="dep-mark">${ok ? '✓' : '⛓'}</span>
|
||||
${dt ? statusChip(dt.status) : ''}
|
||||
<span class="dep-title">${esc(dt ? dt.title : d)}</span>
|
||||
<span class="t-id">${esc(d.slice(-6))}</span>
|
||||
<span class="dep-go">↧</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
parts.push(`<div><div class="detail-label">DEPS · 依赖(全部完成才可执行)</div><div class="dep-list">${items}</div></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>`);
|
||||
parts.push(`<div class="proj-meta">id ${esc(t.id)} · 深度 ${t.depth} · 创建 ${fmtTime(t.createdAt)} · 更新 ${fmtTime(t.updatedAt)}</div>`);
|
||||
|
||||
return `<div class="task-detail"><div class="detail-grid">${parts.join('')}</div></div>`;
|
||||
}
|
||||
@@ -373,6 +667,9 @@ function eventDetail(e) {
|
||||
return `${p.title || name}(${CPLX_LABEL[p.complexity] || ''})`;
|
||||
}
|
||||
if (e.type === 'task.updated') return `${name} · 字段 ${p.field || ''}`;
|
||||
if (e.type === 'project.synced') {
|
||||
return `新建 ${p.created ?? 0} · 推进 done ${p.doneAdvanced ?? 0} · 跳过 ${p.skipped ?? 0}`;
|
||||
}
|
||||
if (e.type === 'run.started' || e.type === 'run.finished') {
|
||||
return `${name} · ${p.kind || ''} ${p.status || ''}`;
|
||||
}
|
||||
@@ -415,6 +712,12 @@ async function act(fn, okMsg) {
|
||||
|
||||
document.addEventListener('click', (ev) => {
|
||||
const el = ev.target.closest('[data-action]');
|
||||
|
||||
// 复杂度下拉:点击弹层外任意处关闭
|
||||
if (S.cplxMenuFor && !ev.target.closest('.cplx-wrap')) {
|
||||
S.cplxMenuFor = null;
|
||||
renderTree();
|
||||
}
|
||||
if (!el) return;
|
||||
const action = el.dataset.action;
|
||||
const id = el.dataset.id;
|
||||
@@ -424,6 +727,8 @@ document.addEventListener('click', (ev) => {
|
||||
if (S.currentProjectId !== id) {
|
||||
S.currentProjectId = id;
|
||||
S.expanded.clear(); S.collapsed.clear(); S.rejectOpen.clear();
|
||||
S.cplxMenuFor = null;
|
||||
$('#configPanel').hidden = true;
|
||||
refresh();
|
||||
}
|
||||
break;
|
||||
@@ -472,8 +777,25 @@ document.addEventListener('click', (ev) => {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'preview-open':
|
||||
S.previewId = id;
|
||||
S.previewReject = false;
|
||||
renderPreview();
|
||||
break;
|
||||
|
||||
case 'preview-close':
|
||||
S.previewId = null;
|
||||
S.previewReject = false;
|
||||
renderPreview();
|
||||
break;
|
||||
|
||||
case 'preview-reject-toggle':
|
||||
S.previewReject = !S.previewReject;
|
||||
renderPreview();
|
||||
break;
|
||||
|
||||
case 'gate-reject-confirm': {
|
||||
const ta = document.getElementById(`rej-${id}`);
|
||||
const ta = document.getElementById(el.dataset.ta || `rej-${id}`);
|
||||
const reason = ta ? ta.value.trim() : '';
|
||||
if (!reason) { toast('驳回意见不能为空'); if (ta) ta.focus(); return; }
|
||||
S.rejectOpen.delete(id);
|
||||
@@ -483,36 +805,118 @@ document.addEventListener('click', (ev) => {
|
||||
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} 已保存`);
|
||||
case 'goto-task': {
|
||||
const target = S.tasks.find((x) => x.id === id);
|
||||
if (!target) { toast('任务不在当前项目'); break; }
|
||||
// 被筛选隐藏时清除筛选,保证可见
|
||||
const vis = computeVisible();
|
||||
if (vis && !vis.visible.has(id)) {
|
||||
S.filter.cplx.clear(); S.filter.status.clear(); S.filter.kw = '';
|
||||
renderFilterBar();
|
||||
}
|
||||
// 展开全部祖先 + 展开目标详情
|
||||
let cur = target;
|
||||
while (cur && cur.parentId) {
|
||||
S.collapsed.delete(cur.parentId);
|
||||
cur = S.tasks.find((x) => x.id === cur.parentId);
|
||||
}
|
||||
S.expanded.add(id);
|
||||
renderTree();
|
||||
const row = document.querySelector(`.task-row[data-id="${CSS.escape(id)}"]`);
|
||||
if (row) {
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
row.classList.add('flash');
|
||||
setTimeout(() => row.classList.remove('flash'), 1800);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case '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 }),
|
||||
});
|
||||
}, '已提交评审');
|
||||
case 'cplx-menu':
|
||||
S.cplxMenuFor = S.cplxMenuFor === id ? null : id;
|
||||
renderTree();
|
||||
break;
|
||||
|
||||
case 'cplx-set': {
|
||||
const c = el.dataset.cplx;
|
||||
S.cplxMenuFor = null;
|
||||
act(() => api(`/api/tasks/${id}`, {
|
||||
method: 'PATCH', body: JSON.stringify({ complexity: c }),
|
||||
}), `复杂度已改为 ${CPLX_LABEL[c]}(状态按新复杂度重置)`);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── 任务树筛选 ──
|
||||
case 'filter-cplx': {
|
||||
const c = el.dataset.cplx;
|
||||
S.filter.cplx.has(c) ? S.filter.cplx.delete(c) : S.filter.cplx.add(c);
|
||||
renderTree(); renderFilterBar();
|
||||
break;
|
||||
}
|
||||
case 'filter-status': {
|
||||
const st = el.dataset.st;
|
||||
S.filter.status.has(st) ? S.filter.status.delete(st) : S.filter.status.add(st);
|
||||
renderTree(); renderFilterBar();
|
||||
break;
|
||||
}
|
||||
case 'filter-group': {
|
||||
const g = FILTER_GROUPS[Number(el.dataset.gi)];
|
||||
if (!g) break;
|
||||
const sts = g[1];
|
||||
const all = sts.every((s) => S.filter.status.has(s));
|
||||
sts.forEach((s) => { all ? S.filter.status.delete(s) : S.filter.status.add(s); });
|
||||
renderTree(); renderFilterBar();
|
||||
break;
|
||||
}
|
||||
case 'filter-clear':
|
||||
S.filter.cplx.clear();
|
||||
S.filter.status.clear();
|
||||
S.filter.kw = '';
|
||||
$('#filterKw').value = '';
|
||||
renderTree(); renderFilterBar();
|
||||
break;
|
||||
|
||||
// ── 同步 todo ──
|
||||
case 'sync-todo': {
|
||||
if (!S.currentProjectId) break;
|
||||
el.disabled = true;
|
||||
S.syncReqAt = Date.now();
|
||||
api(`/api/projects/${S.currentProjectId}/sync`, { method: 'POST', body: JSON.stringify({}) })
|
||||
.then((r) => {
|
||||
toast(`同步完成:新建 ${r.created ?? 0} · 推进 done ${r.doneAdvanced ?? 0} · 跳过 ${r.skipped ?? 0}`, 'ok');
|
||||
(r.warnings || []).forEach((wmsg) => toast(String(wmsg), 'warn'));
|
||||
return fullRefresh();
|
||||
})
|
||||
.catch((e) => { toast(e.message); el.disabled = false; });
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Agent 配置 ──
|
||||
case 'toggle-config': {
|
||||
const panel = $('#configPanel');
|
||||
panel.hidden = !panel.hidden;
|
||||
if (!panel.hidden) {
|
||||
const p = S.projects.find((x) => x.id === S.currentProjectId);
|
||||
if (p) {
|
||||
$('#cfgConcurrency').value = p.concurrency ?? 1;
|
||||
$('#cfgAutonomy').value = AUTONOMY_LABEL[p.autonomy] ? p.autonomy : 'manual';
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'save-config': {
|
||||
if (!S.currentProjectId) break;
|
||||
const cc = Number($('#cfgConcurrency').value);
|
||||
if (!Number.isInteger(cc) || cc < 1) { toast('最大并发必须是 ≥1 的整数'); break; }
|
||||
const autonomy = $('#cfgAutonomy').value;
|
||||
act(async () => {
|
||||
await api(`/api/projects/${S.currentProjectId}`, {
|
||||
method: 'PATCH', body: JSON.stringify({ concurrency: cc, autonomy }),
|
||||
});
|
||||
await loadProjects(); // 刷新当前值显示
|
||||
}, '配置已保存');
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
@@ -551,7 +955,7 @@ $('#newTaskPanel').addEventListener('submit', (ev) => {
|
||||
const body = {
|
||||
title,
|
||||
complexity: f.complexity.value,
|
||||
priority: Number(f.priority.value || 0),
|
||||
priority: Number(f.priority.value ?? 1),
|
||||
};
|
||||
if (f.parentId.value) body.parentId = f.parentId.value;
|
||||
act(async () => {
|
||||
@@ -562,9 +966,20 @@ $('#newTaskPanel').addEventListener('submit', (ev) => {
|
||||
}, '任务已创建');
|
||||
});
|
||||
|
||||
// 关键字筛选(轻防抖)
|
||||
let kwTimer = null;
|
||||
$('#filterKw').addEventListener('input', (ev) => {
|
||||
S.filter.kw = ev.target.value;
|
||||
clearTimeout(kwTimer);
|
||||
kwTimer = setTimeout(() => { renderTree(); renderFilterBar(); }, 150);
|
||||
});
|
||||
|
||||
// Esc 关模态
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Escape') $('#modalRoot').hidden = true;
|
||||
if (ev.key === 'Escape') {
|
||||
if (S.previewId) { S.previewId = null; S.previewReject = false; renderPreview(); return; }
|
||||
$('#modalRoot').hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
// ── WebSocket 实时刷新(指数退避重连) ──
|
||||
@@ -594,6 +1009,18 @@ function connectWs() {
|
||||
ws.onmessage = (msg) => {
|
||||
let evt;
|
||||
try { evt = JSON.parse(msg.data); } catch { return; }
|
||||
if (evt.type === 'project.synced') {
|
||||
// 本端刚发起的 sync 由 POST 响应负责 toast,避免重复
|
||||
if (Date.now() - S.syncReqAt > 3000) {
|
||||
const p = evt.payload || {};
|
||||
toast(`todo 同步:新建 ${p.created ?? 0} · 推进 done ${p.doneAdvanced ?? 0} · 跳过 ${p.skipped ?? 0}`, 'ok');
|
||||
}
|
||||
fullRefresh();
|
||||
return;
|
||||
}
|
||||
if (evt.type === 'run.started' || evt.type === 'run.finished') {
|
||||
loadAgents().then(renderAgents).catch(() => {});
|
||||
}
|
||||
if (evt.projectId === S.currentProjectId) {
|
||||
scheduleRefresh();
|
||||
} else if (evt.type === 'task.created' && evt.payload && evt.payload.kind === 'project') {
|
||||
@@ -611,5 +1038,8 @@ function connectWs() {
|
||||
ws.onerror = () => { try { ws.close(); } catch { /* noop */ } };
|
||||
}
|
||||
|
||||
// 相对时间(agent 开始时间 / 上次同步)定期重绘
|
||||
setInterval(() => { renderAgents(); renderTopbar(); }, 30000);
|
||||
|
||||
// ── 启动 ──
|
||||
fullRefresh().then(connectWs);
|
||||
|
||||
Reference in New Issue
Block a user