Files
maestro/design/ui_kits/console/Topbar.jsx
T
wangjia 8765e61498 feat(console): 任务树筛选/折叠/统一标题 + 侧栏全局Agent卡片/拖拽排序/全局设置弹框/记忆项目 + 状态点真实化
任务树:四维多选下拉筛选(状态/复杂度/优先级/执行者)、已选置顶、新建任务按钮移右上角(0任务也能建)。
四块主区(Agent/审批闸/任务树/归档)统一标题组件(图标+名称+数量徽标)+ 折叠按钮(localStorage 记忆)。
侧栏:全局 Agent 弹层改常驻卡片(项目默认3+折叠+按活跃排序)、顶部项目拖拽排序、全局设置移入用户菜单弹框(保存/取消,落库)、记忆当前项目。
adaptProject 用后端 summary 的 running/attention 判状态点颜色(青/琥珀/灰)+ 紫色关注数徽标;agent 标题不再越界;i18n 5 语言补全。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 11:30:32 +08:00

202 lines
14 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, lang }) {
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 [budgetUsd, setBudgetUsd] = React.useState(project.budgetUsd != null ? String(project.budgetUsd) : '');
const [budgetPeriod, setBudgetPeriod] = React.useState(project.budgetPeriod || 'month');
const BL = lang === 'en'
? { budget: 'Budget $ (empty=∞)', period: 'Period', day: 'Daily', month: 'Monthly', ph: 'e.g. 20' }
: { budget: '预算 $(空=不限)', period: '周期', day: '按天', month: '按月', ph: '如 20' };
const save = () => onSave({
concurrency: Number(concurrency), autonomy,
logo: logo || null, verifyCmd: verifyCmd || null, model: model || null,
budgetUsd: budgetUsd.trim() === '' ? null : Number(budgetUsd),
budgetPeriod,
});
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', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: 12 }}>
<Input label={BL.budget} type="number" placeholder={BL.ph} value={budgetUsd} onChange={setBudgetUsd} width={150} />
<Select label={BL.period} value={budgetPeriod} onChange={setBudgetPeriod}
options={[{ value: 'day', label: BL.day }, { value: 'month', label: BL.month }]} />
</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, onOpenStream }) {
const { QuotaMeter } = window.MaestroDesignSystem_a6a290;
const [folded, setFolded] = React.useState(() => localStorage.getItem('maestro-kit-fold-agent') === '1');
const toggleFold = () => setFolded((f) => { localStorage.setItem('maestro-kit-fold-agent', f ? '0' : '1'); return !f; });
return (
<section>
<window.MaestroKitSectionHead icon="agent" title={t.agentSection} count={agents.length} accent="var(--cyan)" folded={folded} onToggleFold={toggleFold} t={t} sticky />
{!folded ? (<React.Fragment>
{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} onClick={() => onOpenStream && a.taskId && onOpenStream(a)}
title={onOpenStream ? '点击查看实时输出' : undefined}
style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 12, padding: '3px 4px', borderRadius: 4, cursor: onOpenStream && a.taskId ? 'pointer' : 'default' }}
onMouseEnter={(e) => { if (onOpenStream && a.taskId) e.currentTarget.style.background = 'var(--panel-2)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
<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={{ flex: 1, minWidth: 0, 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>
)}
</React.Fragment>) : null}
</section>
);
}
Object.assign(window, { MaestroKitTopbar: Topbar, MaestroKitConfigPanel: ConfigPanel, MaestroKitAgentSection: AgentSection });