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:
+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}
|
||||
|
||||
Reference in New Issue
Block a user