feat(web): 控制台 UI 批次——用量卡片精简+详情弹框、项目列表封顶 6、评审弹框增强、live 输出流、标题栏置顶
- 全局 AGENT 卡片精简为 2×2 概览(近 7 天窗口 + 真实运行数),删按项目列表,加「详情」弹框(按天/周/月柱状图 + 分项目下钻表);- 项目列表展开态封顶 6,多余折叠;- plan/exec 评审弹框:markdown 折叠卡片 + 点文件看 diff;- live 输出流提取 tool_use/tool_result;- Agent/审批闸/任务树/已归档标题栏滚动置顶。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -231,14 +231,13 @@ function fmtTokens(n) {
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// 全局 AGENT 卡片(内联常驻;按项目默认 3 个、多余折叠、按最近活跃排序)
|
||||
function AgentCard({ global, summary, t, collapsed }) {
|
||||
const [showAll, setShowAll] = React.useState(false);
|
||||
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;
|
||||
// 「最近活跃」排序:运行中优先,其次本周 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)',
|
||||
@@ -248,8 +247,8 @@ function AgentCard({ global, summary, t, collapsed }) {
|
||||
<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 }}>
|
||||
{/* 概览 2×2 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1, background: 'var(--line-soft)', border: '1px solid var(--line-soft)', borderRadius: 4, overflow: 'hidden', marginBottom: 10 }}>
|
||||
{[
|
||||
[fmtTokens(summary.tokensWeek), t.apTokens, 'var(--cyan)'],
|
||||
['$' + summary.costWeek.toFixed(1), t.apCost, 'var(--green)'],
|
||||
@@ -262,40 +261,140 @@ function AgentCard({ global, summary, t, collapsed }) {
|
||||
</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>
|
||||
{/* 详情按钮 → 弹框 */}
|
||||
<button type="button" onClick={() => setShowDetail(true)} style={{
|
||||
width: '100%', background: 'transparent', border: '1px dashed var(--line)', color: 'var(--faint)',
|
||||
borderRadius: 4, padding: '5px 0', fontSize: 10.5, fontFamily: 'var(--mono)', cursor: 'pointer', letterSpacing: '.08em',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--cyan)'; e.currentTarget.style.borderColor = 'var(--cyan-dim)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--faint)'; e.currentTarget.style.borderColor = 'var(--line)'; }}>
|
||||
{t.apDetail} →
|
||||
</button>
|
||||
{showDetail ? <AgentDetailModal projects={projects} t={t} onClose={() => setShowDetail(false)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 用量详情弹框:按天/按月时间序列柱状图 + 分项目下钻表(token/花费/任务数/模型)。直连 /api/usage/detail。
|
||||
function AgentDetailModal({ projects, t, onClose }) {
|
||||
const [gran, setGran] = React.useState('day');
|
||||
const [data, setData] = React.useState(null);
|
||||
const [err, setErr] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
setData(null); setErr(false);
|
||||
fetch('/api/usage/detail?granularity=' + gran)
|
||||
.then((r) => r.ok ? r.json() : Promise.reject(new Error('http')))
|
||||
.then((d) => { if (alive) setData(d); })
|
||||
.catch(() => { if (alive) setErr(true); });
|
||||
return () => { alive = false; };
|
||||
}, [gran]);
|
||||
|
||||
const nameOf = new Map((projects || []).map((p) => [p.id, p]));
|
||||
const series = data?.series || [];
|
||||
const maxCost = Math.max(...series.map((s) => s.costUsd), 1e-9);
|
||||
const tot = data?.total || { costUsd: 0, tokens: 0, runs: 0, tasks: 0 };
|
||||
const emptyBox = (msg) => <div style={{ padding: '34px 0', textAlign: 'center', color: 'var(--faint)', fontSize: 12 }}>{msg}</div>;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 2000, display: 'grid', placeItems: 'center' }}>
|
||||
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(4,6,5,.86)', backdropFilter: 'blur(3px)' }}></div>
|
||||
<div style={{
|
||||
position: 'relative', width: 'min(700px, 92vw)', maxHeight: '86vh', overflowY: 'auto',
|
||||
background: 'var(--bg-deep)', border: '1px solid var(--line)', borderRadius: 'var(--radius-md,6px)',
|
||||
boxShadow: '0 18px 50px rgba(0,0,0,.7)', padding: '18px 20px', animation: 'maestro-rise .14s ease both',
|
||||
}}>
|
||||
{/* 标题 + 段切换 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||
<span style={{ color: 'var(--cyan)', display: 'inline-flex' }}><IcoAgents /></span>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, letterSpacing: '.04em', color: 'var(--ink)' }}>{t.apDetailTitle}</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 1, background: 'var(--line-soft)', border: '1px solid var(--line-soft)', borderRadius: 4, overflow: 'hidden' }}>
|
||||
{[['day', t.apByDay], ['week', t.apByWeek], ['month', t.apByMonth]].map(([g, label]) => (
|
||||
<button key={g} type="button" onClick={() => setGran(g)} style={{
|
||||
background: gran === g ? 'var(--cyan-dim)' : 'var(--bg-deep)', color: gran === g ? 'var(--cyan)' : 'var(--muted)',
|
||||
border: 'none', padding: '4px 13px', fontSize: 11, fontFamily: 'var(--mono)', cursor: 'pointer',
|
||||
}}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{err ? emptyBox(t.apEmpty) : !data ? emptyBox('…') : (
|
||||
<React.Fragment>
|
||||
{/* total 概览 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 1, background: 'var(--line-soft)', border: '1px solid var(--line-soft)', borderRadius: 4, overflow: 'hidden', marginBottom: 14 }}>
|
||||
{[
|
||||
[fmtUsd(tot.costUsd), t.apCost, 'var(--green)'],
|
||||
[fmtTokens(tot.tokens), t.apTokens, 'var(--cyan)'],
|
||||
[tot.runs, t.apRuns, 'var(--ink)'],
|
||||
[tot.tasks, t.apColTasks, 'var(--ink)'],
|
||||
].map(([v, k, c], i) => (
|
||||
<div key={i} style={{ background: 'var(--bg-deep)', padding: '8px 10px' }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: c }}>{v}</div>
|
||||
<div style={{ fontSize: 9.5, color: 'var(--muted)', letterSpacing: '.06em', marginTop: 1 }}>{k}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 时间序列柱状图(按花费成柱,hover 看 token) */}
|
||||
{series.length ? (
|
||||
<React.Fragment>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 3, height: 110, padding: '0 2px 4px', borderBottom: '1px solid var(--line-soft)' }}>
|
||||
{series.map((s) => (
|
||||
<div key={s.bucket} title={s.bucket + ' · ' + fmtUsd(s.costUsd) + ' · ' + fmtTokens(s.tokens) + ' ' + t.apTokens + ' · ' + s.runs + ' ' + t.apRuns}
|
||||
style={{ flex: 1, minWidth: 2, height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
|
||||
<div style={{ height: Math.max(2, s.costUsd / maxCost * 100) + '%', background: 'var(--cyan)', borderRadius: '2px 2px 0 0', boxShadow: '0 0 6px rgba(89,200,216,.5)' }}></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 9, color: 'var(--faint)', fontFamily: 'var(--mono)', margin: '4px 0 16px' }}>
|
||||
<span>{series[0].bucket}</span><span>{series[series.length - 1].bucket}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
) : <div style={{ marginBottom: 16 }}>{emptyBox(t.apEmpty)}</div>}
|
||||
|
||||
{/* 分项目下钻表 */}
|
||||
<div style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '.18em', color: 'var(--faint)', marginBottom: 8 }}>{t.apPerProj}</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11.5 }}>
|
||||
<thead>
|
||||
<tr style={{ color: 'var(--muted)', fontSize: 9.5, letterSpacing: '.06em' }}>
|
||||
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'left' }}>{t.apColProject}</th>
|
||||
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'right' }}>{t.apTokens}</th>
|
||||
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'right' }}>{t.apCost}</th>
|
||||
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'right' }}>{t.apColTasks}</th>
|
||||
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'left' }}>{t.apColModels}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.byProject.length ? data.byProject.map((p) => {
|
||||
const pr = nameOf.get(p.projectId);
|
||||
const nm = pr?.name || p.projectId;
|
||||
const hue = pr?.hue ?? 200;
|
||||
return (
|
||||
<tr key={p.projectId} style={{ borderTop: '1px solid var(--line-soft)' }}>
|
||||
<td style={{ padding: '6px' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
|
||||
<span style={{ flex: 'none', width: 16, height: 16, borderRadius: 4, display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, color: '#0a0d0b', background: 'hsl(' + hue + ' 45% 60%)' }}>{nm[0].toUpperCase()}</span>
|
||||
<span style={{ color: 'var(--ink)', fontWeight: 600 }}>{nm}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '6px', textAlign: 'right', color: 'var(--cyan)', fontWeight: 600 }}>{fmtTokens(p.tokens)}</td>
|
||||
<td style={{ padding: '6px', textAlign: 'right', color: 'var(--green)', fontWeight: 600 }}>{fmtUsd(p.costUsd)}</td>
|
||||
<td style={{ padding: '6px', textAlign: 'right', color: 'var(--ink)' }}>{p.tasks}</td>
|
||||
<td style={{ padding: '6px' }}>
|
||||
<span style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
|
||||
{p.models.map((m) => (
|
||||
<span key={m.model} title={shortModel(m.model) + ' · ' + fmtUsd(m.costUsd) + ' · ' + fmtTokens(m.tokens)} style={{ fontSize: 9, color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 3, padding: '0 5px', whiteSpace: 'nowrap' }}>{shortModel(m.model)}</span>
|
||||
))}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}) : <tr><td colSpan={5}>{emptyBox(t.apEmpty)}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -371,6 +470,10 @@ function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onTog
|
||||
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);
|
||||
@@ -402,7 +505,7 @@ function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onTog
|
||||
</div>
|
||||
) : <div style={{ height: 12 }}></div>}
|
||||
<ul style={{ listStyle: 'none', flex: 1, margin: 0, padding: 0 }}>
|
||||
{projects.map((p) => {
|
||||
{shownProjects.map((p) => {
|
||||
const active = p.id === currentId;
|
||||
const meta = projStateMeta(p.state, t);
|
||||
return (
|
||||
@@ -453,9 +556,20 @@ function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onTog
|
||||
);
|
||||
})}
|
||||
</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} />
|
||||
<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' }}>
|
||||
|
||||
Reference in New Issue
Block a user