Files
maestro/design/ui_kits/console/ArchiveSection.jsx
T
wangjia 5443ffa21c fix(console): 评审/归档卡片的 markdown 渲染成 HTML
内联评审闸卡片与归档详情此前把 agent 产出的 markdown(plan/spec/
operations)直接塞进 <pre> 当纯文本,满屏裸 ## / - / 反引号未渲染。
改为在 ui_kit 层用 window.MaestroRichDoc(marked + DOMPurify 净化)
渲染,与 TaskTree 既有写法一致;保留 280px 限高滚动与容器样式。

- GateSection.jsx: GateCard doc 改为 undefined,children 内对非
  attention 闸用 MaestroRichDoc 渲染 a.doc;删除未引用的 reportBox 死代码
- ArchiveSection.jsx: doc() 助手由 <pre> 改为 MaestroRichDoc 容器,
  SPEC/OPERATIONS 两块随之正确渲染
- 安全:两处均走 RichDoc 的 DOMPurify 净化,挡住 same-origin 存储型 XSS;
  不绕过 sanitize 直接注入 HTML。CDN 不可用时回退纯文本不崩
- 不改 GateCard.jsx(避免底层组件反向依赖 ui_kit 全局 + 触发 bundle 重编)
- DiffBody 的 <pre> 渲染 git diff(非 markdown)保持不动

类似但未改:console_mobile/MobileViews.jsx 为独立非在线 demo,其
index.html 未加载 RichDoc/marked/DOMPurify,修复需整套引入,超出本次范围。

tsk_sp0DwZ2Kc319

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 08:30:15 +08:00

