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>
203 lines
14 KiB
React
203 lines
14 KiB
React
// 顶栏动作:默认仅图标,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 });
|