d0aa53c79d
全局面板「用量」数据问题修复与澄清: 1. 时区分桶根因(store.ts):按天/周/月分桶与今天/本月窗口起点原全用 UTC, UTC+8 用户本地 00:00–08:00 的 run 被算进前一天、当天窗口晚 8h → 柱状图偏移 一天、当天偏小。新增 localDay/localMonth,periodStartISO/weekStartLabel/ usageDetail 改用 daemon 本地时区(SQL 比绝对时刻仍正确)。 2. UI 口径区分(design/ui_kits/console):额度条标注账号级含交互会话,AGENT 卡/ 详情弹框标注仅统计 maestro 任务执行,$ 加 API 列表价估算非实际扣费提示并以 ≈ 前缀,模型列加近似角标。新增 4 个 i18n key×5 语言。 3. 文档:新增 docs/usage-cost-explained.html(两数据源/为何对不上/cost 公式/ 单价表/本地时区分桶/已知近似),登记进 docs/index.html 知识库调研。 4. 测试:budget.test.ts 新增 UTC+8 跨日分桶用例。typecheck + 268 测试全绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
611 lines
37 KiB
React
611 lines
37 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 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>
|
||
{/* 口径说明:本卡仅统计 maestro 任务执行,与账号级额度条不同源 */}
|
||
<div style={{ fontSize: 9.5, color: 'var(--faint)', letterSpacing: '.04em', marginBottom: 8, marginTop: -4 }}>{t.apScopeMaestro}</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)', null],
|
||
['≈$' + summary.costWeek.toFixed(1), t.apCost, 'var(--green)', t.apCostEstimateHint],
|
||
[summary.runsWeek, t.apRuns, 'var(--ink)', null],
|
||
[summary.activeNow + ' / ' + global.maxAgents, t.apActive, summary.activeNow ? 'var(--cyan)' : 'var(--faint)', null],
|
||
].map(([v, k, c, hint], i) => (
|
||
<div key={i} style={{ background: 'var(--bg-deep)', padding: '7px 9px' }}>
|
||
<div title={hint || undefined} 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>
|
||
<span style={{ fontSize: 9.5, color: 'var(--faint)', letterSpacing: '.04em' }}>· {t.apScopeMaestro}</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)', t.apCostEstimateHint],
|
||
[fmtTokens(tot.tokens), t.apTokens, 'var(--cyan)', null],
|
||
[tot.runs, t.apRuns, 'var(--ink)', null],
|
||
[tot.tasks, t.apColTasks, 'var(--ink)', null],
|
||
].map(([v, k, c, hint], i) => (
|
||
<div key={i} style={{ background: 'var(--bg-deep)', padding: '8px 10px' }}>
|
||
<div title={hint || undefined} 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 title={t.apModelApprox} style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'left', cursor: 'help' }}>{t.apColModels} <span style={{ color: 'var(--faint)' }}>≈</span></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;
|