187 lines
11 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.
// 已归档区(done / 取消,时间倒序分页)+ 归档详情模态
function ArchiveRow({ item, t, onOpen }) {
const { ComplexityBadge, StatusChip } = window.MaestroDesignSystem_a6a290;
const [hover, setHover] = React.useState(false);
return (
<div onClick={() => onOpen(item)} title={t.archiveTip}
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer',
padding: '7px 12px', fontSize: 12.5,
borderBottom: '1px dashed var(--line-soft)',
background: hover ? 'var(--panel)' : 'transparent',
}}>
<span style={{ color: 'var(--muted)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.title}<span style={{ color: 'var(--faint)', fontSize: 10.5, marginLeft: 6 }}>{item.id}</span>
</span>
{item.subs ? <span style={{ flex: 'none', fontSize: 10.5, color: 'var(--faint)' }}>{t.archiveSubs.replace('{n}', item.subs)}</span> : null}
<span style={{ flex: 1, minWidth: 8 }}></span>
<ComplexityBadge complexity={item.cplx} />
<StatusChip status={item.status} label={t.status[item.status]} />
<span style={{ flex: 'none', fontSize: 11, color: 'var(--faint)' }} title={item.timeFull}>{item.time}</span>
</div>
);
}
function SizeSelect({ size, setSize, t }) {
const [open, setOpen] = React.useState(false);
const [hover, setHover] = 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' }}>
<button onClick={() => setOpen(!open)}
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
style={{
fontFamily: 'var(--mono)', fontSize: 11, fontWeight: 600, letterSpacing: '.04em',
background: 'transparent', color: hover || open ? 'var(--green)' : 'var(--muted)',
border: '1px solid ' + (hover || open ? 'var(--green-dim)' : 'var(--line)'),
borderRadius: 'var(--radius-sm, 4px)', padding: '3px 9px', cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', gap: 7, transition: 'color .12s, border-color .12s',
}}>
{t.perPage} <b style={{ color: 'var(--green)', fontSize: 11.5 }}>{size}</b>
<span style={{ fontSize: 8, color: 'var(--faint)', transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .12s' }}></span>
</button>
{open ? (
<div style={{
position: 'absolute', bottom: 'calc(100% + 5px)', right: 0, zIndex: 60,
display: 'grid', minWidth: '100%',
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',
}}>
{[10, 20, 50, 100].map((n) => {
const active = n === size;
return (
<button key={n} onClick={() => { setSize(n); setOpen(false); }} style={{
fontFamily: 'var(--mono)', fontSize: 11.5, fontWeight: active ? 700 : 400, textAlign: 'left',
background: active ? 'var(--panel-2)' : 'transparent',
color: active ? 'var(--green)' : 'var(--ink)',
border: 'none', borderRadius: 4, padding: '6px 12px 6px 8px', cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 7,
}}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = 'var(--panel)'; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = 'transparent'; }}>
<span style={{ width: 8, color: 'var(--green)', fontSize: 10 }}>{active ? '▍' : ''}</span>{n}
</button>
);
})}
</div>
) : null}
</span>
);
}
function ArchivePager({ page, pages, size, setPage, setSize, t }) {
const { Button } = window.MaestroDesignSystem_a6a290;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', justifyContent: 'center', fontFamily: 'var(--mono)' }}>
{pages > 1 ? (
<React.Fragment>
<Button size="xs" disabled={page <= 1} onClick={() => setPage(page - 1)}>{t.pagePrev}</Button>
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{t.pageOf.replace('{a}', page).replace('{b}', pages)}</span>
<Button size="xs" disabled={page >= pages} onClick={() => setPage(page + 1)}>{t.pageNext}</Button>
</React.Fragment>
) : null}
<SizeSelect size={size} setSize={setSize} t={t} />
</div>
);
}
function ArchiveSection({ items, t, onOpen }) {
const [page, setPage] = React.useState(1);
const [size, setSize] = React.useState(20);
const [folded, setFolded] = React.useState(() => localStorage.getItem('maestro-kit-fold-archive') === '1');
const toggleFold = () => setFolded((f) => { localStorage.setItem('maestro-kit-fold-archive', f ? '0' : '1'); return !f; });
if (!items.length) return null;
const pages = Math.max(1, Math.ceil(items.length / size));
const cur = Math.min(page, pages);
const slice = items.slice((cur - 1) * size, cur * size);
return (
<section style={{ opacity: .82 }}>
<window.MaestroKitSectionHead icon="archive" title={t.archive} count={items.length} accent="var(--faint)" folded={folded} onToggleFold={toggleFold} t={t} sticky />
{!folded ? (
<React.Fragment>
<div>{slice.map((it) => <ArchiveRow key={it.id} item={it} t={t} onOpen={onOpen} />)}</div>
<ArchivePager page={cur} pages={pages} size={size} setPage={setPage} setSize={(n) => { setSize(n); setPage(1); }} t={t} />
</React.Fragment>
) : null}
</section>
);
}
// 归档详情模态(只读:属性 / 产出 / 执行历史 / 结果 / 审批 / 时间线)
function ArchiveModal({ item, t, onClose }) {
const { ComplexityBadge, StatusChip, Timeline } = window.MaestroDesignSystem_a6a290;
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
if (!item) return null;
const d = item.detail || {};
const label = (text) => <div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', margin: '14px 0 4px' }}>{text}</div>;
const doc = (text) => (
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', borderRadius: 4, padding: '10px 12px' }}><window.MaestroRichDoc md={text} /></div>
);
const kvGrid = { fontSize: 12, display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '3px 12px', margin: 0 };
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,.78)', backdropFilter: 'blur(2px)' }}></div>
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', width: 'min(860px, 94vw)', maxHeight: '92vh', background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 'var(--radius-lg, 10px)', overflow: 'hidden', boxShadow: '0 24px 64px rgba(0,0,0,.6)', animation: 'maestro-rise .18s ease both' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', padding: '12px 16px', borderBottom: '1px solid var(--line)', background: 'var(--panel-2)' }}>
<span style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '.18em', color: 'var(--bg-deep)', background: 'var(--muted)', padding: '2px 8px', borderRadius: 3 }}>{t.archiveDetail}</span>
<span style={{ fontWeight: 700, fontSize: 15 }}>{item.title}</span>
<ComplexityBadge complexity={item.cplx} />
<StatusChip status={item.status} label={t.status[item.status]} />
<span style={{ flex: 1 }}></span>
<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: '14px 20px 24px', fontFamily: 'var(--mono)' }}>
{d.attrs ? <dl style={kvGrid}>{d.attrs.map(([k, v], i) => (
<React.Fragment key={i}><dt style={{ color: 'var(--muted)' }}>{k}</dt><dd style={{ margin: 0, wordBreak: 'break-all' }}>{v}</dd></React.Fragment>
))}</dl> : null}
{d.spec ? <React.Fragment>{label('SPEC · 方案')}{doc(d.spec)}</React.Fragment> : null}
{d.ops ? <React.Fragment>{label('OPERATIONS · 操作')}{doc(d.ops)}</React.Fragment> : null}
{d.runs && d.runs.length ? <React.Fragment>
{label(t.runsLabel + '' + d.runs.length + '')}
<dl style={kvGrid}>{d.runs.map((r, i) => (
<React.Fragment key={i}>
<dt style={{ color: 'var(--muted)' }}>{r.kind} · {r.status}</dt>
<dd style={{ margin: 0 }}>{r.span}{r.ref ? <span style={{ display: 'block', color: 'var(--faint)', fontSize: 10.5 }}>{r.ref}</span> : null}</dd>
</React.Fragment>
))}</dl>
</React.Fragment> : null}
{d.result ? <React.Fragment>
{label(t.resultLabel)}
<dl style={kvGrid}>
<dt style={{ color: 'var(--muted)' }}>{t.branch}</dt><dd style={{ margin: 0 }}>{d.result.branch}</dd>
<dt style={{ color: 'var(--muted)' }}>commits</dt><dd style={{ margin: 0 }}>{d.result.commits.join(' · ')}</dd>
<dt style={{ color: 'var(--muted)' }}>diff</dt><dd style={{ margin: 0 }}>{d.result.diff}</dd>
</dl>
</React.Fragment> : null}
{d.approvals && d.approvals.length ? <React.Fragment>
{label(t.approvalsLabel)}
<dl style={kvGrid}>{d.approvals.map((a, i) => (
<React.Fragment key={i}>
<dt style={{ color: a.action === 'accept' ? 'var(--green)' : 'var(--red)', fontWeight: 700 }}>{a.gate} · {a.action === 'accept' ? t.accepted : t.rejected}</dt>
<dd style={{ margin: 0 }}>{t.approver} {a.actor} · {a.at}{a.reason ? <span style={{ display: 'block', color: 'var(--amber)' }}> {a.reason}</span> : null}</dd>
</React.Fragment>
))}</dl>
</React.Fragment> : null}
{d.timeline && d.timeline.length ? <React.Fragment>
{label(t.timelineLabel)}
<Timeline items={d.timeline} />
</React.Fragment> : null}
</div>
</div>
</div>
);
}
Object.assign(window, { MaestroKitArchiveSection: ArchiveSection, MaestroKitArchiveModal: ArchiveModal });