Files
maestro/design/ui_kits/console/Topbar.jsx
T
wangjia 0a1f500c12 feat(app+ds): 阶段2 接前端写操作——任务创建/配置保存/新建项目
让原型里的视觉 mock 表单变为真正可写(附加式、向后兼容原型 index.html):

design/ui_kits/console(surface 功能化,DS 组件本就支持 onChange):
- NewTaskPanel:表单受控(title/complexity/priority/parentId),onCreate 出参;
  父任务下拉扁平化含子任务
- TaskSection:暴露 onCreate prop(无则回退原 toast)
- ConfigPanel:受控(concurrency/autonomy/logo/verifyCmd/model),onSave 出参

app/:
- api.js:加 createProject
- app.jsx:createTask→POST /tasks、saveConfig→PATCH /projects、createProject→
  POST /projects;新增 NewProjectModal(design 无现成 surface,用 DS 组件就地拼装,
  zh/en 标签自含);接 Sidebar 新建项目入口

验证:dev + 生产构建双通过;Playwright 实测新建项目模态打开、受控输入捕获值
(["demo-app",".../demo-app","main"])、0 console 错误。写端点契约逐字段比对
server.ts(PATCH camelCase / POST tasks {title,complexity(auto),priority,parentId,deps})。
活写测试从略:maestro 为 auto-approved,建真实任务会触发 agent。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:44:46 +08:00

