Files
maestro/design/ui_kits/console/Topbar.jsx
T
wangjia d0aa53c79d fix(usage): 修按天分桶时区错位 + 区分账号额度/maestro用量口径 + 成本说明文档 (tsk_JKAzHuXvSw7h)
全局面板「用量」数据问题修复与澄清:

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>
2026-06-30 09:18:36 +08:00

203 lines
14 KiB
React
Raw Blame History

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