Files
maestro/design/ui_kits/console/Sidebar.jsx
T
wangjia 33bef7f5fc feat(console): 侧栏项目按优先级显示三类计数徽标
左侧项目状态新增数字提示,按优先级展示:
1. 需人工处理数(紫,attention)
2. agent 运行数(青,executing)
3. 被阻塞数(琥珀,blocked)
仅非零显示,固定「人工 > 运行 > 阻塞」排列;折叠态仍单状态点,
任一计数 > 0 时放大。后端 projectSummary 已就绪,仅透出 blocked
并渲染。i18n 五语言补齐 projAgentsTip / projBlockedTip。

tsk_0VF1URfdoe3d

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:34:47 +08:00

608 lines
36 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.
// 左栏:logo + 项目列表(git 图标)+ WS 状态脚 · 可折叠(折叠后仅图标)
// 项目 logo:图片优先(原型无图),回退首字母色块 hsl(h 45% 60%)
function ProjectLogo({ project, size = 26 }) {
return (
<span style={{
flex: 'none', width: size, height: size, borderRadius: 5,
display: 'grid', placeItems: 'center',
fontSize: size * 0.5, fontWeight: 700, color: '#0a0d0b',
background: 'hsl(' + (project.hue || 140) + ' 45% 60%)',
}}>{(project.name || '?')[0].toUpperCase()}</span>
);
}
function MaestroGitIcon({ size = 16 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flex: 'none' }}>
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</svg>
);
}
// 像素章鱼 mark(折叠态 logo
function MaestroMark({ scale = 2.2 }) {
const ref = React.useRef(null);
React.useEffect(() => {
const MAP = ['.....LLLLL.....','...LLLLLLLLL...','..GGGGGGGGGGG..','..GGEEGGGEEGG..','..GGEEGGGEEGG..','..GGGGGGGGGGG..','...GGGGGGGGG...','...G..G.G..G...','...G..G.G..G...','..G...G.G...G..','..D...D.D...D..','.D...D...D...D.'];
const INK = { L: '#79ec94', G: '#5fdd7d', D: '#2e6b3d', E: '#070908' };
const ctx = ref.current.getContext('2d');
MAP.forEach((row, y) => [...row].forEach((ch, x) => {
if (INK[ch]) { ctx.fillStyle = INK[ch]; ctx.fillRect(x, y, 1, 1); }
}));
}, []);
return <canvas ref={ref} width="15" height="12"
style={{ width: 15 * scale, height: 12 * scale, imageRendering: 'pixelated', filter: 'drop-shadow(0 0 6px rgba(95,221,125,.4))' }} />;
}
// 项目状态:运行中(青脉冲) / 暂停(faint) / 阻塞(琥珀) / 空闲(muted)
function projStateMeta(state, t) {
return ({
running: { color: 'var(--cyan)', pulse: true, label: t.projRunning },
paused: { color: 'var(--faint)', pulse: false, label: t.projPaused },
blocked: { color: 'var(--amber)', pulse: true, label: t.projBlocked },
idle: { color: 'var(--muted)', pulse: false, label: t.projIdle },
})[state] || { color: 'var(--muted)', pulse: false, label: t.projIdle };
}
// 项目计数徽标:按优先级展示「需人工 / 运行中 / 被阻塞」,仅非零显示
function CountBadge({ value, color, title }) {
return (
<span title={title} style={{
fontFamily: 'var(--mono)', fontSize: 9.5, fontWeight: 700, lineHeight: '15px',
minWidth: 15, height: 15, textAlign: 'center', padding: '0 4px',
color: 'var(--bg-deep)', background: color, borderRadius: 8,
}}>{value}</span>
);
}
function ProjStatusDot({ meta, size = 7 }) {
return (
<span style={{
flex: 'none', width: size, height: size, borderRadius: '50%', background: meta.color,
boxShadow: '0 0 7px ' + meta.color,
animation: meta.pulse ? 'maestro-pulse 1.4s infinite' : 'none',
}}></span>
);
}
// 侧栏底部小行:图标 + 主文 + 副文(折叠时仅图标,hover 高亮)
function SideRow({ icon, label, detail, onClick, collapsed, title, accent }) {
const interactive = !!onClick;
return (
<div onClick={onClick} title={title || label}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: collapsed ? '9px 0' : '8px 12px', minHeight: 40,
justifyContent: collapsed ? 'center' : 'flex-start',
cursor: interactive ? 'pointer' : 'default', transition: 'background .1s',
}}
onMouseEnter={interactive ? (e) => { e.currentTarget.style.background = 'var(--panel)'; } : undefined}
onMouseLeave={interactive ? (e) => { e.currentTarget.style.background = 'transparent'; } : undefined}>
<span style={{ flex: 'none', color: accent || 'var(--muted)', display: 'inline-flex' }}>{icon}</span>
{!collapsed ? (
<span style={{ display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0, flex: 1 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ink)', whiteSpace: 'nowrap' }}>{label}</span>
{detail ? <span style={{ fontSize: 10, color: 'var(--faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{detail}</span> : null}
</span>
) : null}
{!collapsed && interactive ? <span style={{ flex: 'none', color: 'var(--faint)', fontSize: 11 }}></span> : null}
</div>
);
}
const sIco = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', style: { flex: 'none' } };
function IcoAgents() { return <svg {...sIco}><circle cx="12" cy="12" r="8" /><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none" /></svg>; }
function IcoGlobalCfg() { return <svg {...sIco}><circle cx="12" cy="12" r="9" /><line x1="3" y1="12" x2="21" y2="12" /><path d="M12 3a14 14 0 0 0 0 18a14 14 0 0 0 0-18" /></svg>; }
// 全局设置弹框:用户级默认配置(新建项目套用)。保存 / 取消,落库经 onSave。
function SettingsModal({ settings, onSave, onClose, t }) {
const { Button, Input, Select } = window.MaestroDesignSystem_a6a290;
const s = settings || {};
const [autonomy, setAutonomy] = React.useState(s.autonomy || 'manual');
const [concurrency, setConcurrency] = React.useState(String(s.concurrency ?? 1));
const [maxRetries, setMaxRetries] = React.useState(String(s.maxRetries ?? 2));
const [timeoutMin, setTimeoutMin] = React.useState(String(Math.round((s.timeoutMs ?? 1800000) / 60000)));
const [autoPlan, setAutoPlan] = React.useState(!!s.autoApprovePlan);
const [autoExec, setAutoExec] = React.useState(!!s.autoApproveExec);
const [budgetUsd, setBudgetUsd] = React.useState(s.budgetUsd != null ? String(s.budgetUsd) : '');
const [budgetPeriod, setBudgetPeriod] = React.useState(s.budgetPeriod || 'month');
const [model, setModel] = React.useState(s.model || '');
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const submit = () => {
onSave({
autonomy, concurrency: Number(concurrency) || 1, maxRetries: Number(maxRetries) || 0,
timeoutMs: Math.max(1, Number(timeoutMin) || 30) * 60000,
autoApprovePlan: autoPlan, autoApproveExec: autoExec,
budgetUsd: budgetUsd.trim() === '' ? null : Number(budgetUsd),
budgetPeriod, model: model.trim() === '' ? null : model.trim(),
});
onClose();
};
const Toggle = ({ on, set }) => (
<button type="button" onClick={() => set(!on)} style={{
alignSelf: 'flex-start', fontFamily: 'var(--mono)', fontSize: 11, fontWeight: 700, letterSpacing: '.08em',
background: on ? 'rgba(95,221,125,.1)' : 'var(--bg-deep)', color: on ? 'var(--green)' : 'var(--faint)',
border: '1px solid ' + (on ? 'var(--green-dim)' : 'var(--line)'), borderRadius: 'var(--radius-sm,4px)',
padding: '6px 14px', cursor: 'pointer', transition: 'all .1s',
}}>{on ? 'ON' : 'OFF'}</button>
);
const field = (label, node) => (
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--muted)', letterSpacing: '.05em' }}>
<span>{label}</span>{node}
</label>
);
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 2000, display: 'grid', placeItems: 'center' }}>
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(4,6,5,.86)', backdropFilter: 'blur(3px)' }}></div>
<div style={{
position: 'relative', width: 460, maxWidth: '92vw', maxHeight: '88vh', overflowY: 'auto',
background: 'var(--bg-deep)', border: '1px solid var(--line)', borderRadius: 'var(--radius-md,6px)',
boxShadow: '0 18px 50px rgba(0,0,0,.7)', padding: '18px 20px', animation: 'maestro-rise .14s ease both',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, fontWeight: 700, letterSpacing: '.16em', color: 'var(--muted)', textTransform: 'uppercase', marginBottom: 3 }}>
<span style={{ color: 'var(--green)' }}></span>{t.gGlobalSettings}
</div>
<div style={{ fontSize: 11, color: 'var(--faint)', marginBottom: 15 }}>{t.gSettingsHint}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 13 }}>
{field(t.workMode, <Select value={autonomy} onChange={setAutonomy} options={Object.entries(t.autonomy).map(([value, label]) => ({ value, label }))} />)}
{field(t.maxConcurrency, <Input type="number" value={concurrency} onChange={setConcurrency} style={{ width: '100%' }} />)}
{field(t.gMaxRetries, <Input type="number" value={maxRetries} onChange={setMaxRetries} style={{ width: '100%' }} />)}
{field(t.gTimeoutMin, <Input type="number" value={timeoutMin} onChange={setTimeoutMin} style={{ width: '100%' }} />)}
{field(t.gAutoPlan, <Toggle on={autoPlan} set={setAutoPlan} />)}
{field(t.gAutoExec, <Toggle on={autoExec} set={setAutoExec} />)}
{field(t.gBudget, <Input type="number" placeholder="∞" value={budgetUsd} onChange={setBudgetUsd} style={{ width: '100%' }} />)}
{field(t.gBudgetPeriod, <Select value={budgetPeriod} onChange={setBudgetPeriod} options={[{ value: 'day', label: t.gPeriodDay }, { value: 'month', label: t.gPeriodMonth }]} />)}
</div>
<div style={{ marginTop: 13 }}>
{field(t.model, <Input placeholder={t.modelPh} value={model} onChange={setModel} style={{ width: '100%' }} />)}
</div>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 18 }}>
<Button onClick={onClose}>{t.cancel}</Button>
<Button variant="solid" onClick={submit}>{t.save}</Button>
</div>
</div>
</div>
);
}
function UserRow({ user, t, collapsed, settings, onSaveSettings }) {
const [open, setOpen] = React.useState(false);
const [showSettings, setShowSettings] = 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]);
const avatar = (
<span style={{
flex: 'none', width: 26, height: 26, borderRadius: '50%', display: 'grid', placeItems: 'center',
fontSize: 12, fontWeight: 700, color: '#0a0d0b', background: 'hsl(' + (user.hue || 200) + ' 50% 62%)',
}}>{user.initial}</span>
);
return (
<div ref={ref} style={{ position: 'relative', borderTop: '1px solid var(--line-soft)' }}>
<div onClick={() => setOpen(!open)} title={user.name + ' · ' + user.plan}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: collapsed ? '10px 0' : '10px 12px', justifyContent: collapsed ? 'center' : 'flex-start', cursor: 'pointer', transition: 'background .1s' }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--panel)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
{avatar}
{!collapsed ? (
<React.Fragment>
<span style={{ display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0, flex: 1 }}>
<span style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--ink)', whiteSpace: 'nowrap' }}>{user.name}</span>
<span style={{ fontSize: 10, color: 'var(--green)', whiteSpace: 'nowrap' }}>{user.plan}</span>
</span>
<span style={{ flex: 'none', color: 'var(--faint)', fontSize: 11, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .12s' }}></span>
</React.Fragment>
) : null}
</div>
{open ? (
<div style={{
position: collapsed ? 'fixed' : 'absolute',
...(collapsed
? { left: 58, bottom: 14, width: 180 }
: { bottom: 'calc(100% + 4px)', left: 12, right: 12, minWidth: 160 }),
zIndex: 80,
background: 'var(--bg-deep)', border: '1px solid var(--line)', borderRadius: 'var(--radius-md,6px)',
boxShadow: '0 10px 30px rgba(0,0,0,.65)', padding: 6, animation: 'maestro-rise .12s ease both',
}}>
<div style={{ padding: '6px 10px', borderBottom: '1px solid var(--line-soft)', marginBottom: 4 }}>
<div style={{ fontSize: 12, fontWeight: 600 }}>{user.name} <span style={{ color: 'var(--faint)', fontWeight: 400 }}>{user.handle}</span></div>
<div style={{ fontSize: 10.5, color: 'var(--green)', marginTop: 2 }}>{t.gUserPlan} · {user.plan}</div>
</div>
{[
{ label: t.gGlobalSettings, onClick: () => { setShowSettings(true); setOpen(false); }, red: false },
{ label: t.gUserSettings, onClick: null, red: false },
{ label: t.gUserSignOut, onClick: null, red: true },
].map((it) => (
<div key={it.label} onClick={it.onClick || undefined} style={{ padding: '6px 10px', fontSize: 12, color: it.red ? 'var(--red)' : 'var(--ink)', cursor: 'pointer', borderRadius: 3 }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--panel-2)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>{it.label}</div>
))}
</div>
) : null}
{showSettings ? <SettingsModal settings={settings} onSave={onSaveSettings} onClose={() => setShowSettings(false)} t={t} /> : null}
</div>
);
}
function fmtTokens(n) {
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1e3) return Math.round(n / 1e3) + 'K';
return String(n);
}
function fmtUsd(n) { return '$' + (n || 0).toFixed(2); }
function shortModel(m) { return (m || 'unknown').replace(/^claude-/, ''); }
// 全局 AGENT 卡片(内联常驻;2×2 概览 = token/花费/运行/活跃,近 7 天窗口;明细走「详情」弹框)
function AgentCard({ global, summary, t, collapsed, projects }) {
const [showDetail, setShowDetail] = React.useState(false);
if (collapsed) return null;
return (
<div style={{
margin: '10px 12px 4px', border: '1px solid var(--line)', borderRadius: 'var(--radius-md,6px)',
background: 'var(--bg-deep)', padding: '11px 12px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 10.5, fontWeight: 700, letterSpacing: '.18em', color: 'var(--muted)', marginBottom: 10 }}>
<span style={{ color: 'var(--cyan)', display: 'inline-flex' }}><IcoAgents /></span>{t.gAgents.toUpperCase()}
<span style={{ marginLeft: 'auto', fontWeight: 400, letterSpacing: '.04em', color: 'var(--faint)' }}>{t.apWeek}</span>
</div>
{/* 概览 2×2 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1, background: 'var(--line-soft)', border: '1px solid var(--line-soft)', borderRadius: 4, overflow: 'hidden', marginBottom: 10 }}>
{[
[fmtTokens(summary.tokensWeek), t.apTokens, 'var(--cyan)'],
['$' + summary.costWeek.toFixed(1), t.apCost, 'var(--green)'],
[summary.runsWeek, t.apRuns, 'var(--ink)'],
[summary.activeNow + ' / ' + global.maxAgents, t.apActive, summary.activeNow ? 'var(--cyan)' : 'var(--faint)'],
].map(([v, k, c], i) => (
<div key={i} style={{ background: 'var(--bg-deep)', padding: '7px 9px' }}>
<div style={{ fontSize: 15, fontWeight: 700, color: c, letterSpacing: '.02em' }}>{v}</div>
<div style={{ fontSize: 9.5, color: 'var(--muted)', letterSpacing: '.06em', marginTop: 1 }}>{k}</div>
</div>
))}
</div>
{/* 详情按钮 → 弹框 */}
<button type="button" onClick={() => setShowDetail(true)} style={{
width: '100%', background: 'transparent', border: '1px dashed var(--line)', color: 'var(--faint)',
borderRadius: 4, padding: '5px 0', fontSize: 10.5, fontFamily: 'var(--mono)', cursor: 'pointer', letterSpacing: '.08em',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--cyan)'; e.currentTarget.style.borderColor = 'var(--cyan-dim)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--faint)'; e.currentTarget.style.borderColor = 'var(--line)'; }}>
{t.apDetail}
</button>
{showDetail ? <AgentDetailModal projects={projects} t={t} onClose={() => setShowDetail(false)} /> : null}
</div>
);
}
// 用量详情弹框:按天/按月时间序列柱状图 + 分项目下钻表(token/花费/任务数/模型)。直连 /api/usage/detail。
function AgentDetailModal({ projects, t, onClose }) {
const [gran, setGran] = React.useState('day');
const [data, setData] = React.useState(null);
const [err, setErr] = React.useState(false);
React.useEffect(() => {
let alive = true;
setData(null); setErr(false);
fetch('/api/usage/detail?granularity=' + gran)
.then((r) => r.ok ? r.json() : Promise.reject(new Error('http')))
.then((d) => { if (alive) setData(d); })
.catch(() => { if (alive) setErr(true); });
return () => { alive = false; };
}, [gran]);
const nameOf = new Map((projects || []).map((p) => [p.id, p]));
const series = data?.series || [];
const maxCost = Math.max(...series.map((s) => s.costUsd), 1e-9);
const tot = data?.total || { costUsd: 0, tokens: 0, runs: 0, tasks: 0 };
const emptyBox = (msg) => <div style={{ padding: '34px 0', textAlign: 'center', color: 'var(--faint)', fontSize: 12 }}>{msg}</div>;
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 2000, display: 'grid', placeItems: 'center' }}>
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(4,6,5,.86)', backdropFilter: 'blur(3px)' }}></div>
<div style={{
position: 'relative', width: 'min(700px, 92vw)', maxHeight: '86vh', overflowY: 'auto',
background: 'var(--bg-deep)', border: '1px solid var(--line)', borderRadius: 'var(--radius-md,6px)',
boxShadow: '0 18px 50px rgba(0,0,0,.7)', padding: '18px 20px', animation: 'maestro-rise .14s ease both',
}}>
{/* 标题 + 段切换 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<span style={{ color: 'var(--cyan)', display: 'inline-flex' }}><IcoAgents /></span>
<span style={{ fontSize: 14, fontWeight: 700, letterSpacing: '.04em', color: 'var(--ink)' }}>{t.apDetailTitle}</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 1, background: 'var(--line-soft)', border: '1px solid var(--line-soft)', borderRadius: 4, overflow: 'hidden' }}>
{[['day', t.apByDay], ['week', t.apByWeek], ['month', t.apByMonth]].map(([g, label]) => (
<button key={g} type="button" onClick={() => setGran(g)} style={{
background: gran === g ? 'var(--cyan-dim)' : 'var(--bg-deep)', color: gran === g ? 'var(--cyan)' : 'var(--muted)',
border: 'none', padding: '4px 13px', fontSize: 11, fontFamily: 'var(--mono)', cursor: 'pointer',
}}>{label}</button>
))}
</div>
</div>
{err ? emptyBox(t.apEmpty) : !data ? emptyBox('…') : (
<React.Fragment>
{/* total 概览 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 1, background: 'var(--line-soft)', border: '1px solid var(--line-soft)', borderRadius: 4, overflow: 'hidden', marginBottom: 14 }}>
{[
[fmtUsd(tot.costUsd), t.apCost, 'var(--green)'],
[fmtTokens(tot.tokens), t.apTokens, 'var(--cyan)'],
[tot.runs, t.apRuns, 'var(--ink)'],
[tot.tasks, t.apColTasks, 'var(--ink)'],
].map(([v, k, c], i) => (
<div key={i} style={{ background: 'var(--bg-deep)', padding: '8px 10px' }}>
<div style={{ fontSize: 15, fontWeight: 700, color: c }}>{v}</div>
<div style={{ fontSize: 9.5, color: 'var(--muted)', letterSpacing: '.06em', marginTop: 1 }}>{k}</div>
</div>
))}
</div>
{/* 时间序列柱状图(按花费成柱,hover 看 token */}
{series.length ? (
<React.Fragment>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 3, height: 110, padding: '0 2px 4px', borderBottom: '1px solid var(--line-soft)' }}>
{series.map((s) => (
<div key={s.bucket} title={s.bucket + ' · ' + fmtUsd(s.costUsd) + ' · ' + fmtTokens(s.tokens) + ' ' + t.apTokens + ' · ' + s.runs + ' ' + t.apRuns}
style={{ flex: 1, minWidth: 2, height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
<div style={{ height: Math.max(2, s.costUsd / maxCost * 100) + '%', background: 'var(--cyan)', borderRadius: '2px 2px 0 0', boxShadow: '0 0 6px rgba(89,200,216,.5)' }}></div>
</div>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 9, color: 'var(--faint)', fontFamily: 'var(--mono)', margin: '4px 0 16px' }}>
<span>{series[0].bucket}</span><span>{series[series.length - 1].bucket}</span>
</div>
</React.Fragment>
) : <div style={{ marginBottom: 16 }}>{emptyBox(t.apEmpty)}</div>}
{/* 分项目下钻表 */}
<div style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '.18em', color: 'var(--faint)', marginBottom: 8 }}>{t.apPerProj}</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11.5 }}>
<thead>
<tr style={{ color: 'var(--muted)', fontSize: 9.5, letterSpacing: '.06em' }}>
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'left' }}>{t.apColProject}</th>
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'right' }}>{t.apTokens}</th>
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'right' }}>{t.apCost}</th>
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'right' }}>{t.apColTasks}</th>
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'left' }}>{t.apColModels}</th>
</tr>
</thead>
<tbody>
{data.byProject.length ? data.byProject.map((p) => {
const pr = nameOf.get(p.projectId);
const nm = pr?.name || p.projectId;
const hue = pr?.hue ?? 200;
return (
<tr key={p.projectId} style={{ borderTop: '1px solid var(--line-soft)' }}>
<td style={{ padding: '6px' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
<span style={{ flex: 'none', width: 16, height: 16, borderRadius: 4, display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, color: '#0a0d0b', background: 'hsl(' + hue + ' 45% 60%)' }}>{nm[0].toUpperCase()}</span>
<span style={{ color: 'var(--ink)', fontWeight: 600 }}>{nm}</span>
</span>
</td>
<td style={{ padding: '6px', textAlign: 'right', color: 'var(--cyan)', fontWeight: 600 }}>{fmtTokens(p.tokens)}</td>
<td style={{ padding: '6px', textAlign: 'right', color: 'var(--green)', fontWeight: 600 }}>{fmtUsd(p.costUsd)}</td>
<td style={{ padding: '6px', textAlign: 'right', color: 'var(--ink)' }}>{p.tasks}</td>
<td style={{ padding: '6px' }}>
<span style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
{p.models.map((m) => (
<span key={m.model} title={shortModel(m.model) + ' · ' + fmtUsd(m.costUsd) + ' · ' + fmtTokens(m.tokens)} style={{ fontSize: 9, color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 3, padding: '0 5px', whiteSpace: 'nowrap' }}>{shortModel(m.model)}</span>
))}
</span>
</td>
</tr>
);
}) : <tr><td colSpan={5}>{emptyBox(t.apEmpty)}</td></tr>}
</tbody>
</table>
</React.Fragment>
)}
</div>
</div>
);
}
function GlobalConfigRow({ global, t, collapsed }) {
const { Select } = window.MaestroDesignSystem_a6a290;
const [open, setOpen] = React.useState(false);
const [pos, setPos] = React.useState(null);
const ref = React.useRef(null);
const rowRef = React.useRef(null);
React.useEffect(() => {
if (!open) return;
const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target) && rowRef.current && !rowRef.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
const toggle = () => {
if (!open && rowRef.current) {
const r = rowRef.current.getBoundingClientRect();
const W = 250;
// 优先在行上方对齐左缘;折叠态贴侧栏右侧
const left = collapsed ? r.right + 6 : Math.min(r.left, window.innerWidth - W - 8);
setPos({ left, bottom: window.innerHeight - r.top + 6, width: W });
}
setOpen((o) => !o);
};
const stat = (k, v, accent) => (
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, padding: '5px 0', borderBottom: '1px solid var(--line-soft)' }}>
<span style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.06em' }}>{k}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: accent || 'var(--ink)' }}>{v}</span>
</div>
);
return (
<div style={{ position: 'relative' }}>
<div ref={rowRef}>
<SideRow collapsed={collapsed} icon={<IcoGlobalCfg />} onClick={toggle}
label={t.gConfig}
detail={t.gConfigDetail.replace('{n}', global.maxAgents).replace('{v}', global.daemon)}
title={t.gConfig} />
</div>
{open && pos ? (
<div ref={ref} style={{
position: 'fixed', left: pos.left, bottom: pos.bottom, width: pos.width,
zIndex: 1000, background: 'var(--bg-deep)', border: '1px solid var(--line)', borderRadius: 'var(--radius-md,6px)',
boxShadow: '0 12px 34px rgba(0,0,0,.7)', padding: '12px 14px', animation: 'maestro-rise .12s ease both',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 10.5, fontWeight: 700, letterSpacing: '.18em', color: 'var(--muted)', marginBottom: 8 }}>
<span style={{ color: 'var(--cyan)' }}></span>{t.gConfig.toUpperCase()}
</div>
<div style={{ marginBottom: 12 }}>
{stat('daemon', global.daemon, 'var(--green)')}
{stat('port', ':' + global.port)}
{stat('uptime', global.uptime)}
{stat(t.gAgents, global.runningAgents + ' / ' + global.maxAgents, 'var(--cyan)')}
</div>
<div style={{ display: 'grid', gap: 10 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--muted)', letterSpacing: '.08em' }}>
<span>{t.maxConcurrency}</span>
<Select defaultValue={String(global.maxAgents)} style={{ width: '100%' }} options={['1', '2', '4', '6', '8'].map((n) => ({ value: n, label: n }))} />
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--muted)', letterSpacing: '.08em' }}>
<span>{t.workMode}</span>
<Select defaultValue={global.autonomy} style={{ width: '100%' }} options={Object.entries(t.autonomy).map(([value, label]) => ({ value, label }))} />
</label>
</div>
</div>
) : null}
</div>
);
}
function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onToggleCollapse, t, global, user, summary, onGlobalConfig, onReorder, settings, onSaveSettings }) {
const { Button } = window.MaestroDesignSystem_a6a290;
const [dragId, setDragId] = React.useState(null);
const [overId, setOverId] = React.useState(null);
const [showAllProjects, setShowAllProjects] = React.useState(false);
const PROJ_LIMIT = 6;
// 展开态下最多显示 6 个,多余折叠(折叠侧栏 rail 态显示全部小圆点,不截断)
const shownProjects = (collapsed || showAllProjects) ? projects : projects.slice(0, PROJ_LIMIT);
const onDrop = (targetId) => {
if (dragId && dragId !== targetId && onReorder) {
const ids = projects.map((x) => x.id);
const from = ids.indexOf(dragId);
const to = ids.indexOf(targetId);
if (from > -1 && to > -1) { ids.splice(to, 0, ids.splice(from, 1)[0]); onReorder(ids); }
}
setDragId(null); setOverId(null);
};
return (
<aside style={{ background: 'var(--bg-deep)', borderRight: '1px solid var(--line)', display: 'flex', flexDirection: 'column', overflowY: 'auto', overflowX: 'hidden' }}>
<div style={{ padding: collapsed ? '16px 0 12px' : '20px 16px 14px', borderBottom: '1px solid var(--line-soft)', display: 'flex', flexDirection: 'column', alignItems: collapsed ? 'center' : 'flex-start' }}>
{collapsed ? <MaestroMark /> : (
<React.Fragment>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<MaestroMark scale={1.8} />
<span style={{ fontSize: 19, fontWeight: 700, letterSpacing: '.28em', color: 'var(--green)', textShadow: '0 0 12px rgba(95,221,125,.45)' }}>
MAESTRO<span style={{ marginLeft: 1, animation: 'maestro-blink 1.1s steps(1) infinite' }}></span>
</span>
</div>
<div style={{ marginTop: 5, fontSize: 9, fontWeight: 600, color: 'var(--muted)', letterSpacing: '.2em', lineHeight: 1.7, textTransform: 'uppercase' }}>{t.logoSub}</div>
</React.Fragment>
)}
</div>
{!collapsed ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 600, letterSpacing: '.22em', color: 'var(--muted)', padding: '16px 16px 8px' }}>
<span style={{ color: 'var(--green)' }}></span>{t.projects}
<span style={{ marginLeft: 'auto' }}><Button variant="ghost" size="xs" onClick={onNewProject}>{t.newProject}</Button></span>
</div>
) : <div style={{ height: 12 }}></div>}
<ul style={{ listStyle: 'none', flex: 1, margin: 0, padding: 0 }}>
{shownProjects.map((p) => {
const active = p.id === currentId;
const meta = projStateMeta(p.state, t);
return (
<li key={p.id} onClick={() => onSelect(p.id)} title={p.name + ' · ' + meta.label
+ (p.pending ? ' · ' + t.projPendingTip.replace('{n}', p.pending) : '')
+ (p.agents ? ' · ' + t.projAgentsTip.replace('{n}', p.agents) : '')
+ (p.blocked ? ' · ' + t.projBlockedTip.replace('{n}', p.blocked) : '')}
draggable={!collapsed}
onDragStart={(e) => { setDragId(p.id); e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => { if (dragId) { e.preventDefault(); if (p.id !== dragId && overId !== p.id) setOverId(p.id); } }}
onDragLeave={() => setOverId((o) => (o === p.id ? null : o))}
onDrop={(e) => { e.preventDefault(); onDrop(p.id); }}
onDragEnd={() => { setDragId(null); setOverId(null); }}
style={{
position: 'relative',
padding: collapsed ? '10px 0' : '9px 14px 9px 12px', cursor: dragId ? 'grabbing' : 'pointer',
display: 'flex', alignItems: 'center', gap: 9,
justifyContent: collapsed ? 'center' : 'flex-start',
borderLeft: '2px solid ' + (active ? 'var(--green)' : 'transparent'),
background: active ? 'var(--panel-2)' : 'transparent',
color: active ? 'var(--green)' : 'var(--muted)',
opacity: dragId === p.id ? 0.4 : 1,
boxShadow: overId === p.id ? 'inset 0 2px 0 var(--green)' : 'none',
}}
onMouseEnter={(e) => { if (!active && !dragId) e.currentTarget.style.background = 'var(--panel)'; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = 'transparent'; }}>
<ProjectLogo project={p} size={collapsed ? 26 : 24} />
{!collapsed ? (
<React.Fragment>
<span style={{ display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0, flex: 1 }}>
<span style={{ fontWeight: 600, fontSize: 13, color: active ? 'var(--green)' : 'var(--ink)' }}>{p.name}</span>
<span style={{ fontSize: 10.5, color: 'var(--faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.path}</span>
</span>
<span style={{ flex: 'none', display: 'flex', alignItems: 'center', gap: 5 }}>
{p.pending > 0 ? <CountBadge value={p.pending} color="var(--violet)" title={t.projPendingTip.replace('{n}', p.pending)} /> : null}
{p.agents > 0 ? <CountBadge value={p.agents} color="var(--cyan)" title={t.projAgentsTip.replace('{n}', p.agents)} /> : null}
{p.blocked > 0 ? <CountBadge value={p.blocked} color="var(--amber)" title={t.projBlockedTip.replace('{n}', p.blocked)} /> : null}
<ProjStatusDot meta={meta} />
</span>
</React.Fragment>
) : (
<span style={{ position: 'absolute', top: 7, right: 9, display: 'flex', alignItems: 'center' }}>
<ProjStatusDot meta={meta} size={(p.pending || p.agents || p.blocked) > 0 ? 8 : 6} />
</span>
)}
</li>
);
})}
</ul>
{!collapsed && projects.length > PROJ_LIMIT ? (
<button type="button" onClick={() => setShowAllProjects((s) => !s)} style={{
margin: '2px 14px 8px', width: 'calc(100% - 28px)', background: 'transparent',
border: '1px dashed var(--line)', color: 'var(--faint)', borderRadius: 4, padding: '5px 0',
fontSize: 10.5, fontFamily: 'var(--mono)', cursor: 'pointer', letterSpacing: '.05em',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.borderColor = 'var(--muted)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--faint)'; e.currentTarget.style.borderColor = 'var(--line)'; }}>
{showAllProjects ? t.apCollapse : t.apMore.replace('{n}', projects.length - PROJ_LIMIT)}
</button>
) : null}
{/* ── 全局 AGENT 卡片(常驻)+ 用户(全局设置已并入用户菜单)── */}
<div style={{ borderTop: '1px solid var(--line-soft)' }}>
<AgentCard global={global} summary={summary} t={t} collapsed={collapsed} projects={projects} />
</div>
<UserRow user={user} t={t} collapsed={collapsed} settings={settings} onSaveSettings={onSaveSettings} />
<div style={{ borderTop: '1px solid var(--line-soft)', padding: collapsed ? '10px 0' : '10px 12px', fontSize: 11, color: 'var(--muted)', display: 'flex', alignItems: 'center', gap: 7, justifyContent: collapsed ? 'center' : 'flex-start' }}>
{!collapsed ? (
<React.Fragment>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--green)', boxShadow: '0 0 8px var(--green)', display: 'inline-block', flex: 'none' }}></span>
<span style={{ overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>{t.connected}</span>
</React.Fragment>
) : null}
<button onClick={onToggleCollapse} title={collapsed ? t.expandSide : t.collapseSide}
style={{
marginLeft: collapsed ? 0 : 'auto', flex: 'none', cursor: 'pointer',
fontFamily: 'var(--mono)', fontSize: 12, lineHeight: 1,
background: 'transparent', color: 'var(--muted)',
border: '1px solid var(--line)', borderRadius: 'var(--radius-sm, 4px)', padding: '4px 7px',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--green)'; e.currentTarget.style.borderColor = 'var(--green-dim)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.borderColor = 'var(--line)'; }}>
{collapsed ? '»' : '«'}
</button>
</div>
</aside>
);
}
window.MaestroKitSidebar = Sidebar;