182 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 顶栏动作:默认仅图标,hover 横向展开文字(与 CountBadge 同交互)
const maestroIconActionCss = `
.m-iact{display:inline-flex;align-items:center;height:28px;padding:0 8px;cursor:pointer;font-family:var(--mono);font-size:11.5px;font-weight:600;letter-spacing:.08em;line-height:1;background:transparent;color:var(--muted);border:1px solid var(--line);border-radius:var(--radius-sm,4px);transition:color .12s,border-color .12s;white-space:nowrap}
.m-iact svg{flex:none}
.m-iact .m-iact-label{max-width:0;opacity:0;overflow:hidden;transition:max-width .28s ease,opacity .22s ease,margin-left .28s ease}
.m-iact:hover{color:var(--green);border-color:var(--green-dim)}
.m-iact:hover .m-iact-label{max-width:9em;opacity:1;margin-left:6px}
`;
function ensureIconActionCss() {
if (document.getElementById('m-iact-css')) return;
const s = document.createElement('style'); s.id = 'm-iact-css'; s.textContent = maestroIconActionCss;
document.head.appendChild(s);
}
function IconAction({ icon, label, title, onClick }) {
ensureIconActionCss();
return (
<button type="button" className="m-iact" title={title || label} onClick={onClick}>
{icon}
<span className="m-iact-label">{label}</span>
</button>
);
}
const iactSvg = { fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', width: 14, height: 14, viewBox: '0 0 24 24' };
function SyncIcon() {
return <svg {...iactSvg}><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /></svg>;
}
function ConfigIcon() {
return <svg {...iactSvg}><line x1="3" y1="6" x2="12" y2="6" /><circle cx="15" cy="6" r="2.5" /><line x1="18" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="5.5" y2="12" /><circle cx="9" cy="12" r="2.5" /><line x1="12.5" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="12.5" y2="18" /><circle cx="16" cy="18" r="2.5" /><line x1="19.5" y1="18" x2="21" y2="18" /></svg>;
}
function LangIcon() {
return <svg {...iactSvg}><circle cx="12" cy="12" r="9" /><path d="M3 12h18" /><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18" /></svg>;
}
function SunIcon() {
return <svg {...iactSvg}><circle cx="12" cy="12" r="4" /><line x1="12" y1="2" x2="12" y2="5" /><line x1="12" y1="19" x2="12" y2="22" /><line x1="2" y1="12" x2="5" y2="12" /><line x1="19" y1="12" x2="22" y2="12" /><line x1="4.9" y1="4.9" x2="7" y2="7" /><line x1="17" y1="17" x2="19.1" y2="19.1" /><line x1="4.9" y1="19.1" x2="7" y2="17" /><line x1="17" y1="7" x2="19.1" y2="4.9" /></svg>;
}
function MoonIcon() {
return <svg {...iactSvg}><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></svg>;
}
function LangMenu({ t, lang, onSelectLang }) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
if (!open) return;
const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
return (
<span ref={ref} style={{ position: 'relative', display: 'inline-flex' }}>
<span onClick={() => setOpen(!open)}>
<IconAction icon={<LangIcon />} label={t.lang} title={t.langTip} onClick={() => {}} />
</span>
{open ? (
<div style={{
position: 'absolute', top: 'calc(100% + 5px)', right: 0, zIndex: 60,
display: 'grid', minWidth: 124,
background: 'var(--bg-deep)', border: '1px solid var(--line)', borderRadius: 'var(--radius-md, 6px)',
padding: 4, boxShadow: '0 10px 30px rgba(0,0,0,.65)', animation: 'maestro-rise .12s ease both',
}}>
{window.MAESTRO_LANGS.map(([code, name]) => {
const active = code === lang;
return (
<button key={code} onClick={() => { onSelectLang(code); setOpen(false); }} style={{
fontFamily: 'var(--mono)', fontSize: 12, textAlign: 'left',
background: active ? 'var(--panel-2)' : 'transparent',
color: active ? 'var(--green)' : 'var(--ink)',
border: 'none', borderRadius: 4, padding: '7px 10px', cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 8,
}}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = 'var(--panel)'; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = 'transparent'; }}>
<span style={{ width: 12, color: 'var(--green)' }}>{active ? '▍' : ''}</span>{name}
</button>
);
})}
</div>
) : null}
</span>
);
}
// 顶栏:项目标题 + 徽章组 + 动作;agent 执行面板
function Topbar({ project, counts, onSync, onToggleConfig, t, theme, onToggleTheme, lang, onSelectLang }) {
const { Button, CountBadge } = window.MaestroDesignSystem_a6a290;
return (
<header style={{ display: 'flex', alignItems: 'flex-end', gap: 14, padding: '22px 0 14px', borderBottom: '1px solid var(--line)' }}>
<div style={{ minWidth: 0, flex: '0 1 auto' }}>
<div style={{ fontSize: 20, fontWeight: 700, letterSpacing: '.04em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{project.name}</div>
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={project.path + ' · ' + project.branch + ' · ' + project.autonomy}>{project.path} · {project.branch} · {project.autonomy}</div>
</div>
<div style={{ flex: 1 }}></div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<CountBadge kind="gate" count={counts.gate} label={t.badgeGate} title={t.badgeGateTip} />
<CountBadge kind="ready" count={counts.ready} label={t.badgeReady} title={t.badgeReadyTip} />
<CountBadge kind="run" count={counts.run} label={t.badgeRun} title={t.badgeRunTip} />
<CountBadge kind="blocked" count={counts.blocked} label={t.badgeBlocked} title={t.badgeBlockedTip} />
<CountBadge kind="total" count={counts.total} label={t.badgeTotal} title={t.badgeTotalTip} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<IconAction icon={<ConfigIcon />} label={t.config} title={t.configTip} onClick={onToggleConfig} />
<LangMenu t={t} lang={lang} onSelectLang={onSelectLang} />
<IconAction icon={theme === 'light' ? <MoonIcon /> : <SunIcon />} label={theme === 'light' ? t.themeDark : t.themeLight} title={t.themeTip} onClick={onToggleTheme} />
</div>
</header>
);
}
function ConfigPanel({ project, onSave, onClose, onSync, t }) {
const { Button, Input, Select } = window.MaestroDesignSystem_a6a290;
const [concurrency, setConcurrency] = React.useState(String(project.concurrency));
const [autonomy, setAutonomy] = React.useState(project.autonomy);
const [logo, setLogo] = React.useState(project.logo || '');
const [verifyCmd, setVerifyCmd] = React.useState(project.verifyCmd || '');
const [model, setModel] = React.useState(project.model || '');
const save = () => onSave({
concurrency: Number(concurrency), autonomy,
logo: logo || null, verifyCmd: verifyCmd || null, model: model || null,
});
return (
<section style={{ position: 'relative', zIndex: 30, background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 6, padding: '12px 14px', marginTop: 12, animation: 'maestro-rise .18s ease both' }}>
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<Input label={t.maxConcurrency} type="number" value={concurrency} onChange={setConcurrency} width={76} />
<Select label={t.workMode} value={autonomy} onChange={setAutonomy} options={Object.entries(t.autonomy).map(([value, label]) => ({ value, label }))} />
<span style={{ fontSize: 11, color: 'var(--muted)', letterSpacing: '.06em', marginLeft: 'auto', paddingBottom: 7 }}>
{t.current}{project.concurrency} · {project.autonomy}
</span>
<Button variant="solid" onClick={save}>{t.save}</Button>
<Button onClick={onClose}>{t.collapse}</Button>
</div>
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: 12 }}>
<span style={{ flex: 2, minWidth: 220, display: 'flex' }}><Input label={t.projLogo} placeholder={t.logoPh} value={logo} onChange={setLogo} style={{ width: '100%' }} /></span>
<span style={{ flex: 1, minWidth: 170, display: 'flex' }}><Input label={t.verifyCmd} placeholder={t.verifyPh} value={verifyCmd} onChange={setVerifyCmd} style={{ width: '100%' }} /></span>
<span style={{ flex: 1, minWidth: 170, display: 'flex' }}><Input label={t.model} placeholder={t.modelPh} value={model} onChange={setModel} style={{ width: '100%' }} /></span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10, paddingTop: 10, borderTop: '1px dashed var(--line-soft)' }}>
<span style={{ fontSize: 10.5, color: 'var(--faint)', letterSpacing: '.06em' }}>{t.lastSync}</span>
<Button size="xs" onClick={onSync}> {t.sync}</Button>
</div>
</section>
);
}
function AgentSection({ agents, quota, t }) {
const { SectionHead, QuotaMeter } = window.MaestroDesignSystem_a6a290;
return (
<section>
<SectionHead mark="cyan" title={<span>{t.agentSection}<span style={{ color: 'var(--cyan)', letterSpacing: '.08em', marginLeft: 8 }}>{agents.length > 0 ? agents.length : ''}</span></span>} sticky />
{quota ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 22, flexWrap: 'wrap', fontFamily: 'var(--mono)', margin: '0 0 12px' }}>
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--muted)', letterSpacing: '.08em' }}>{t.quota}</span>
<QuotaMeter label="5h" pct={quota.five.pct} detail={t.quotaReset.replace('{t}', quota.five.reset)} />
<QuotaMeter label={t.quotaWeek} pct={quota.week.pct} detail={t.quotaReset.replace('{t}', quota.week.reset)} />
</div>
) : null}
{agents.length === 0 ? (
<div style={{ padding: '12px 14px', color: 'var(--faint)', fontSize: 12, border: '1px dashed var(--line)', borderRadius: 6 }}>{t.agentEmpty}</div>
) : (
<div style={{ display: 'flex', gap: 20, alignItems: 'stretch', background: 'var(--panel)', border: '1px solid var(--cyan-dim)', borderRadius: 6, padding: '12px 16px', animation: 'maestro-rise .2s ease both' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 64, padding: '4px 8px', borderRight: '1px solid var(--line-soft)' }}>
<span style={{ fontSize: 40, fontWeight: 700, lineHeight: 1, color: 'var(--cyan)', textShadow: '0 0 18px rgba(89,200,216,.5)' }}>{agents.length}</span>
<span style={{ fontSize: 9, fontWeight: 600, letterSpacing: '.3em', color: 'var(--muted)', marginTop: 6 }}>RUNNING</span>
</div>
<div style={{ flex: 1, display: 'grid', gap: 10, minWidth: 0, alignContent: 'center' }}>
{agents.map((a) => (
<div key={a.id} style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 12, padding: '3px 0' }}>
<span style={{ flex: 'none', width: 6, height: 6, borderRadius: '50%', background: 'var(--cyan)', boxShadow: '0 0 8px var(--cyan)', animation: 'maestro-pulse .9s infinite' }}></span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.title}</span>
<span style={{ flex: 'none', fontSize: 10, letterSpacing: '.1em', color: 'var(--cyan)', border: '1px solid var(--cyan-dim)', padding: '0 6px', borderRadius: 3 }}>{a.kind}</span>
<span style={{ flex: 'none', marginLeft: 'auto', fontSize: 10.5, color: 'var(--faint)' }}>{a.meta} · {a.time} {t.since}</span>
</div>
))}
</div>
</div>
)}
</section>
);
}
Object.assign(window, { MaestroKitTopbar: Topbar, MaestroKitConfigPanel: ConfigPanel, MaestroKitAgentSection: AgentSection });