feat(app): React 18 + Vite 前端,像素级复刻 Maestro 设计系统 console
复用 design/ 设计系统作单一真相源(token + _ds_bundle.js + 6 个 surface 源文件不改),只移植 App 入口。整屏 console 与原型截图 diff 0.023%(≤0.5% 阈)。 - vite.config.js:经典 JSX + 注入 React,alias @ds → ../design - src/globals.js:先注入 window.React,供预编译 bundle 使用 - src/main.jsx:按序加载 tokens → bundle → mock/i18n → 6 surface → App - src/app.jsx:移植自原型 App(),三栏可拖拽 grid - src/ambience.css:扫描线 + 暗角 phosphor 氛围层 下一步:mock data.js/i18n.js 换成真实 REST + WebSocket(按 ⑧ 前端 API 契约)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MAESTRO · 任务调度台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1043
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "maestro-console",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/* 全屏外壳基样 + phosphor 氛围层(扫描线 + 暗角),照搬 ui_kits/console/index.html 内联样式 */
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
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;
|
||||
}
|
||||
[data-theme="light"] body::before { opacity: .12; }
|
||||
@keyframes maestro-locate {
|
||||
0%, 35% { background: var(--amber-dim); box-shadow: inset 2px 0 0 var(--amber); }
|
||||
100% { background: transparent; box-shadow: none; }
|
||||
}
|
||||
::selection { background: var(--green-dim); color: #fff; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--line); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--green-dim); }
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
// 从 design/ui_kits/console/index.html 的 App() 移植。surface 组件来自全局
|
||||
// window.MaestroKit*(由各 .jsx 注册);mock 数据/文案来自 window.MAESTRO_MOCK/I18N。
|
||||
// 下一步:把 M(mock)替换成真实 REST + WS 数据源(按 ⑧ 前端 API 契约)。
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
const { Toast } = window.MaestroDesignSystem_a6a290;
|
||||
|
||||
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];
|
||||
React.useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
localStorage.setItem('maestro-kit-theme', theme);
|
||||
}, [theme]);
|
||||
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 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);
|
||||
const evMax = () => Math.min(EV_MAX, window.innerWidth - (collapsed ? 52 : sideW) - CENTER_MIN);
|
||||
const [sideW, setSideW] = React.useState(() => clamp(Number(localStorage.getItem('maestro-kit-sidew')) || 232, SIDE_MIN, SIDE_MAX));
|
||||
const [evW, setEvW] = React.useState(() => clamp(Number(localStorage.getItem('maestro-kit-evw')) || 320, EV_MIN, EV_MAX));
|
||||
const [dragging, setDragging] = React.useState(null);
|
||||
React.useEffect(() => { localStorage.setItem('maestro-kit-sidew', sideW); }, [sideW]);
|
||||
React.useEffect(() => { localStorage.setItem('maestro-kit-evw', evW); }, [evW]);
|
||||
const startDrag = (which) => (e) => {
|
||||
e.preventDefault();
|
||||
setDragging(which);
|
||||
const startX = e.clientX;
|
||||
const startW = which === 'side' ? sideW : evW;
|
||||
const onMove = (ev) => {
|
||||
if (which === 'side') setSideW(clamp(startW + (ev.clientX - startX), SIDE_MIN, Math.max(SIDE_MIN, sideMax())));
|
||||
else setEvW(clamp(startW - (ev.clientX - startX), EV_MIN, Math.max(EV_MIN, evMax())));
|
||||
};
|
||||
const onUp = () => {
|
||||
setDragging(null);
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
document.body.style.userSelect = 'none';
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
};
|
||||
const Resizer = ({ which }) => (
|
||||
<div onPointerDown={startDrag(which)} title="拖拽调整宽度"
|
||||
style={{
|
||||
position: 'absolute', top: 0, bottom: 0, width: 9, cursor: 'col-resize', zIndex: 40,
|
||||
...(which === 'side' ? { left: sideW - 4 } : { right: evW - 4 }),
|
||||
display: 'flex', justifyContent: 'center',
|
||||
}}>
|
||||
<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();
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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)}
|
||||
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}
|
||||
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}
|
||||
onSave={() => { setShowConfig(false); toast('ok', t.toastSaved); }}
|
||||
onSync={() => toast('ok', t.toastSynced)}
|
||||
onClose={() => setShowConfig(false)} /> : null}
|
||||
<window.MaestroKitAgentSection agents={agents} quota={isMain ? M.quota : null} t={t} />
|
||||
<window.MaestroKitGateSection approvals={gates} onDecide={decide} t={t} />
|
||||
{tasks.length ? (
|
||||
<window.MaestroKitTaskSection tasks={tasks} onToast={toast} t={t} />
|
||||
) : (
|
||||
<div style={{ padding: '46px 0', textAlign: 'center', color: 'var(--faint)', border: '1px dashed var(--line)', borderRadius: 6, marginTop: 18 }}>
|
||||
<b style={{ display: 'block', fontSize: 15, color: 'var(--muted)', marginBottom: 6, letterSpacing: '.2em' }}>{t.empty}</b>
|
||||
{t.emptyTasks}
|
||||
</div>
|
||||
)}
|
||||
{isMain ? <window.MaestroKitArchiveSection items={M.archived} t={t} onOpen={setArchiveItem} /> : null}
|
||||
</main>
|
||||
<window.MaestroKitEventPanel events={events} collapsed={evCollapsed} onToggleCollapse={toggleEvents} t={t} />
|
||||
{archiveItem ? <window.MaestroKitArchiveModal item={archiveItem} t={t} onClose={() => setArchiveItem(null)} /> : null}
|
||||
<div style={{ position: 'fixed', bottom: 18, left: '50%', transform: 'translateX(-50%)', zIndex: 1200, display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
|
||||
{toasts.map((x) => <Toast key={x.id} kind={x.kind}>{x.text}</Toast>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function mount() {
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// 预编译的 _ds_bundle.js 与 ui_kit surface 源文件均以「全局 React」运行。
|
||||
// 本模块必须最先被 import,确保在加载 bundle 前 window.React 已就位。
|
||||
import React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
|
||||
globalThis.React = React;
|
||||
globalThis.ReactDOM = ReactDOM;
|
||||
@@ -0,0 +1,21 @@
|
||||
// 加载顺序很重要:globals 先注入 window.React → bundle → mock/i18n → 各 surface → App。
|
||||
import './globals.js';
|
||||
import '@ds/styles.css';
|
||||
import './ambience.css';
|
||||
import '@ds/_ds_bundle.js';
|
||||
|
||||
// ui_kit 数据与文案(暂用设计系统自带 mock;下一步换成真实 REST+WS)
|
||||
import '@ds/ui_kits/console/data.js';
|
||||
import '@ds/ui_kits/console/i18n.js';
|
||||
|
||||
// 六个整屏 surface(保持 design/ 源文件不动,作为单一真相源)
|
||||
import '@ds/ui_kits/console/Sidebar.jsx';
|
||||
import '@ds/ui_kits/console/EventPanel.jsx';
|
||||
import '@ds/ui_kits/console/Topbar.jsx';
|
||||
import '@ds/ui_kits/console/GateSection.jsx';
|
||||
import '@ds/ui_kits/console/TaskTree.jsx';
|
||||
import '@ds/ui_kits/console/ArchiveSection.jsx';
|
||||
|
||||
import { mount } from './app.jsx';
|
||||
|
||||
mount();
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// 复刻策略:设计系统(design/)为单源。surface/.jsx 源文件不改,用经典 JSX 转换 +
|
||||
// 注入 React 直接加载;编译好的 _ds_bundle.js 走全局 window.React(见 src/globals.js)。
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: { '@ds': path.resolve(__dirname, '../design') },
|
||||
},
|
||||
esbuild: {
|
||||
jsx: 'transform',
|
||||
jsxFactory: 'React.createElement',
|
||||
jsxFragment: 'React.Fragment',
|
||||
jsxInject: `import React from 'react'`,
|
||||
},
|
||||
server: {
|
||||
port: 4519,
|
||||
fs: { allow: [path.resolve(__dirname, '..')] }, // 允许访问仓库内的 design/
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user