8765e61498
任务树:四维多选下拉筛选(状态/复杂度/优先级/执行者)、已选置顶、新建任务按钮移右上角(0任务也能建)。 四块主区(Agent/审批闸/任务树/归档)统一标题组件(图标+名称+数量徽标)+ 折叠按钮(localStorage 记忆)。 侧栏:全局 Agent 弹层改常驻卡片(项目默认3+折叠+按活跃排序)、顶部项目拖拽排序、全局设置移入用户菜单弹框(保存/取消,落库)、记忆当前项目。 adaptProject 用后端 summary 的 running/attention 判状态点颜色(青/琥珀/灰)+ 紫色关注数徽标;agent 标题不再越界;i18n 5 语言补全。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
484 lines
29 KiB
React
484 lines
29 KiB
React
// 左栏: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 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);
|
||
}
|
||
|
||
// 全局 AGENT 卡片(内联常驻;按项目默认 3 个、多余折叠、按最近活跃排序)
|
||
function AgentCard({ global, summary, t, collapsed }) {
|
||
const [showAll, setShowAll] = React.useState(false);
|
||
if (collapsed) return null;
|
||
// 「最近活跃」排序:运行中优先,其次本周 token 用量(无活跃时间戳时的代理)
|
||
const projs = [...summary.byProject].sort((a, b) => (b.active - a.active) || (b.tokens - a.tokens));
|
||
const maxTok = Math.max(...projs.map((p) => p.tokens), 1);
|
||
const shown = showAll ? projs : projs.slice(0, 3);
|
||
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>
|
||
{/* 总结 */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1, background: 'var(--line-soft)', border: '1px solid var(--line-soft)', borderRadius: 4, overflow: 'hidden', marginBottom: 11 }}>
|
||
{[
|
||
[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>
|
||
{/* 按项目(默认 3,多余折叠,按活跃排序)*/}
|
||
<div style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '.18em', color: 'var(--faint)', marginBottom: 7 }}>{t.apPerProj}</div>
|
||
<div style={{ display: 'grid', gap: 8 }}>
|
||
{shown.map((p) => (
|
||
<div key={p.id}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 3 }}>
|
||
<span style={{ flex: 'none', width: 16, height: 16, borderRadius: 4, display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, color: '#0a0d0b', background: 'hsl(' + p.hue + ' 45% 60%)' }}>{p.name[0].toUpperCase()}</span>
|
||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||
{p.active > 0 ? (
|
||
<span style={{ flex: 'none', display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 9.5, color: 'var(--cyan)', border: '1px solid var(--cyan-dim)', borderRadius: 3, padding: '0 5px' }}>
|
||
<span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--cyan)', boxShadow: '0 0 6px var(--cyan)', animation: 'maestro-pulse .9s infinite' }}></span>{p.active}
|
||
</span>
|
||
) : <span style={{ flex: 'none', fontSize: 9.5, color: 'var(--faint)' }}>{t.apIdle}</span>}
|
||
<span style={{ marginLeft: 'auto', flex: 'none', fontSize: 11, fontWeight: 600, color: 'var(--cyan)' }}>{fmtTokens(p.tokens)}</span>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingLeft: 24 }}>
|
||
<span style={{ flex: 1, height: 4, background: 'var(--panel-2)', borderRadius: 2, overflow: 'hidden' }}>
|
||
<span style={{ display: 'block', height: '100%', width: (p.tokens / maxTok * 100) + '%', background: 'var(--cyan)', boxShadow: '0 0 6px rgba(89,200,216,.5)' }}></span>
|
||
</span>
|
||
<span style={{ flex: 'none', fontSize: 10, color: 'var(--faint)' }}>{p.runs} {t.apRuns}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{projs.length > 3 ? (
|
||
<button type="button" onClick={() => setShowAll((s) => !s)} style={{
|
||
marginTop: 9, width: '100%', background: 'transparent', border: '1px dashed var(--line)', color: 'var(--faint)',
|
||
borderRadius: 4, padding: '4px 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)'; }}>
|
||
{showAll ? t.apCollapse : t.apMore.replace('{n}', projs.length - 3)}
|
||
</button>
|
||
) : null}
|
||
</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 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 }}>
|
||
{projects.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) : '')}
|
||
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: 6 }}>
|
||
{p.pending > 0 ? (
|
||
<span title={t.projPendingTip.replace('{n}', p.pending)} 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: 'var(--violet)', borderRadius: 8,
|
||
}}>{p.pending}</span>
|
||
) : 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 > 0 ? 8 : 6} />
|
||
</span>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
{/* ── 全局 AGENT 卡片(常驻)+ 用户(全局设置已并入用户菜单)── */}
|
||
<div style={{ borderTop: '1px solid var(--line-soft)' }}>
|
||
<AgentCard global={global} summary={summary} t={t} collapsed={collapsed} />
|
||
</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;
|