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:
@@ -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 run(AgentSection 用)
|
||||
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)} 天前`;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 真实数据层:直连本地 maestro daemon 的 REST + WS(按 ⑧ 前端 API 契约)。
|
||||
// 路径用同源相对地址,dev 下经 vite.config.js 的 proxy 反代到 :4517。
|
||||
|
||||
async function http(method, path, body) {
|
||||
const opt = { method, headers: {} };
|
||||
if (body !== undefined) {
|
||||
opt.headers['Content-Type'] = 'application/json';
|
||||
opt.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(path, opt);
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText;
|
||||
try { msg = (await res.json()).error || msg; } catch { /* 非 JSON 错误体 */ }
|
||||
throw new Error(`${method} ${path} → ${res.status} ${msg}`);
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
return ct.includes('application/json') ? res.json() : res.text();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listProjects: () => http('GET', '/api/projects'),
|
||||
projectTasks: (pid) => http('GET', `/api/projects/${pid}/tasks`),
|
||||
projectEvents: (pid) => http('GET', `/api/projects/${pid}/events`),
|
||||
agents: () => http('GET', '/api/agents'),
|
||||
approvals: () => http('GET', '/api/approvals'),
|
||||
usage: () => http('GET', '/api/usage'),
|
||||
syncProject: (pid) => http('POST', `/api/projects/${pid}/sync`),
|
||||
decide: (taskId, action, reason, merge) =>
|
||||
http('POST', `/api/tasks/${taskId}/decide`, { action, reason, merge }),
|
||||
createTask: (pid, body) => http('POST', `/api/projects/${pid}/tasks`, body),
|
||||
patchProject: (pid, body) => http('PATCH', `/api/projects/${pid}`, body),
|
||||
};
|
||||
|
||||
// WS 单向订阅:连上后服务端推送 events 表记录。断线自动重连(指数退避,封顶 10s)。
|
||||
export function subscribeEvents(onEvent, onStatus) {
|
||||
let ws = null;
|
||||
let backoff = 500;
|
||||
let closedByUser = false;
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
ws.onopen = () => { backoff = 500; onStatus?.('open'); };
|
||||
ws.onmessage = (msg) => {
|
||||
try { onEvent(JSON.parse(msg.data)); } catch { /* 忽略非 JSON 帧 */ }
|
||||
};
|
||||
ws.onclose = () => {
|
||||
onStatus?.('closed');
|
||||
if (closedByUser) return;
|
||||
setTimeout(connect, backoff);
|
||||
backoff = Math.min(backoff * 2, 10000);
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
connect();
|
||||
|
||||
return () => { closedByUser = true; ws?.close(); };
|
||||
}
|
||||
+115
-44
@@ -1,25 +1,39 @@
|
||||
// 从 design/ui_kits/console/index.html 的 App() 移植。surface 组件来自全局
|
||||
// window.MaestroKit*(由各 .jsx 注册);mock 数据/文案来自 window.MAESTRO_MOCK/I18N。
|
||||
// 下一步:把 M(mock)替换成真实 REST + WS 数据源(按 ⑧ 前端 API 契约)。
|
||||
// 真实数据版 App:从本地 daemon 的 REST 拉取、订阅 WS 增量刷新,
|
||||
// 通过 src/adapt.js 映射成各 surface 期望的形状(替代 window.MAESTRO_MOCK)。
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { api, subscribeEvents } from './api.js';
|
||||
import {
|
||||
adaptProject, buildTaskTree, adaptApproval, adaptEvent, adaptQuota,
|
||||
deriveGlobal, deriveAgentSummary, adaptActiveAgents,
|
||||
} from './adapt.js';
|
||||
|
||||
const { Toast } = window.MaestroDesignSystem_a6a290;
|
||||
|
||||
const USER = { name: 'local', handle: '@local', plan: 'Claude Code', initial: 'L', hue: 200 };
|
||||
|
||||
export function App() {
|
||||
const M = window.MAESTRO_MOCK;
|
||||
const I18N = window.MAESTRO_I18N;
|
||||
const [currentId, setCurrentId] = React.useState('p1');
|
||||
const [approvals, setApprovals] = React.useState(M.approvals);
|
||||
const [events, setEvents] = React.useState(M.events);
|
||||
const [showConfig, setShowConfig] = React.useState(false);
|
||||
const [archiveItem, setArchiveItem] = React.useState(null);
|
||||
const [toasts, setToasts] = React.useState([]);
|
||||
const [lang, setLang] = React.useState(() => {
|
||||
const saved = localStorage.getItem('maestro-kit-lang');
|
||||
return I18N[saved] ? saved : 'zh';
|
||||
});
|
||||
const [theme, setTheme] = React.useState(() => localStorage.getItem('maestro-kit-theme') || 'dark');
|
||||
const t = I18N[lang];
|
||||
|
||||
// ── 真实数据 state ──────────────────────────────────
|
||||
const [projectsRaw, setProjectsRaw] = React.useState([]);
|
||||
const [tasksRaw, setTasksRaw] = React.useState([]);
|
||||
const [approvalsRaw, setApprovalsRaw] = React.useState([]);
|
||||
const [eventsRaw, setEventsRaw] = React.useState([]);
|
||||
const [agentsResp, setAgentsResp] = React.useState({ totalActive: 0, agents: [] });
|
||||
const [usage, setUsage] = React.useState(null);
|
||||
const [currentId, setCurrentId] = React.useState(null);
|
||||
const [wsState, setWsState] = React.useState('closed');
|
||||
|
||||
// ── UI state(沿用原型)─────────────────────────────
|
||||
const [showConfig, setShowConfig] = React.useState(false);
|
||||
const [archiveItem, setArchiveItem] = React.useState(null);
|
||||
const [toasts, setToasts] = React.useState([]);
|
||||
const [theme, setTheme] = React.useState(() => localStorage.getItem('maestro-kit-theme') || 'dark');
|
||||
React.useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
localStorage.setItem('maestro-kit-theme', theme);
|
||||
@@ -27,14 +41,8 @@ export function App() {
|
||||
React.useEffect(() => { localStorage.setItem('maestro-kit-lang', lang); }, [lang]);
|
||||
const [collapsed, setCollapsed] = React.useState(() => localStorage.getItem('maestro-kit-sidebar') === 'collapsed');
|
||||
const [evCollapsed, setEvCollapsed] = React.useState(() => localStorage.getItem('maestro-kit-events') === 'collapsed');
|
||||
const toggleEvents = () => setEvCollapsed((c) => {
|
||||
localStorage.setItem('maestro-kit-events', c ? 'open' : 'collapsed');
|
||||
return !c;
|
||||
});
|
||||
const toggleCollapse = () => setCollapsed((c) => {
|
||||
localStorage.setItem('maestro-kit-sidebar', c ? 'open' : 'collapsed');
|
||||
return !c;
|
||||
});
|
||||
const toggleEvents = () => setEvCollapsed((c) => { localStorage.setItem('maestro-kit-events', c ? 'open' : 'collapsed'); return !c; });
|
||||
const toggleCollapse = () => setCollapsed((c) => { localStorage.setItem('maestro-kit-sidebar', c ? 'open' : 'collapsed'); return !c; });
|
||||
const SIDE_MIN = 200, SIDE_MAX = 420, EV_MIN = 240, EV_MAX = 520, CENTER_MIN = 480;
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const sideMax = () => Math.min(SIDE_MAX, window.innerWidth - (evCollapsed ? 52 : evW) - CENTER_MIN);
|
||||
@@ -73,49 +81,112 @@ export function App() {
|
||||
<span style={{ width: 1, background: dragging === which ? 'var(--green)' : 'var(--line-soft)', boxShadow: dragging === which ? '0 0 8px var(--green)' : 'none', transition: 'background .12s' }}></span>
|
||||
</div>
|
||||
);
|
||||
const project = M.projects.find((p) => p.id === currentId);
|
||||
const isMain = currentId === 'p1';
|
||||
const tasks = isMain ? M.tasks : [];
|
||||
const agents = isMain ? M.agents : [];
|
||||
const gates = isMain ? approvals : [];
|
||||
|
||||
// ── 数据加载 ────────────────────────────────────────
|
||||
const toast = (kind, text) => {
|
||||
const id = Date.now();
|
||||
const id = Date.now() + Math.random();
|
||||
setToasts((list) => [...list, { id, kind, text }]);
|
||||
setTimeout(() => setToasts((list) => list.filter((x) => x.id !== id)), 2600);
|
||||
};
|
||||
const now = () => new Date().toTimeString().slice(0, 8);
|
||||
const decide = (a, action, reason) => {
|
||||
setApprovals((list) => list.filter((x) => x.id !== a.id));
|
||||
if (action === 'accept') {
|
||||
setEvents((ev) => [{ type: 'approval.granted', time: now(), detail: a.title + ' · ' + a.gate + '_review' + t.gatePassed }, ...ev]);
|
||||
toast('ok', t.toastAccepted + a.title);
|
||||
} else {
|
||||
setEvents((ev) => [{ type: 'approval.rejected', time: now(), detail: a.title + ' ↳ ' + reason }, ...ev]);
|
||||
toast('warn', t.toastRejected);
|
||||
|
||||
const loadGlobal = React.useCallback(async () => {
|
||||
try {
|
||||
const [pj, ag, us, ap] = await Promise.all([api.listProjects(), api.agents(), api.usage(), api.approvals()]);
|
||||
setProjectsRaw(pj); setAgentsResp(ag); setUsage(us); setApprovalsRaw(ap);
|
||||
setCurrentId((cur) => cur || (pj[0] && pj[0].id) || null);
|
||||
} catch (e) { toast('warn', '加载失败:' + e.message); }
|
||||
}, []);
|
||||
|
||||
const loadProject = React.useCallback(async (pid) => {
|
||||
if (!pid) return;
|
||||
try {
|
||||
const [tasks, events] = await Promise.all([api.projectTasks(pid), api.projectEvents(pid)]);
|
||||
setTasksRaw(tasks);
|
||||
setEventsRaw(events.slice(0, 60));
|
||||
} catch (e) { toast('warn', '加载任务失败:' + e.message); }
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => { loadGlobal(); }, [loadGlobal]);
|
||||
React.useEffect(() => { loadProject(currentId); }, [currentId, loadProject]);
|
||||
|
||||
// WS:增量刷新。事件即时入流;任务/审批/agent 做轻量去抖刷新。
|
||||
const refreshTimer = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
const unsub = subscribeEvents((evt) => {
|
||||
if (!evt || !evt.type) return;
|
||||
if (!evt.projectId || evt.projectId === currentId) {
|
||||
setEventsRaw((list) => [evt, ...list].slice(0, 60));
|
||||
}
|
||||
clearTimeout(refreshTimer.current);
|
||||
refreshTimer.current = setTimeout(() => {
|
||||
loadProject(currentId);
|
||||
api.approvals().then(setApprovalsRaw).catch(() => {});
|
||||
api.agents().then(setAgentsResp).catch(() => {});
|
||||
api.listProjects().then(setProjectsRaw).catch(() => {});
|
||||
}, 350);
|
||||
}, setWsState);
|
||||
return () => { unsub(); clearTimeout(refreshTimer.current); };
|
||||
}, [currentId, loadProject]);
|
||||
|
||||
// ── 派生:映射成 surface 形状 ───────────────────────
|
||||
const projects = projectsRaw.map(adaptProject);
|
||||
const project = projects.find((p) => p.id === currentId) || projects[0];
|
||||
const projNameOf = (pid) => (projectsRaw.find((p) => p.id === pid) || {}).name;
|
||||
const { active: tasks, archived } = buildTaskTree(tasksRaw);
|
||||
const REVIEW = new Set(['plan_review', 'spec_review', 'exec_review']);
|
||||
const gates = approvalsRaw
|
||||
.filter((a) => a.projectId === currentId && REVIEW.has(a.status))
|
||||
.map((a) => adaptApproval(a, projNameOf(a.projectId)));
|
||||
const agents = adaptActiveAgents(agentsResp, currentId);
|
||||
const events = eventsRaw.map(adaptEvent);
|
||||
const quota = adaptQuota(usage);
|
||||
const global = deriveGlobal(projects, agentsResp);
|
||||
const agentSummary = deriveAgentSummary(projects, agentsResp);
|
||||
const NONTERMINAL = (s) => !['done', 'cancelled', 'decomposed'].includes(s);
|
||||
const counts = {
|
||||
gate: gates.length,
|
||||
ready: tasksRaw.filter((x) => x.projectId === currentId && (x.status === 'ready' || x.status === 'queued')).length,
|
||||
run: tasksRaw.filter((x) => x.projectId === currentId && x.status === 'executing').length,
|
||||
blocked: tasksRaw.filter((x) => x.projectId === currentId && x.status === 'blocked').length,
|
||||
total: tasksRaw.filter((x) => x.projectId === currentId && NONTERMINAL(x.status)).length,
|
||||
};
|
||||
|
||||
// ── 动作(真实写入)────────────────────────────────
|
||||
const decide = async (a, action, reason) => {
|
||||
setApprovalsRaw((list) => list.filter((x) => x.id !== a.id)); // 乐观移除
|
||||
try {
|
||||
await api.decide(a.id, action, reason);
|
||||
toast(action === 'accept' ? 'ok' : 'warn', (action === 'accept' ? t.toastAccepted : t.toastRejected) + (action === 'accept' ? a.title : ''));
|
||||
} catch (e) {
|
||||
toast('warn', '裁决失败:' + e.message);
|
||||
api.approvals().then(setApprovalsRaw).catch(() => {}); // 回滚
|
||||
}
|
||||
};
|
||||
const onSync = async () => {
|
||||
try { await api.syncProject(currentId); toast('ok', t.toastSynced); loadProject(currentId); loadGlobal(); }
|
||||
catch (e) { toast('warn', '同步失败:' + e.message); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', display: 'grid', gridTemplateColumns: (collapsed ? '52px' : sideW + 'px') + ' minmax(0,1fr) ' + (evCollapsed ? '52px' : evW + 'px'), height: '100vh' }}>
|
||||
{!collapsed ? <Resizer which="side" /> : null}
|
||||
{!evCollapsed ? <Resizer which="ev" /> : null}
|
||||
<window.MaestroKitSidebar projects={M.projects} currentId={currentId} t={t}
|
||||
global={M.global} user={M.user} summary={M.agentSummary} onGlobalConfig={() => toast('warn', t.toastModal)}
|
||||
<window.MaestroKitSidebar projects={projects} currentId={currentId} t={t}
|
||||
global={global} user={USER} summary={agentSummary} onGlobalConfig={() => toast('warn', t.toastModal)}
|
||||
collapsed={collapsed} onToggleCollapse={toggleCollapse}
|
||||
onSelect={setCurrentId} onNewProject={() => toast('warn', t.toastModal)} />
|
||||
<main style={{ overflowY: 'auto', padding: '0 22px 60px' }}>
|
||||
<window.MaestroKitTopbar project={project} t={t} theme={theme}
|
||||
{project ? <window.MaestroKitTopbar project={project} t={t} theme={theme}
|
||||
onToggleTheme={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
||||
lang={lang} onSelectLang={setLang}
|
||||
counts={{ gate: gates.length, ready: isMain ? 1 : 0, run: agents.length, blocked: isMain ? 1 : 0, total: isMain ? 5 : 0 }}
|
||||
onSync={() => toast('ok', t.toastSynced)}
|
||||
onToggleConfig={() => setShowConfig(!showConfig)} />
|
||||
{showConfig ? <window.MaestroKitConfigPanel project={project} t={t}
|
||||
counts={counts}
|
||||
onSync={onSync}
|
||||
onToggleConfig={() => setShowConfig(!showConfig)} /> : null}
|
||||
{showConfig && project ? <window.MaestroKitConfigPanel project={project} t={t}
|
||||
onSave={() => { setShowConfig(false); toast('ok', t.toastSaved); }}
|
||||
onSync={() => toast('ok', t.toastSynced)}
|
||||
onSync={onSync}
|
||||
onClose={() => setShowConfig(false)} /> : null}
|
||||
<window.MaestroKitAgentSection agents={agents} quota={isMain ? M.quota : null} t={t} />
|
||||
<window.MaestroKitAgentSection agents={agents} quota={quota} t={t} />
|
||||
<window.MaestroKitGateSection approvals={gates} onDecide={decide} t={t} />
|
||||
{tasks.length ? (
|
||||
<window.MaestroKitTaskSection tasks={tasks} onToast={toast} t={t} />
|
||||
@@ -125,7 +196,7 @@ export function App() {
|
||||
{t.emptyTasks}
|
||||
</div>
|
||||
)}
|
||||
{isMain ? <window.MaestroKitArchiveSection items={M.archived} t={t} onOpen={setArchiveItem} /> : null}
|
||||
<window.MaestroKitArchiveSection items={archived} t={t} onOpen={setArchiveItem} />
|
||||
</main>
|
||||
<window.MaestroKitEventPanel events={events} collapsed={evCollapsed} onToggleCollapse={toggleEvents} t={t} />
|
||||
{archiveItem ? <window.MaestroKitArchiveModal item={archiveItem} t={t} onClose={() => setArchiveItem(null)} /> : null}
|
||||
|
||||
+1
-2
@@ -4,8 +4,7 @@ import '@ds/styles.css';
|
||||
import './ambience.css';
|
||||
import '@ds/_ds_bundle.js';
|
||||
|
||||
// ui_kit 数据与文案(暂用设计系统自带 mock;下一步换成真实 REST+WS)
|
||||
import '@ds/ui_kits/console/data.js';
|
||||
// 文案 i18n(真实数据由 src/api.js + src/adapt.js 提供,不再用 data.js mock)
|
||||
import '@ds/ui_kits/console/i18n.js';
|
||||
|
||||
// 六个整屏 surface(保持 design/ 源文件不动,作为单一真相源)
|
||||
|
||||
+7
-1
@@ -10,14 +10,20 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: { '@ds': path.resolve(__dirname, '../design') },
|
||||
},
|
||||
// surface/.jsx 与 app.jsx 统一走全局 React(globals.js 注入 window.React),
|
||||
// 不向各模块注入 import——否则生产构建时 rollup 会试图从 design/ 解析 react 而失败。
|
||||
esbuild: {
|
||||
jsx: 'transform',
|
||||
jsxFactory: 'React.createElement',
|
||||
jsxFragment: 'React.Fragment',
|
||||
jsxInject: `import React from 'react'`,
|
||||
},
|
||||
server: {
|
||||
port: 4519,
|
||||
fs: { allow: [path.resolve(__dirname, '..')] }, // 允许访问仓库内的 design/
|
||||
// 把 REST + WS 反代到本地 daemon(:4517),客户端用同源相对路径
|
||||
proxy: {
|
||||
'/api': { target: 'http://127.0.0.1:4517', changeOrigin: true },
|
||||
'/ws': { target: 'http://127.0.0.1:4517', ws: true, changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user