feat(app): 阶段2 接真实数据——REST + WS 直连本地 daemon

前端从 mock 切换到真实数据源(按 ⑧ 前端 API 契约,复用现有 :4517 daemon API):

- src/api.js:REST 封装 + WS 单向订阅(断线指数退避重连)
- src/adapt.js:纯映射层,把 API 的 camelCase 行映射成各 surface 期望形状
  (扁平任务→children 树、priority 整数→P0/1/2、complexity→cplx、终态→归档卡)
- src/app.jsx:改为 useEffect 拉取 + WS 增量刷新;decide/sync 走真实 POST;
  审批闸收紧到 plan/spec/exec_review 三态(needs_attention 仍在任务树可见)
- vite.config.js:加 /api + /ws 反代到 :4517;去掉 jsxInject(surface 走全局
  React,避免生产构建 rollup 从 design/ 解析 react 失败)
- main.jsx:移除 data.js mock 导入

验证:dev + 生产构建双通过;真实数据渲染(4 项目/审批闸带 diff/真实配额),
WS 已连接,0 console 错误。token/成本类(agentSummary)属 ⑧ ★ 新 /api/usage
(预算特性),暂留空待补。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-24 14:29:19 +08:00
parent 1fcd811c12
commit d6ae4dbe13
5 changed files with 387 additions and 47 deletions
+205
View File
@@ -0,0 +1,205 @@
// 适配层:把真实 API 响应(src/model 的 camelCase 行)映射成各 surface 期望的形状
// (即 design/ui_kits/console/data.js 的 mock 字段名)。纯函数,便于测试与替换。
const ARCHIVE_STATUSES = new Set(['done', 'cancelled']);
const GATE_STATUSES = new Set(['plan_review', 'spec_review', 'exec_review']);
// 项目名 → 稳定色相(0..360),与 mock 的 hue 字段对齐
export function hueFor(name) {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
return h;
}
const prio = (n) => 'P' + (n ?? 2);
const shortPath = (p) => (p || '').replace(/^\/Users\/[^/]+/, '~');
// ── 项目 ───────────────────────────────────────────────
export function adaptProject(p) {
const s = p.summary || {};
let state = 'idle';
if (p.status === 'paused') state = 'paused';
else if (s.executing > 0) state = 'running';
else if (s.blocked > 0) state = 'blocked';
else if (s.alive > 0) state = 'running';
return {
id: p.id, name: p.name, path: shortPath(p.repoPath), branch: p.defaultBranch,
autonomy: p.autonomy, concurrency: p.concurrency, hue: hueFor(p.name),
state, pending: s.pending || 0, agents: s.executing || 0,
// 透传给 ConfigPanel 用的原始字段
model: p.model, verifyCmd: p.verifyCmd, maxRetries: p.maxRetries, repoPath: p.repoPath,
};
}
// ── 任务树(扁平 → 嵌套 children)──────────────────────
function mapTask(tk) {
return {
id: tk.id, title: tk.title, cplx: tk.complexity, status: tk.status, prio: prio(tk.priority),
deps: tk.deps || [],
doc: tk.spec || tk.plan || undefined,
ops: tk.operations || undefined,
children: [],
_raw: tk,
};
}
// 返回 { active: 顶层活跃任务树, archived: 终态任务卡片[] }
export function buildTaskTree(flat) {
const byId = new Map();
flat.forEach((tk) => byId.set(tk.id, mapTask(tk)));
const roots = [];
byId.forEach((node) => {
const pid = node._raw.parentId;
if (pid && byId.has(pid)) byId.get(pid).children.push(node);
else roots.push(node);
});
// 子节点按优先级(P0 在前)再按创建时间排序
const sortKids = (n) => {
n.children.sort((a, b) => a.prio.localeCompare(b.prio) || a._raw.createdAt.localeCompare(b._raw.createdAt));
n.children.forEach(sortKids);
};
roots.forEach(sortKids);
const active = [];
const archived = [];
for (const r of roots) {
const hasLiveDescendant = (n) => !ARCHIVE_STATUSES.has(n.status) || n.children.some(hasLiveDescendant);
if (ARCHIVE_STATUSES.has(r.status) && !r.children.some(hasLiveDescendant)) {
archived.push(adaptArchive(r));
} else {
active.push(r);
}
}
active.sort((a, b) => a.prio.localeCompare(b.prio) || a._raw.createdAt.localeCompare(b._raw.createdAt));
archived.sort((a, b) => b.timeFull.localeCompare(a.timeFull));
return { active, archived };
}
// ── 审批闸卡片 ─────────────────────────────────────────
export function adaptApproval(tk, projName) {
const gate = tk.status.replace('_review', ''); // plan | spec | exec
let doc;
if (gate === 'plan') doc = tk.plan;
else if (gate === 'spec') doc = tk.spec;
else doc = (tk.result && (tk.result.diffSummary || JSON.stringify(tk.result, null, 2))) || tk.operations;
return {
id: tk.id, gate, taskId: tk.id, title: tk.title,
meta: `${projName || ''} · ${(tk.complexity || '').toUpperCase()}`,
doc: doc || '(无可预览内容)',
};
}
// ── 事件流 ─────────────────────────────────────────────
const TIME = (iso) => { try { return new Date(iso).toTimeString().slice(0, 8); } catch { return ''; } };
export function adaptEvent(e) {
const p = e.payload || {};
let detail = '';
switch (e.type) {
case 'status.changed': detail = `${p.from}${p.to}` + (p.by ? ` · ${p.by}` : ''); break;
case 'run.started': detail = `${p.kind || 'run'} · ${p.runId || ''}`; break;
case 'run.finished': detail = `${p.runId || ''} · ${p.status || ''}`; break;
case 'approval.granted': case 'approval.rejected':
detail = `${p.gate || ''}` + (p.reason ? `${p.reason}` : ''); break;
case 'task.created': detail = p.title || e.taskId || ''; break;
case 'project.synced': detail = `导入 ${p.created ?? 0} · 完成 ${p.done ?? 0}`; break;
default: detail = JSON.stringify(p).slice(0, 80);
}
return { type: e.type, time: TIME(e.at), detail };
}
// ── 用量 / 配额 ────────────────────────────────────────
const resetIn = (iso) => {
if (!iso) return '—';
const ms = new Date(iso).getTime() - Date.now();
if (isNaN(ms) || ms <= 0) return '0m';
const h = Math.floor(ms / 3.6e6), m = Math.floor((ms % 3.6e6) / 6e4);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
};
export function adaptQuota(u) {
return {
five: { pct: u?.session?.percent ?? 0, reset: resetIn(u?.session?.resetsAt) },
week: { pct: u?.weekly?.percent ?? 0, reset: resetIn(u?.weekly?.resetsAt) },
};
}
// ── 全局概览(从 projects + agents 派生)───────────────
export function deriveGlobal(projects, agentsResp) {
const running = projects.filter((p) => p.state === 'running').length;
const maxAgents = projects.reduce((s, p) => s + (p.concurrency || 0), 0);
return {
projectCount: projects.length, runningProjects: running,
runningAgents: agentsResp?.totalActive ?? 0, maxAgents,
autonomy: projects[0]?.autonomy || 'manual', daemon: 'live', port: 4517, uptime: '—',
};
}
// 跨项目 agent 概览。token/成本属 ⑧ 标 ★ 的新 /api/usage(预算特性),暂留 0。
export function deriveAgentSummary(projects, agentsResp) {
return {
tokensWeek: 0, runsWeek: 0, costWeek: 0, activeNow: agentsResp?.totalActive ?? 0,
byProject: projects.map((p) => ({
id: p.id, name: p.name, hue: p.hue,
active: p.agents || 0, runs: 0, tokens: 0,
})),
};
}
// 当前项目正在跑的 agent runAgentSection 用)
export function adaptActiveAgents(agentsResp, projectId) {
const out = [];
for (const a of agentsResp?.agents || []) {
if (projectId && a.projectId !== projectId) continue;
for (const r of a.active || []) {
out.push({
id: r.runId || r.id, project: a.projectName,
title: r.title || r.taskTitle || r.taskId || '(运行中)',
kind: (r.kind || 'run').toUpperCase(),
time: TIME(r.startedAt || r.at), meta: `${r.worktree || ''} · ${r.model || ''}`,
});
}
}
return out;
}
// ── 归档任务详情 ───────────────────────────────────────
function adaptArchive(node) {
const tk = node._raw;
const cplxU = (tk.complexity || '').toUpperCase();
const statusZh = tk.status === 'done' ? '完成' : '取消';
const attrs = [
['id', tk.id], ['复杂度', cplxU], ['优先级', node.prio], ['状态', statusZh],
['深度', String(tk.depth ?? 1)], ['创建时间', (tk.createdAt || '').replace('T', ' ').slice(0, 19)],
['最后更新', (tk.updatedAt || '').replace('T', ' ').slice(0, 19)],
];
const detail = { attrs };
if (tk.spec) detail.spec = tk.spec;
if (tk.operations) detail.ops = tk.operations;
if (tk.result) {
detail.result = {
branch: tk.result.branch,
commits: tk.result.commits || [],
diff: tk.result.diffSummary ? tk.result.diffSummary.split('\n').slice(-1)[0] : undefined,
};
}
detail.approvals = (tk.approvals || []).map((a) => ({
gate: a.gate, action: a.action, actor: a.actor, at: (a.at || '').replace('T', ' ').slice(0, 19), reason: a.reason,
}));
detail.runs = [];
detail.timeline = [{ time: (tk.createdAt || '').slice(5, 16).replace('T', ' '), text: '任务创建', who: 'you' }];
return {
id: tk.id, title: tk.title, cplx: tk.complexity, status: tk.status,
subs: node.children.length, time: relTime(tk.updatedAt), timeFull: (tk.updatedAt || '').replace('T', ' ').slice(0, 19),
detail,
};
}
function relTime(iso) {
if (!iso) return '';
const ms = Date.now() - new Date(iso).getTime();
const h = Math.floor(ms / 3.6e6);
if (h < 1) return '刚刚';
if (h < 24) return `${h} 小时前`;
return `${Math.floor(h / 24)} 天前`;
}