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:
@@ -1,6 +1,108 @@
|
||||
// ── 报告渲染:把 markdown(## 章节 + - 列表 + **粗体** + `代码`)渲染成可折叠卡片 ──
|
||||
function mdInline(text) {
|
||||
const nodes = []; const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
|
||||
let last = 0; let m; let key = 0;
|
||||
while ((m = re.exec(text))) {
|
||||
if (m.index > last) nodes.push(text.slice(last, m.index));
|
||||
const tok = m[0];
|
||||
if (tok.startsWith('**')) nodes.push(<b key={key++} style={{ color: 'var(--ink)' }}>{tok.slice(2, -2)}</b>);
|
||||
else nodes.push(<code key={key++} style={{ fontFamily: 'var(--mono)', fontSize: '.92em', color: 'var(--cyan)', background: 'var(--panel-2)', borderRadius: 3, padding: '1px 5px' }}>{tok.slice(1, -1)}</code>);
|
||||
last = m.index + tok.length;
|
||||
}
|
||||
if (last < text.length) nodes.push(text.slice(last));
|
||||
return nodes;
|
||||
}
|
||||
function mdBody(body) {
|
||||
const out = []; let key = 0;
|
||||
for (const raw of body.split('\n')) {
|
||||
const t = raw.trim();
|
||||
if (!t) { out.push(<div key={key++} style={{ height: 6 }} />); continue; }
|
||||
if (/^[-*]\s/.test(t)) {
|
||||
out.push(<div key={key++} style={{ display: 'flex', gap: 8, padding: '2px 0 2px 2px' }}><span style={{ flex: 'none', color: 'var(--green)' }}>·</span><span>{mdInline(t.replace(/^[-*]\s/, ''))}</span></div>);
|
||||
} else if (/^#{1,4}\s/.test(t)) {
|
||||
out.push(<div key={key++} style={{ fontWeight: 700, color: 'var(--muted)', margin: '6px 0 2px' }}>{mdInline(t.replace(/^#{1,4}\s/, ''))}</div>);
|
||||
} else {
|
||||
out.push(<div key={key++} style={{ padding: '1px 0' }}>{mdInline(t)}</div>);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function mdSections(md) {
|
||||
if (!md) return [];
|
||||
const out = []; const lines = md.split('\n');
|
||||
let cur = null;
|
||||
for (const line of lines) {
|
||||
const h = line.match(/^##\s+(.+)$/);
|
||||
if (h) { if (cur) out.push(cur); cur = { title: h[1].trim(), body: '' }; }
|
||||
else if (cur) { cur.body += (cur.body ? '\n' : '') + line; }
|
||||
else { if (line.trim()) { cur = { title: '', body: line }; } } // 首章节前的前言
|
||||
}
|
||||
if (cur) out.push(cur);
|
||||
return out;
|
||||
}
|
||||
function ReportSectionCard({ title, body, t, defaultOpen }) {
|
||||
const [open, setOpen] = React.useState(defaultOpen);
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div onClick={() => setOpen((o) => !o)} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '9px 12px', cursor: 'pointer', background: 'var(--panel-2)', userSelect: 'none' }}>
|
||||
<span style={{ flex: 'none', color: 'var(--green)', fontSize: 9, transition: 'transform .12s', transform: open ? 'none' : 'rotate(-90deg)' }}>▼</span>
|
||||
<span style={{ flex: 1, fontSize: 12.5, fontWeight: 700, color: 'var(--ink)', letterSpacing: '.02em' }}>{title || '说明'}</span>
|
||||
</div>
|
||||
{open ? <div style={{ padding: '11px 14px', fontSize: 12.5, lineHeight: 1.7, color: 'var(--ink)', wordBreak: 'break-word' }}>{mdBody(body)}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function ReportCards({ md, t }) {
|
||||
const secs = mdSections(md);
|
||||
if (!secs.length) return null;
|
||||
return <div style={{ display: 'grid', gap: 8 }}>{secs.map((s, i) => <ReportSectionCard key={i} title={s.title} body={s.body} t={t} defaultOpen />)}</div>;
|
||||
}
|
||||
|
||||
// 单文件 diff 渲染(+/- 行着色)
|
||||
function DiffBody({ diff }) {
|
||||
return (
|
||||
<pre style={{ margin: 0, padding: '10px 12px', fontSize: 11.5, lineHeight: 1.5, overflowX: 'auto', background: 'var(--bg-deep)', fontFamily: 'var(--mono)' }}>
|
||||
{diff.split('\n').map((ln, i) => {
|
||||
const c = (ln.startsWith('+') && !ln.startsWith('+++')) ? 'var(--green)'
|
||||
: (ln.startsWith('-') && !ln.startsWith('---')) ? 'var(--red)'
|
||||
: ln.startsWith('@@') ? 'var(--cyan)'
|
||||
: (ln.startsWith('diff --git') || ln.startsWith('index ') || ln.startsWith('+++') || ln.startsWith('---')) ? 'var(--faint)'
|
||||
: 'var(--muted)';
|
||||
return <div key={i} style={{ color: c, whiteSpace: 'pre' }}>{ln || ' '}</div>;
|
||||
})}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
// 改动文件列表:点击文件展开其 diff(按需向 daemon 取)
|
||||
function FileDiffs({ taskId, t }) {
|
||||
const [files, setFiles] = React.useState(null);
|
||||
const [openPath, setOpenPath] = React.useState(null);
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
fetch('/api/tasks/' + taskId + '/diff').then((r) => r.json()).then((d) => { if (alive) setFiles(d.files || []); }).catch(() => { if (alive) setFiles([]); });
|
||||
return () => { alive = false; };
|
||||
}, [taskId]);
|
||||
if (!files) return <div style={{ fontSize: 11, color: 'var(--faint)', padding: '4px 2px' }}>… diff</div>;
|
||||
if (!files.length) return null;
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 6, marginTop: 9 }}>
|
||||
{files.map((f) => (
|
||||
<div key={f.path} style={{ border: '1px solid var(--line-soft)', borderRadius: 5, overflow: 'hidden' }}>
|
||||
<div onClick={() => setOpenPath((p) => (p === f.path ? null : f.path))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 11px', cursor: 'pointer', background: 'var(--panel-2)' }}>
|
||||
<span style={{ flex: 'none', color: 'var(--green)', fontSize: 9, transform: openPath === f.path ? 'none' : 'rotate(-90deg)' }}>▼</span>
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--cyan)', wordBreak: 'break-all' }}>{f.path}</span>
|
||||
<span style={{ flex: 'none', fontSize: 10, color: 'var(--faint)' }}>{t.clickToView || ''}</span>
|
||||
</div>
|
||||
{openPath === f.path ? <DiffBody diff={f.diff} /> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 全屏预览:读完整方案 + 就地裁决
|
||||
function GatePreview({ item, t, onDecide, onClose }) {
|
||||
const { ComplexityBadge, Button, Textarea } = window.MaestroDesignSystem_a6a290;
|
||||
const { ComplexityBadge, StatusChip, Button, Textarea } = window.MaestroDesignSystem_a6a290;
|
||||
const [rejecting, setRejecting] = React.useState(false);
|
||||
const [reason, setReason] = React.useState('');
|
||||
React.useEffect(() => {
|
||||
@@ -9,6 +111,26 @@ function GatePreview({ item, t, onDecide, onClose }) {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
if (!item) return null;
|
||||
// 拆解子任务序号映射(依赖用 #序号 引用)
|
||||
const subIdx = {};
|
||||
(item.subtasks || []).forEach((s, i) => { subIdx[s.id] = i + 1; });
|
||||
const rv = item.review;
|
||||
const vBadge = (label, v) => {
|
||||
const ok = v === 'approve', bad = v === 'reject';
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11.5, fontWeight: 700, padding: '4px 11px', borderRadius: 5,
|
||||
color: ok ? 'var(--green)' : bad ? 'var(--red)' : 'var(--faint)',
|
||||
border: '1px solid ' + (ok ? 'var(--green-dim)' : bad ? 'var(--red-dim)' : 'var(--line)'),
|
||||
background: ok ? 'rgba(95,221,125,.08)' : bad ? 'rgba(255,93,93,.08)' : 'transparent',
|
||||
}}>
|
||||
<span style={{ opacity: .75, fontWeight: 600 }}>{label}</span>
|
||||
<span>{ok ? '✓ ' + t.vApprove : bad ? '✗ ' + t.vReject : t.vNone}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
const secLabel = (txt) => <div style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--muted)', letterSpacing: '.18em', margin: '18px 0 8px' }}>{txt}</div>;
|
||||
const reportBox = (txt) => <div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 4, padding: '12px 16px', fontSize: 12.5, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--ink)' }}>{txt}</div>;
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 1100, display: 'grid', placeItems: 'center' }}>
|
||||
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(4,6,5,.86)', backdropFilter: 'blur(3px)' }}></div>
|
||||
@@ -21,8 +143,53 @@ function GatePreview({ item, t, onDecide, onClose }) {
|
||||
<button onClick={onClose} title={t.closeTip} style={{ fontFamily: 'var(--mono)', fontSize: 13, background: 'transparent', color: 'var(--muted)', border: 'none', cursor: 'pointer', padding: '4px 8px' }}>✕</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px 28px', fontFamily: 'var(--mono)' }}>
|
||||
{item.gate === 'exec' && rv ? (
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 14, flexWrap: 'wrap' }}>
|
||||
{vBadge(t.lblCodeReview, rv.verdict)}
|
||||
{vBadge(t.lblSecurity, rv.securityVerdict)}
|
||||
</div>
|
||||
) : null}
|
||||
{item.docLabel ? <div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 6 }}>{item.docLabel}</div> : null}
|
||||
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', borderRadius: 4, padding: '12px 16px', fontSize: 13.5, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--ink)' }}>{item.doc}</div>
|
||||
{/^##\s/m.test(item.doc || '')
|
||||
? <ReportCards md={item.doc} t={t} />
|
||||
: <div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', borderRadius: 4, padding: '12px 16px', fontSize: 13.5, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--ink)' }}>{item.doc}</div>}
|
||||
{item.gate === 'exec' && rv && rv.securitySummary ? (
|
||||
<React.Fragment>{secLabel(t.lblSecurity)}<ReportCards md={rv.securitySummary} t={t} /></React.Fragment>
|
||||
) : null}
|
||||
{item.gate === 'exec' && rv && (rv.commits.length || rv.diffSummary) ? (
|
||||
<React.Fragment>
|
||||
{secLabel(t.lblChanges + (rv.branch ? ' · ' + rv.branch : ''))}
|
||||
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 4, padding: '12px 16px', fontSize: 12.5, lineHeight: 1.7 }}>
|
||||
{rv.commits.map((c) => <div key={c} style={{ color: 'var(--green)' }}>· {c}</div>)}
|
||||
{rv.diffSummary ? <div style={{ marginTop: rv.commits.length ? 9 : 0, color: 'var(--faint)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{rv.diffSummary}</div> : null}
|
||||
</div>
|
||||
<FileDiffs taskId={item.taskId} t={t} />
|
||||
</React.Fragment>
|
||||
) : null}
|
||||
{item.gate === 'plan' && item.subtasks && item.subtasks.length ? (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 11 }}>{t.gateSubtasks} · {item.subtasks.length}</div>
|
||||
<div style={{ display: 'grid', gap: 9 }}>
|
||||
{item.subtasks.map((s, i) => {
|
||||
const deps = (s.deps || []).map((d) => (subIdx[d] ? '#' + subIdx[d] : null)).filter(Boolean);
|
||||
return (
|
||||
<div key={s.id} style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 6, padding: '11px 13px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
|
||||
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: '50%', background: 'var(--panel-2)', color: 'var(--muted)', fontSize: 11, fontWeight: 700, display: 'grid', placeItems: 'center' }}>{i + 1}</span>
|
||||
<ComplexityBadge complexity={s.complexity} />
|
||||
<span style={{ flex: 'none', fontSize: 10, fontWeight: 700, color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 3, padding: '1px 6px' }}>P{s.priority}</span>
|
||||
<span style={{ flex: 1, fontSize: 13.5, fontWeight: 600, color: 'var(--ink)', minWidth: 120 }}>{s.title}</span>
|
||||
{StatusChip ? <StatusChip status={s.status} label={t.status[s.status]} /> : null}
|
||||
</div>
|
||||
<div style={{ marginTop: 7, paddingLeft: 31, fontSize: 11.5, color: 'var(--faint)' }}>
|
||||
{t.dependsOn}: {deps.length ? <span style={{ color: 'var(--amber)', fontWeight: 600 }}>{deps.join('、')}</span> : t.noDeps}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', padding: '12px 16px', borderTop: '1px solid var(--line)', background: 'var(--panel-2)' }}>
|
||||
{rejecting ? (
|
||||
@@ -60,7 +227,7 @@ function GateSection({ approvals, onDecide, t }) {
|
||||
if (approvals.length === 0) return null;
|
||||
return (
|
||||
<section>
|
||||
<window.MaestroKitSectionHead icon="gate" title={t.gateSection} count={approvals.length} accent="var(--violet)" folded={sectionFolded} onToggleFold={toggleSectionFold} t={t} />
|
||||
<window.MaestroKitSectionHead icon="gate" title={t.gateSection} count={approvals.length} accent="var(--violet)" folded={sectionFolded} onToggleFold={toggleSectionFold} t={t} sticky />
|
||||
{!sectionFolded ? <GateStripe /> : null}
|
||||
{!sectionFolded ? approvals.map((a) => {
|
||||
const isFolded = folded.has(a.id);
|
||||
|
||||
Reference in New Issue
Block a user