docs(design): 解压 Maestro Design System 到 design/(重构参照材料)
phosphor console v1.1 设计系统(从现网 web/style.css 演化):tokens(colors/effects/ typography/fonts) + components(core/forms/surfaces .jsx) + ui_kits(console + console_mobile) + assets/icons(15 双色 SVG) + guidelines + CLAUDE/SKILL/readme。供 B1–B6 重构任务对照实现。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
// 已归档区(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);
|
||||
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={{ marginTop: 28, opacity: .82 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontFamily: 'var(--mono)', fontSize: 11, fontWeight: 600, letterSpacing: '.22em', color: 'var(--muted)', textTransform: 'uppercase', padding: '18px 0 10px' }}>
|
||||
<span style={{ color: 'var(--faint)' }}>▣</span>{t.archive} · {items.length}
|
||||
</div>
|
||||
<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} />
|
||||
</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) => (
|
||||
<pre style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', borderRadius: 4, padding: '10px 12px', fontFamily: 'var(--mono)', fontSize: 12.5, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0, color: 'var(--ink)' }}>{text}</pre>
|
||||
);
|
||||
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 });
|
||||
@@ -0,0 +1,45 @@
|
||||
// 右栏:事件流 · 可折叠(折叠后仅图标)
|
||||
function MaestroEventIcon({ size = 16 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flex: 'none' }}>
|
||||
<polyline points="2 12 7 12 10 5 14 19 17 12 22 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function maestroPanelToggleProps(onClick, title) {
|
||||
return {
|
||||
onClick, title,
|
||||
style: {
|
||||
cursor: 'pointer', flex: 'none', fontFamily: 'var(--mono)', fontSize: 12, lineHeight: 1,
|
||||
background: 'transparent', color: 'var(--muted)',
|
||||
border: '1px solid var(--line)', borderRadius: 'var(--radius-sm, 4px)', padding: '4px 7px',
|
||||
},
|
||||
onMouseEnter: (e) => { e.currentTarget.style.color = 'var(--green)'; e.currentTarget.style.borderColor = 'var(--green-dim)'; },
|
||||
onMouseLeave: (e) => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.borderColor = 'var(--line)'; },
|
||||
};
|
||||
}
|
||||
|
||||
function EventPanel({ events, collapsed, onToggleCollapse, t }) {
|
||||
const { EventItem, SectionHead } = window.MaestroDesignSystem_a6a290;
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside style={{ background: 'var(--bg-deep)', borderLeft: '1px solid var(--line)', display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '16px 0', gap: 14 }}>
|
||||
<button {...maestroPanelToggleProps(onToggleCollapse, t.expandEvents)}>«</button>
|
||||
<span style={{ color: 'var(--muted)' }} title={t.events + ' · ' + events.length}><MaestroEventIcon size={18} /></span>
|
||||
<span style={{ fontSize: 10.5, color: 'var(--faint)', letterSpacing: '.04em' }}>{events.length}</span>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<aside style={{ background: 'var(--bg-deep)', borderLeft: '1px solid var(--line)', overflowY: 'auto', paddingBottom: 30 }}>
|
||||
<SectionHead title={t.events} action={<button {...maestroPanelToggleProps(onToggleCollapse, t.collapseEvents)}>»</button>}
|
||||
style={{ padding: '14px 14px 10px', position: 'sticky', top: 0, zIndex: 5, background: 'linear-gradient(var(--bg-deep) 75%, transparent)' }} />
|
||||
<ul style={{ listStyle: 'none', padding: '0 14px', margin: 0 }}>
|
||||
{events.map((e, i) => <EventItem key={i} type={e.type} label={t.eventTypes[e.type]} time={e.time} detail={e.detail} />)}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
window.MaestroKitEventPanel = EventPanel;
|
||||
@@ -0,0 +1,101 @@
|
||||
// 全屏预览:读完整方案 + 就地裁决
|
||||
function GatePreview({ item, t, onDecide, onClose }) {
|
||||
const { ComplexityBadge, Button, Textarea } = window.MaestroDesignSystem_a6a290;
|
||||
const [rejecting, setRejecting] = React.useState(false);
|
||||
const [reason, setReason] = React.useState('');
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
if (!item) return null;
|
||||
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>
|
||||
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', width: 'min(1080px, 94vw)', height: '94vh', background: 'var(--panel)', border: '1px solid var(--violet-dim)', borderRadius: 'var(--radius-lg, 10px)', overflow: 'hidden', boxShadow: '0 0 0 1px var(--line-soft), 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)', background: 'var(--violet)', padding: '2px 8px', borderRadius: 3 }}>{t.gates[item.gate]}</span>
|
||||
<span style={{ fontWeight: 700, fontSize: 15 }}>{item.title}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{item.meta}</span>
|
||||
<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: '16px 20px 28px', fontFamily: 'var(--mono)' }}>
|
||||
{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>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', padding: '12px 16px', borderTop: '1px solid var(--line)', background: 'var(--panel-2)' }}>
|
||||
{rejecting ? (
|
||||
<React.Fragment>
|
||||
<span style={{ flexBasis: '100%', display: 'flex' }}>
|
||||
<Textarea danger placeholder={t.rejectPlaceholder} minHeight={56} value={reason} onChange={setReason} />
|
||||
</span>
|
||||
<Button variant="reject" disabled={!reason.trim()} onClick={() => { onDecide(item, 'reject', reason); onClose(); }}>{t.confirmReject}</Button>
|
||||
<Button onClick={() => { setRejecting(false); setReason(''); }}>{t.cancel}</Button>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<Button variant="accept" onClick={() => { onDecide(item, 'accept'); onClose(); }}>{t.accept}</Button>
|
||||
<Button variant="reject" onClick={() => setRejecting(true)}>{t.reject}</Button>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 审批闸区:斜纹条 + GateCard 列表(accept/reject + 驳回必填理由)
|
||||
function GateSection({ approvals, onDecide, t }) {
|
||||
const { GateCard, GateStripe, Button, Textarea, SectionHead } = window.MaestroDesignSystem_a6a290;
|
||||
const [rejecting, setRejecting] = React.useState(null);
|
||||
const [reason, setReason] = React.useState('');
|
||||
const [preview, setPreview] = React.useState(null);
|
||||
const [folded, setFolded] = React.useState(() => new Set());
|
||||
const toggleFold = (id) => setFolded((prev) => {
|
||||
const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next;
|
||||
});
|
||||
if (approvals.length === 0) return null;
|
||||
return (
|
||||
<section style={{ marginTop: 18 }}>
|
||||
<GateStripe />
|
||||
<SectionHead mark="violet" title={t.gateSection} style={{ paddingTop: 12, color: 'var(--violet)' }} />
|
||||
{approvals.map((a) => {
|
||||
const isFolded = folded.has(a.id);
|
||||
return (
|
||||
<div key={a.id} style={{ marginBottom: 12 }}>
|
||||
<GateCard gate={a.gate} kindLabel={t.gates[a.gate]} title={a.title} meta={a.meta} docLabel={a.docLabel} doc={a.doc}
|
||||
collapsed={isFolded} foldable onToggleFold={() => toggleFold(a.id)}
|
||||
foldTitle={isFolded ? t.gateExpand : t.gateCollapse}
|
||||
onHeaderDoubleClick={() => setPreview(a)} headerTitle={t.gateDblTip}
|
||||
headerExtra={<button type="button" title={t.fullRead} onClick={() => setPreview(a)}
|
||||
style={{ fontFamily: 'var(--mono)', fontSize: 12, lineHeight: 1, cursor: 'pointer', background: 'transparent', color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 'var(--radius-sm,4px)', padding: '3px 7px' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--violet)'; e.currentTarget.style.borderColor = 'var(--violet-dim)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.borderColor = 'var(--line)'; }}>⛶</button>}
|
||||
actions={
|
||||
<React.Fragment>
|
||||
<Button variant="accept" onClick={() => onDecide(a, 'accept')}>{t.accept}</Button>
|
||||
<Button variant="reject" onClick={() => setRejecting(rejecting === a.id ? null : a.id)}>{t.reject}</Button>
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
<Button variant="ghost" size="xs" title={t.fullRead} onClick={() => setPreview(a)}>⛶ {t.fullRead}</Button>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
}>
|
||||
{rejecting === a.id ? (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginTop: 10 }}>
|
||||
<Textarea danger placeholder={t.rejectPlaceholder} minHeight={56} value={reason} onChange={setReason} />
|
||||
<Button variant="reject" disabled={!reason.trim()}
|
||||
onClick={() => { onDecide(a, 'reject', reason); setRejecting(null); setReason(''); }}>
|
||||
{t.confirmReject}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</GateCard>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<GatePreview item={preview} t={t} onDecide={onDecide} onClose={() => setPreview(null)} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
window.MaestroKitGateSection = GateSection;
|
||||
@@ -0,0 +1,397 @@
|
||||
// 左栏:logo + 项目列表(git 图标)+ WS 状态脚 · 可折叠(折叠后仅图标)
|
||||
// 项目 logo:图片优先(原型无图),回退首字母色块 hsl(h 45% 60%)
|
||||
function ProjectLogo({ project, size = 26 }) {
|
||||
return (
|
||||
<span style={{
|
||||
flex: 'none', width: size, height: size, borderRadius: 5,
|
||||
display: 'grid', placeItems: 'center',
|
||||
fontSize: size * 0.5, fontWeight: 700, color: '#0a0d0b',
|
||||
background: 'hsl(' + (project.hue || 140) + ' 45% 60%)',
|
||||
}}>{(project.name || '?')[0].toUpperCase()}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MaestroGitIcon({ size = 16 }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flex: 'none' }}>
|
||||
<line x1="6" y1="3" x2="6" y2="15" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<path d="M18 9a9 9 0 0 1-9 9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// 像素章鱼 mark(折叠态 logo)
|
||||
function MaestroMark({ scale = 2.2 }) {
|
||||
const ref = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
const MAP = ['.....LLLLL.....','...LLLLLLLLL...','..GGGGGGGGGGG..','..GGEEGGGEEGG..','..GGEEGGGEEGG..','..GGGGGGGGGGG..','...GGGGGGGGG...','...G..G.G..G...','...G..G.G..G...','..G...G.G...G..','..D...D.D...D..','.D...D...D...D.'];
|
||||
const INK = { L: '#79ec94', G: '#5fdd7d', D: '#2e6b3d', E: '#070908' };
|
||||
const ctx = ref.current.getContext('2d');
|
||||
MAP.forEach((row, y) => [...row].forEach((ch, x) => {
|
||||
if (INK[ch]) { ctx.fillStyle = INK[ch]; ctx.fillRect(x, y, 1, 1); }
|
||||
}));
|
||||
}, []);
|
||||
return <canvas ref={ref} width="15" height="12"
|
||||
style={{ width: 15 * scale, height: 12 * scale, imageRendering: 'pixelated', filter: 'drop-shadow(0 0 6px rgba(95,221,125,.4))' }} />;
|
||||
}
|
||||
|
||||
// 项目状态:运行中(青脉冲) / 暂停(faint) / 阻塞(琥珀) / 空闲(muted)
|
||||
function projStateMeta(state, t) {
|
||||
return ({
|
||||
running: { color: 'var(--cyan)', pulse: true, label: t.projRunning },
|
||||
paused: { color: 'var(--faint)', pulse: false, label: t.projPaused },
|
||||
blocked: { color: 'var(--amber)', pulse: true, label: t.projBlocked },
|
||||
idle: { color: 'var(--muted)', pulse: false, label: t.projIdle },
|
||||
})[state] || { color: 'var(--muted)', pulse: false, label: t.projIdle };
|
||||
}
|
||||
|
||||
function ProjStatusDot({ meta, size = 7 }) {
|
||||
return (
|
||||
<span style={{
|
||||
flex: 'none', width: size, height: size, borderRadius: '50%', background: meta.color,
|
||||
boxShadow: '0 0 7px ' + meta.color,
|
||||
animation: meta.pulse ? 'maestro-pulse 1.4s infinite' : 'none',
|
||||
}}></span>
|
||||
);
|
||||
}
|
||||
|
||||
// 侧栏底部小行:图标 + 主文 + 副文(折叠时仅图标,hover 高亮)
|
||||
function SideRow({ icon, label, detail, onClick, collapsed, title, accent }) {
|
||||
const interactive = !!onClick;
|
||||
return (
|
||||
<div onClick={onClick} title={title || label}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: collapsed ? '9px 0' : '8px 12px', minHeight: 40,
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
cursor: interactive ? 'pointer' : 'default', transition: 'background .1s',
|
||||
}}
|
||||
onMouseEnter={interactive ? (e) => { e.currentTarget.style.background = 'var(--panel)'; } : undefined}
|
||||
onMouseLeave={interactive ? (e) => { e.currentTarget.style.background = 'transparent'; } : undefined}>
|
||||
<span style={{ flex: 'none', color: accent || 'var(--muted)', display: 'inline-flex' }}>{icon}</span>
|
||||
{!collapsed ? (
|
||||
<span style={{ display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0, flex: 1 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ink)', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
{detail ? <span style={{ fontSize: 10, color: 'var(--faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{detail}</span> : null}
|
||||
</span>
|
||||
) : null}
|
||||
{!collapsed && interactive ? <span style={{ flex: 'none', color: 'var(--faint)', fontSize: 11 }}>›</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sIco = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', style: { flex: 'none' } };
|
||||
function IcoAgents() { return <svg {...sIco}><circle cx="12" cy="12" r="8" /><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none" /></svg>; }
|
||||
function IcoGlobalCfg() { return <svg {...sIco}><circle cx="12" cy="12" r="9" /><line x1="3" y1="12" x2="21" y2="12" /><path d="M12 3a14 14 0 0 0 0 18a14 14 0 0 0 0-18" /></svg>; }
|
||||
|
||||
function UserRow({ user, t, collapsed }) {
|
||||
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]);
|
||||
const avatar = (
|
||||
<span style={{
|
||||
flex: 'none', width: 26, height: 26, borderRadius: '50%', display: 'grid', placeItems: 'center',
|
||||
fontSize: 12, fontWeight: 700, color: '#0a0d0b', background: 'hsl(' + (user.hue || 200) + ' 50% 62%)',
|
||||
}}>{user.initial}</span>
|
||||
);
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', borderTop: '1px solid var(--line-soft)' }}>
|
||||
<div onClick={() => setOpen(!open)} title={user.name + ' · ' + user.plan}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: collapsed ? '10px 0' : '10px 12px', justifyContent: collapsed ? 'center' : 'flex-start', cursor: 'pointer', transition: 'background .1s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--panel)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
|
||||
{avatar}
|
||||
{!collapsed ? (
|
||||
<React.Fragment>
|
||||
<span style={{ display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0, flex: 1 }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--ink)', whiteSpace: 'nowrap' }}>{user.name}</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--green)', whiteSpace: 'nowrap' }}>{user.plan}</span>
|
||||
</span>
|
||||
<span style={{ flex: 'none', color: 'var(--faint)', fontSize: 11, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .12s' }}>▴</span>
|
||||
</React.Fragment>
|
||||
) : null}
|
||||
</div>
|
||||
{open ? (
|
||||
<div style={{
|
||||
position: collapsed ? 'fixed' : 'absolute',
|
||||
...(collapsed
|
||||
? { left: 58, bottom: 14, width: 180 }
|
||||
: { bottom: 'calc(100% + 4px)', left: 12, right: 12, minWidth: 160 }),
|
||||
zIndex: 80,
|
||||
background: 'var(--bg-deep)', border: '1px solid var(--line)', borderRadius: 'var(--radius-md,6px)',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,.65)', padding: 6, animation: 'maestro-rise .12s ease both',
|
||||
}}>
|
||||
<div style={{ padding: '6px 10px', borderBottom: '1px solid var(--line-soft)', marginBottom: 4 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600 }}>{user.name} <span style={{ color: 'var(--faint)', fontWeight: 400 }}>{user.handle}</span></div>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--green)', marginTop: 2 }}>{t.gUserPlan} · {user.plan}</div>
|
||||
</div>
|
||||
{[t.gUserSettings, t.gUserSignOut].map((label, i) => (
|
||||
<div key={label} style={{ padding: '6px 10px', fontSize: 12, color: i === 1 ? 'var(--red)' : 'var(--ink)', cursor: 'pointer', borderRadius: 3 }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--panel-2)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>{label}</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtTokens(n) {
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
|
||||
if (n >= 1e3) return Math.round(n / 1e3) + 'K';
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function AgentsRow({ global, summary, t, collapsed }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [pos, setPos] = React.useState(null);
|
||||
const ref = React.useRef(null);
|
||||
const rowRef = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target) && rowRef.current && !rowRef.current.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [open]);
|
||||
const toggle = () => {
|
||||
if (!open && rowRef.current) {
|
||||
const r = rowRef.current.getBoundingClientRect();
|
||||
const W = 286;
|
||||
const left = collapsed ? r.right + 6 : Math.min(r.left, window.innerWidth - W - 8);
|
||||
setPos({ left, bottom: window.innerHeight - r.top + 6, width: W });
|
||||
}
|
||||
setOpen((o) => !o);
|
||||
};
|
||||
const maxTok = Math.max(...summary.byProject.map((p) => p.tokens), 1);
|
||||
const detail = t.gAgentsDetail.replace('{p}', global.runningProjects).replace('{P}', global.projectCount).replace('{a}', global.runningAgents);
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div ref={rowRef}>
|
||||
<SideRow collapsed={collapsed} icon={<IcoAgents />} accent="var(--cyan)" onClick={toggle}
|
||||
label={t.gAgents} detail={detail} title={t.gAgents + ' · ' + detail} />
|
||||
</div>
|
||||
{open && pos ? (
|
||||
<div ref={ref} style={{
|
||||
position: 'fixed', left: pos.left, bottom: pos.bottom, width: pos.width, zIndex: 1000,
|
||||
background: 'var(--bg-deep)', border: '1px solid var(--line)', borderRadius: 'var(--radius-md,6px)',
|
||||
boxShadow: '0 12px 34px rgba(0,0,0,.7)', padding: '12px 14px', animation: 'maestro-rise .12s ease both',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 10.5, fontWeight: 700, letterSpacing: '.18em', color: 'var(--muted)', marginBottom: 10 }}>
|
||||
<span style={{ color: 'var(--cyan)' }}>▍</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: 12 }}>
|
||||
{[
|
||||
[fmtTokens(summary.tokensWeek), t.apTokens, 'var(--cyan)'],
|
||||
['$' + summary.costWeek.toFixed(1), t.apCost, 'var(--green)'],
|
||||
[summary.runsWeek, t.apRuns, 'var(--ink)'],
|
||||
[summary.activeNow + ' / ' + global.maxAgents, t.apActive, summary.activeNow ? 'var(--cyan)' : 'var(--faint)'],
|
||||
].map(([v, k, c], i) => (
|
||||
<div key={i} style={{ background: 'var(--bg-deep)', padding: '8px 10px' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: c, letterSpacing: '.02em' }}>{v}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', letterSpacing: '.06em', marginTop: 1 }}>{k}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 按项目 */}
|
||||
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '.18em', color: 'var(--faint)', marginBottom: 7 }}>{t.apPerProj}</div>
|
||||
<div style={{ display: 'grid', gap: 9 }}>
|
||||
{summary.byProject.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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalConfigRow({ global, t, collapsed }) {
|
||||
const { Select } = window.MaestroDesignSystem_a6a290;
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [pos, setPos] = React.useState(null);
|
||||
const ref = React.useRef(null);
|
||||
const rowRef = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target) && rowRef.current && !rowRef.current.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [open]);
|
||||
const toggle = () => {
|
||||
if (!open && rowRef.current) {
|
||||
const r = rowRef.current.getBoundingClientRect();
|
||||
const W = 250;
|
||||
// 优先在行上方对齐左缘;折叠态贴侧栏右侧
|
||||
const left = collapsed ? r.right + 6 : Math.min(r.left, window.innerWidth - W - 8);
|
||||
setPos({ left, bottom: window.innerHeight - r.top + 6, width: W });
|
||||
}
|
||||
setOpen((o) => !o);
|
||||
};
|
||||
const stat = (k, v, accent) => (
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, padding: '5px 0', borderBottom: '1px solid var(--line-soft)' }}>
|
||||
<span style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.06em' }}>{k}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: accent || 'var(--ink)' }}>{v}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div ref={rowRef}>
|
||||
<SideRow collapsed={collapsed} icon={<IcoGlobalCfg />} onClick={toggle}
|
||||
label={t.gConfig}
|
||||
detail={t.gConfigDetail.replace('{n}', global.maxAgents).replace('{v}', global.daemon)}
|
||||
title={t.gConfig} />
|
||||
</div>
|
||||
{open && pos ? (
|
||||
<div ref={ref} style={{
|
||||
position: 'fixed', left: pos.left, bottom: pos.bottom, width: pos.width,
|
||||
zIndex: 1000, background: 'var(--bg-deep)', border: '1px solid var(--line)', borderRadius: 'var(--radius-md,6px)',
|
||||
boxShadow: '0 12px 34px rgba(0,0,0,.7)', padding: '12px 14px', animation: 'maestro-rise .12s ease both',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 10.5, fontWeight: 700, letterSpacing: '.18em', color: 'var(--muted)', marginBottom: 8 }}>
|
||||
<span style={{ color: 'var(--cyan)' }}>▍</span>{t.gConfig.toUpperCase()}
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{stat('daemon', global.daemon, 'var(--green)')}
|
||||
{stat('port', ':' + global.port)}
|
||||
{stat('uptime', global.uptime)}
|
||||
{stat(t.gAgents, global.runningAgents + ' / ' + global.maxAgents, 'var(--cyan)')}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--muted)', letterSpacing: '.08em' }}>
|
||||
<span>{t.maxConcurrency}</span>
|
||||
<Select defaultValue={String(global.maxAgents)} style={{ width: '100%' }} options={['1', '2', '4', '6', '8'].map((n) => ({ value: n, label: n }))} />
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--muted)', letterSpacing: '.08em' }}>
|
||||
<span>{t.workMode}</span>
|
||||
<Select defaultValue={global.autonomy} style={{ width: '100%' }} options={Object.entries(t.autonomy).map(([value, label]) => ({ value, label }))} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onToggleCollapse, t, global, user, summary, onGlobalConfig }) {
|
||||
const { Button } = window.MaestroDesignSystem_a6a290;
|
||||
return (
|
||||
<aside style={{ background: 'var(--bg-deep)', borderRight: '1px solid var(--line)', display: 'flex', flexDirection: 'column', overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
<div style={{ padding: collapsed ? '16px 0 12px' : '20px 16px 14px', borderBottom: '1px solid var(--line-soft)', display: 'flex', flexDirection: 'column', alignItems: collapsed ? 'center' : 'flex-start' }}>
|
||||
{collapsed ? <MaestroMark /> : (
|
||||
<React.Fragment>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<MaestroMark scale={1.8} />
|
||||
<span style={{ fontSize: 19, fontWeight: 700, letterSpacing: '.28em', color: 'var(--green)', textShadow: '0 0 12px rgba(95,221,125,.45)' }}>
|
||||
MAESTRO<span style={{ marginLeft: 1, animation: 'maestro-blink 1.1s steps(1) infinite' }}>▮</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 5, fontSize: 9, fontWeight: 600, color: 'var(--muted)', letterSpacing: '.2em', lineHeight: 1.7, textTransform: 'uppercase' }}>{t.logoSub}</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 600, letterSpacing: '.22em', color: 'var(--muted)', padding: '16px 16px 8px' }}>
|
||||
<span style={{ color: 'var(--green)' }}>▍</span>{t.projects}
|
||||
<span style={{ marginLeft: 'auto' }}><Button variant="ghost" size="xs" onClick={onNewProject}>{t.newProject}</Button></span>
|
||||
</div>
|
||||
) : <div style={{ height: 12 }}></div>}
|
||||
<ul style={{ listStyle: 'none', flex: 1, margin: 0, padding: 0 }}>
|
||||
{projects.map((p) => {
|
||||
const active = p.id === currentId;
|
||||
const meta = projStateMeta(p.state, t);
|
||||
return (
|
||||
<li key={p.id} onClick={() => onSelect(p.id)} title={p.name + ' · ' + meta.label + (p.pending ? ' · ' + t.projPendingTip.replace('{n}', p.pending) : '')}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: collapsed ? '10px 0' : '9px 14px 9px 12px', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 9,
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
borderLeft: '2px solid ' + (active ? 'var(--green)' : 'transparent'),
|
||||
background: active ? 'var(--panel-2)' : 'transparent',
|
||||
color: active ? 'var(--green)' : 'var(--muted)',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = 'var(--panel)'; }}
|
||||
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = 'transparent'; }}>
|
||||
<ProjectLogo project={p} size={collapsed ? 26 : 24} />
|
||||
{!collapsed ? (
|
||||
<React.Fragment>
|
||||
<span style={{ display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0, flex: 1 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: active ? 'var(--green)' : 'var(--ink)' }}>{p.name}</span>
|
||||
<span style={{ fontSize: 10.5, color: 'var(--faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.path}</span>
|
||||
</span>
|
||||
<span style={{ flex: 'none', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{p.pending > 0 ? (
|
||||
<span title={t.projPendingTip.replace('{n}', p.pending)} style={{
|
||||
fontFamily: 'var(--mono)', fontSize: 9.5, fontWeight: 700, lineHeight: '15px',
|
||||
minWidth: 15, height: 15, textAlign: 'center', padding: '0 4px',
|
||||
color: 'var(--bg-deep)', background: 'var(--violet)', borderRadius: 8,
|
||||
}}>{p.pending}</span>
|
||||
) : null}
|
||||
<ProjStatusDot meta={meta} />
|
||||
</span>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<span style={{ position: 'absolute', top: 7, right: 9, display: 'flex', alignItems: 'center' }}>
|
||||
<ProjStatusDot meta={meta} size={p.pending > 0 ? 8 : 6} />
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{/* ── 底部:全局 agent · 全局配置 · 用户 ── */}
|
||||
<div style={{ borderTop: '1px solid var(--line-soft)' }}>
|
||||
<AgentsRow global={global} summary={summary} t={t} collapsed={collapsed} />
|
||||
<GlobalConfigRow global={global} t={t} collapsed={collapsed} />
|
||||
</div>
|
||||
<UserRow user={user} t={t} collapsed={collapsed} />
|
||||
<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' }}>
|
||||
{!collapsed ? (
|
||||
<React.Fragment>
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--green)', boxShadow: '0 0 8px var(--green)', display: 'inline-block', flex: 'none' }}></span>
|
||||
<span style={{ overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>{t.connected}</span>
|
||||
</React.Fragment>
|
||||
) : null}
|
||||
<button onClick={onToggleCollapse} title={collapsed ? t.expandSide : t.collapseSide}
|
||||
style={{
|
||||
marginLeft: collapsed ? 0 : 'auto', flex: 'none', cursor: 'pointer',
|
||||
fontFamily: 'var(--mono)', fontSize: 12, lineHeight: 1,
|
||||
background: 'transparent', color: 'var(--muted)',
|
||||
border: '1px solid var(--line)', borderRadius: 'var(--radius-sm, 4px)', padding: '4px 7px',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--green)'; e.currentTarget.style.borderColor = 'var(--green-dim)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.borderColor = 'var(--line)'; }}>
|
||||
{collapsed ? '»' : '«'}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
window.MaestroKitSidebar = Sidebar;
|
||||
@@ -0,0 +1,317 @@
|
||||
// 任务树:筛选栏(搜索+复杂度+状态分组)+ 折叠层级 + 复杂度可点换档 + 依赖可视化跳转 + 展开详情;新建任务表单
|
||||
const KIT_FILTER_GROUPS = [
|
||||
['todo', ['init', 'ready', 'blocked']],
|
||||
['doing', ['analyzing', 'speccing', 'queued', 'executing', 'decomposed']],
|
||||
['gate', ['plan_review', 'spec_review', 'exec_review']],
|
||||
['bad', ['failed', 'needs_attention']],
|
||||
['hold', ['paused', 'cancelled']],
|
||||
['done', ['done']],
|
||||
];
|
||||
|
||||
function kitFlatten(tasks, map = new Map()) {
|
||||
for (const tk of tasks) { map.set(tk.id, tk); if (tk.children) kitFlatten(tk.children, map); }
|
||||
return map;
|
||||
}
|
||||
|
||||
// 复杂度徽章 + 点击换档下拉
|
||||
function CplxPicker({ task, cplx, onChange }) {
|
||||
const { ComplexityBadge } = window.MaestroDesignSystem_a6a290;
|
||||
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', flex: 'none' }} onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => setOpen(!open)} title="点击调整复杂度" style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'inline-flex' }}>
|
||||
<ComplexityBadge complexity={cplx} />
|
||||
</button>
|
||||
{open ? (
|
||||
<span style={{
|
||||
position: 'absolute', top: 'calc(100% + 5px)', right: 0, zIndex: 60,
|
||||
display: 'flex', gap: 5, background: 'var(--bg-deep)', border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-md, 6px)', padding: 6, boxShadow: '0 10px 30px rgba(0,0,0,.65)',
|
||||
animation: 'maestro-rise .12s ease both',
|
||||
}}>
|
||||
{['hard', 'medium', 'easy'].map((v) => (
|
||||
<button key={v} onClick={() => { onChange(task.id, v); setOpen(false); }} style={{
|
||||
background: 'none', border: 'none', padding: 0, cursor: 'pointer', display: 'inline-flex',
|
||||
outline: v === cplx ? '1px solid currentColor' : 'none', outlineOffset: 1,
|
||||
filter: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.filter = 'brightness(1.35)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.filter = 'none'; }}>
|
||||
<ComplexityBadge complexity={v} />
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterBar({ t, kw, setKw, cplxSet, toggleCplx, statusSet, toggleGroup, matchCount, filtering, onClear }) {
|
||||
const { Button } = window.MaestroDesignSystem_a6a290;
|
||||
const chip = (on, color, dim, label, onClick) => (
|
||||
<button key={label} onClick={onClick} style={{
|
||||
fontFamily: 'var(--mono)', fontSize: 10, fontWeight: 700, letterSpacing: '.1em',
|
||||
background: on ? 'rgba(95,221,125,.08)' : 'transparent',
|
||||
color: on ? color : 'var(--faint)',
|
||||
border: '1px solid ' + (on ? dim : 'var(--line)'),
|
||||
borderRadius: 3, padding: '2px 8px', cursor: 'pointer', transition: 'all .1s', whiteSpace: 'nowrap',
|
||||
}}>{label}</button>
|
||||
);
|
||||
return (
|
||||
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 6, padding: '8px 10px', marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input value={kw} onChange={(e) => setKw(e.target.value)} placeholder={t.searchPh} autoComplete="off" style={{
|
||||
fontFamily: 'var(--mono)', fontSize: 12, width: 190, padding: '4px 9px',
|
||||
background: 'var(--bg-deep)', color: 'var(--ink)', border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-sm, 4px)', outline: 'none',
|
||||
}} />
|
||||
<span style={{ display: 'inline-flex', gap: 6 }}>
|
||||
{chip(cplxSet.has('hard'), 'var(--red)', 'var(--red-dim)', 'HARD', () => toggleCplx('hard'))}
|
||||
{chip(cplxSet.has('medium'), 'var(--amber)', 'var(--amber-dim)', 'MED', () => toggleCplx('medium'))}
|
||||
{chip(cplxSet.has('easy'), 'var(--green)', 'var(--green-dim)', 'EASY', () => toggleCplx('easy'))}
|
||||
</span>
|
||||
<span style={{ flex: 1 }}></span>
|
||||
{filtering ? <span style={{ fontSize: 11, color: 'var(--amber)', letterSpacing: '.08em' }}>{t.matchCount.replace('{n}', matchCount)}</span> : null}
|
||||
{filtering ? <Button variant="ghost" size="xs" onClick={onClear}>{t.clearFilter}</Button> : null}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{KIT_FILTER_GROUPS.map(([g, sts]) => {
|
||||
const on = sts.every((s) => statusSet.has(s));
|
||||
const part = !on && sts.some((s) => statusSet.has(s));
|
||||
return (
|
||||
<button key={g} onClick={() => toggleGroup(sts)} style={{
|
||||
fontFamily: 'var(--mono)', fontSize: 10.5, letterSpacing: '.04em',
|
||||
background: 'transparent',
|
||||
color: on ? 'var(--green)' : part ? 'var(--amber)' : 'var(--muted)',
|
||||
border: '1px dashed ' + (on ? 'var(--green-dim)' : part ? 'var(--amber-dim)' : 'var(--line-soft)'),
|
||||
borderRadius: 3, padding: '2px 9px', cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
}}>{t.fgroups[g]}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleOpen, t, byId, cplxOf, onChangeCplx, flashId, onJump, visibleSet }) {
|
||||
const { StatusChip } = window.MaestroDesignSystem_a6a290;
|
||||
const kids = (task.children || []).filter((k) => !visibleSet || visibleSet.has(k.id));
|
||||
const open = openIds.has(task.id) || !!visibleSet; // 筛选时自动展开可见节点
|
||||
const expanded = expandedId === task.id;
|
||||
const isGate = ['plan_review', 'spec_review', 'exec_review'].includes(task.status);
|
||||
const flashing = flashId === task.id;
|
||||
const [hover, setHover] = React.useState(false);
|
||||
return (
|
||||
<div style={depth > 0 ? { borderLeft: '1px solid var(--line-soft)' } : null}>
|
||||
<div onClick={() => setExpandedId(expanded ? null : task.id)} data-task-id={task.id}
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px 8px 6px',
|
||||
borderBottom: '1px solid var(--line-soft)', cursor: 'pointer', transition: 'background .1s',
|
||||
background: expanded ? 'var(--panel-2)' : isGate ? 'rgba(184,142,245,.05)' : hover ? 'var(--panel)' : 'transparent',
|
||||
opacity: ctx ? .55 : 1,
|
||||
animation: flashing ? 'maestro-locate 1.8s ease-out' : 'none',
|
||||
}}>
|
||||
<span onClick={(e) => { e.stopPropagation(); if (kids.length) toggleOpen(task.id); }}
|
||||
style={{
|
||||
width: 16, flex: 'none', textAlign: 'center', fontSize: 10, userSelect: 'none',
|
||||
color: kids.length ? (open ? 'var(--green)' : 'var(--muted)') : 'var(--faint)',
|
||||
transform: kids.length && open ? 'rotate(90deg)' : 'none', transition: 'transform .12s',
|
||||
}}>
|
||||
{kids.length ? '▶' : '·'}
|
||||
</span>
|
||||
<span style={{ fontWeight: 500, color: ctx ? 'var(--muted)' : 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{task.title}<span style={{ color: 'var(--faint)', fontSize: 10.5, marginLeft: 6 }}>{task.id}</span>
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 8 }}></span>
|
||||
{task.deps ? (
|
||||
<span style={{ flex: 'none', fontSize: 10.5, letterSpacing: '.06em', color: 'var(--amber)', border: '1px dashed var(--amber-dim)', borderRadius: 3, padding: '1px 7px', lineHeight: 1.5, whiteSpace: 'nowrap' }}>
|
||||
{t.depsWait} {task.deps.length}
|
||||
</span>
|
||||
) : null}
|
||||
<span style={{ fontSize: 10.5, color: task.prio === 'P0' ? 'var(--amber)' : 'var(--faint)', flex: 'none' }}>{task.prio}</span>
|
||||
<CplxPicker task={task} cplx={cplxOf(task)} onChange={onChangeCplx} />
|
||||
<StatusChip status={task.status} label={t.status[task.status]} />
|
||||
</div>
|
||||
{expanded ? <TaskDetail task={task} t={t} byId={byId} onJump={onJump} /> : null}
|
||||
{kids.length && open ? (
|
||||
<div style={{ marginLeft: 22 }}>
|
||||
{kids.map((k) => (
|
||||
<TaskRow key={k.id} task={k} depth={depth + 1} ctx={visibleSet ? visibleSet.ctx.has(k.id) : false}
|
||||
expandedId={expandedId} setExpandedId={setExpandedId} openIds={openIds} toggleOpen={toggleOpen} t={t}
|
||||
byId={byId} cplxOf={cplxOf} onChangeCplx={onChangeCplx} flashId={flashId} onJump={onJump} visibleSet={visibleSet} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDetail({ task, t, byId, onJump }) {
|
||||
const label = task.doc ? t.specLabel : task.ops ? t.opsLabel : null;
|
||||
const text = task.doc || task.ops;
|
||||
return (
|
||||
<div style={{ background: 'var(--panel)', borderBottom: '1px solid var(--line)', borderLeft: '2px solid var(--green-dim)', padding: '14px 16px', animation: 'maestro-rise .2s ease both', display: 'grid', gap: 14 }}>
|
||||
{label ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 4 }}>{label}</div>
|
||||
<pre style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', padding: '10px 12px', fontFamily: 'var(--mono)', fontSize: 12.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0, color: 'var(--ink)', borderRadius: 4 }}>{text}</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '10px 12px', border: '1px dashed var(--line)', borderRadius: 6, color: 'var(--faint)', fontStyle: 'italic', fontSize: 12 }}>
|
||||
{t.pendingDoc}
|
||||
</div>
|
||||
)}
|
||||
{task.deps && task.deps.length ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 4 }}>{t.depsLabel}</div>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
{task.deps.map((d) => {
|
||||
const dep = byId.get(d);
|
||||
const ok = dep && dep.status === 'done';
|
||||
return (
|
||||
<div key={d} onClick={(e) => { e.stopPropagation(); onJump(d); }} title="→" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, padding: '4px 10px', cursor: 'pointer',
|
||||
background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 4,
|
||||
borderLeft: '2px solid ' + (ok ? 'var(--green-dim)' : 'var(--amber-dim)'),
|
||||
transition: 'border-color .12s, background .12s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--panel-2)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'var(--bg-deep)'; }}>
|
||||
<span style={{ color: ok ? 'var(--green)' : 'var(--amber)', flex: 'none' }}>{ok ? '✓' : '◌'}</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{dep ? dep.title : d}</span>
|
||||
<span style={{ flex: 'none', fontSize: 10.5, color: ok ? 'var(--green)' : 'var(--amber)' }}>{ok ? t.depDone : t.depWait}</span>
|
||||
<span style={{ marginLeft: 'auto', color: 'var(--faint)', fontSize: 12 }}>→</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
|
||||
const { Button, Input, Select, ComplexitySeg } = window.MaestroDesignSystem_a6a290;
|
||||
return (
|
||||
<form style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 6, padding: 14, marginBottom: 14 }}
|
||||
onSubmit={(e) => { e.preventDefault(); onCreate(); }}>
|
||||
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<span style={{ flex: 1, minWidth: 320, display: 'flex' }}><Input label={t.titleLabel} required placeholder={t.titlePlaceholder} style={{ width: '100%' }} /></span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: 12 }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--muted)', letterSpacing: '.08em' }}>
|
||||
<span>{t.complexity} <span style={{ color: 'var(--red)' }}>*</span></span>
|
||||
<ComplexitySeg defaultValue="auto" includeAuto autoLabel={t.cplxAuto} />
|
||||
</label>
|
||||
<Select label={t.priority} defaultValue="1" options={[
|
||||
{ value: '0', label: t.p0 }, { value: '1', label: t.p1 }, { value: '2', label: t.p2 },
|
||||
]} />
|
||||
<span style={{ flex: 1, minWidth: 240, display: 'flex' }}>
|
||||
<Select label={t.parentTask} style={{ width: '100%' }} options={[{ value: '', label: t.topLevel }, ...tasks.map((tk) => ({ value: tk.id, label: tk.title }))]} />
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--line-soft)' }}>
|
||||
<Button onClick={onCancel}>{t.cancel}</Button>
|
||||
<Button variant="solid" type="submit">{t.create}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskSection({ tasks, onToast, t }) {
|
||||
const { Button, SectionHead } = window.MaestroDesignSystem_a6a290;
|
||||
const [expandedId, setExpandedId] = React.useState(null);
|
||||
const [openIds, setOpenIds] = React.useState(() => new Set(['t1']));
|
||||
const [showNew, setShowNew] = React.useState(false);
|
||||
const [kw, setKw] = React.useState('');
|
||||
const [cplxSet, setCplxSet] = React.useState(() => new Set());
|
||||
const [statusSet, setStatusSet] = React.useState(() => new Set());
|
||||
const [cplxOverride, setCplxOverride] = React.useState({});
|
||||
const [flashId, setFlashId] = React.useState(null);
|
||||
const byId = React.useMemo(() => kitFlatten(tasks), [tasks]);
|
||||
const cplxOf = (task) => cplxOverride[task.id] || task.cplx;
|
||||
const onChangeCplx = (id, v) => setCplxOverride((m) => ({ ...m, [id]: v }));
|
||||
const toggleOpen = (id) => setOpenIds((prev) => {
|
||||
const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next;
|
||||
});
|
||||
const toggleCplx = (v) => setCplxSet((prev) => {
|
||||
const next = new Set(prev); next.has(v) ? next.delete(v) : next.add(v); return next;
|
||||
});
|
||||
const toggleGroup = (sts) => setStatusSet((prev) => {
|
||||
const next = new Set(prev);
|
||||
const allOn = sts.every((s) => next.has(s));
|
||||
for (const s of sts) allOn ? next.delete(s) : next.add(s);
|
||||
return next;
|
||||
});
|
||||
const filtering = kw.trim() !== '' || cplxSet.size > 0 || statusSet.size > 0;
|
||||
const clearFilters = () => { setKw(''); setCplxSet(new Set()); setStatusSet(new Set()); };
|
||||
|
||||
// 筛选:自身命中 → 显示;祖先链作为上下文淡显;命中节点的父级自动展开
|
||||
const { visibleSet, matchCount } = React.useMemo(() => {
|
||||
if (!filtering) return { visibleSet: null, matchCount: 0 };
|
||||
const matches = (tk) =>
|
||||
(kw.trim() === '' || tk.title.toLowerCase().includes(kw.trim().toLowerCase())) &&
|
||||
(cplxSet.size === 0 || cplxSet.has(cplxOverride[tk.id] || tk.cplx)) &&
|
||||
(statusSet.size === 0 || statusSet.has(tk.status));
|
||||
const vis = new Set(); const ctx = new Set(); let count = 0;
|
||||
const walk = (tk) => {
|
||||
let childHit = false;
|
||||
for (const k of tk.children || []) if (walk(k)) childHit = true;
|
||||
const hit = matches(tk);
|
||||
if (hit) count++;
|
||||
if (hit || childHit) { vis.add(tk.id); if (!hit) ctx.add(tk.id); return true; }
|
||||
return false;
|
||||
};
|
||||
for (const tk of tasks) walk(tk);
|
||||
const set = new Set(vis); set.ctx = ctx;
|
||||
return { visibleSet: set, matchCount: count };
|
||||
}, [filtering, kw, cplxSet, statusSet, tasks, cplxOverride]);
|
||||
|
||||
// 依赖跳转:展开所有祖先 + flash 定位
|
||||
const onJump = (id) => {
|
||||
setOpenIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
const openAncestors = (list, chain) => {
|
||||
for (const tk of list) {
|
||||
if (tk.id === id) { for (const c of chain) next.add(c); return true; }
|
||||
if (tk.children && openAncestors(tk.children, [...chain, tk.id])) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
openAncestors(tasks, []);
|
||||
return next;
|
||||
});
|
||||
setFlashId(null);
|
||||
requestAnimationFrame(() => setFlashId(id));
|
||||
setTimeout(() => setFlashId((f) => (f === id ? null : f)), 1900);
|
||||
};
|
||||
|
||||
const roots = visibleSet ? tasks.filter((tk) => visibleSet.has(tk.id)) : tasks;
|
||||
return (
|
||||
<section>
|
||||
<SectionHead title={t.taskSection} sticky action={<Button variant="ghost" size="xs" onClick={() => setShowNew(!showNew)}>{t.newTask}</Button>} />
|
||||
{showNew ? <NewTaskPanel tasks={tasks} t={t} onCancel={() => setShowNew(false)}
|
||||
onCreate={() => { setShowNew(false); onToast('ok', t.toastCreated); }} /> : null}
|
||||
<FilterBar t={t} kw={kw} setKw={setKw} cplxSet={cplxSet} toggleCplx={toggleCplx}
|
||||
statusSet={statusSet} toggleGroup={toggleGroup} matchCount={matchCount} filtering={filtering} onClear={clearFilters} />
|
||||
<div>
|
||||
{roots.map((tk) => (
|
||||
<TaskRow key={tk.id} task={tk} depth={0} ctx={visibleSet ? visibleSet.ctx.has(tk.id) : false}
|
||||
expandedId={expandedId} setExpandedId={setExpandedId} openIds={openIds} toggleOpen={toggleOpen} t={t}
|
||||
byId={byId} cplxOf={cplxOf} onChangeCplx={onChangeCplx} flashId={flashId} onJump={onJump} visibleSet={visibleSet} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { MaestroKitTaskSection: TaskSection });
|
||||
@@ -0,0 +1,172 @@
|
||||
// 顶栏动作:默认仅图标,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 }) {
|
||||
const { Button, Input, Select } = window.MaestroDesignSystem_a6a290;
|
||||
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" defaultValue={String(project.concurrency)} width={76} />
|
||||
<Select label={t.workMode} defaultValue={project.autonomy} 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={onSave}>{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} style={{ width: '100%' }} /></span>
|
||||
<span style={{ flex: 1, minWidth: 170, display: 'flex' }}><Input label={t.verifyCmd} placeholder={t.verifyPh} style={{ width: '100%' }} /></span>
|
||||
<span style={{ flex: 1, minWidth: 170, display: 'flex' }}><Input label={t.model} placeholder={t.modelPh} style={{ width: '100%' }} /></span>
|
||||
</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 }) {
|
||||
const { SectionHead, QuotaMeter } = window.MaestroDesignSystem_a6a290;
|
||||
return (
|
||||
<section>
|
||||
<SectionHead mark="cyan" title={<span>{t.agentSection}<span style={{ color: 'var(--cyan)', letterSpacing: '.08em', marginLeft: 8 }}>{agents.length > 0 ? agents.length : ''}</span></span>} sticky />
|
||||
{quota ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 22, flexWrap: 'wrap', fontFamily: 'var(--mono)', margin: '0 0 12px' }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--muted)', letterSpacing: '.08em' }}>{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)} />
|
||||
</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} style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 12, padding: '3px 0' }}>
|
||||
<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={{ 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>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { MaestroKitTopbar: Topbar, MaestroKitConfigPanel: ConfigPanel, MaestroKitAgentSection: AgentSection });
|
||||
@@ -0,0 +1,99 @@
|
||||
// MAESTRO 调度台 · UI kit 假数据(与 src/model 状态机对齐)
|
||||
window.MAESTRO_MOCK = {
|
||||
projects: [
|
||||
{ id: 'p1', name: 'maestro', path: '~/dev/maestro', branch: 'main', autonomy: 'auto-easy', concurrency: 2, hue: 140, state: 'running', pending: 1, agents: 1 },
|
||||
{ id: 'p2', name: 'blog-engine', path: '~/dev/blog-engine', branch: 'main', autonomy: 'manual', concurrency: 1, hue: 38, state: 'paused', pending: 0, agents: 0 },
|
||||
{ id: 'p3', name: 'dotfiles', path: '~/.dotfiles', branch: 'master', autonomy: 'auto-approved', concurrency: 1, hue: 265, state: 'blocked', pending: 0, agents: 0 },
|
||||
],
|
||||
agents: [
|
||||
{ id: 'r1', project: 'maestro', title: '把轮询改为 WS 推送', kind: 'EXECUTOR', time: '06:12', meta: 'wt-127 · claude-sonnet' },
|
||||
],
|
||||
// 全局:跨项目 agent 概览 / daemon 配置 / 当前用户
|
||||
global: {
|
||||
projectCount: 3, runningProjects: 1, runningAgents: 1, maxAgents: 4,
|
||||
autonomy: 'auto-easy', daemon: 'v0.4.2', port: 4517, uptime: '3d 04h',
|
||||
},
|
||||
// Agent 汇总:本周窗口,按项目 + 总结
|
||||
agentSummary: {
|
||||
tokensWeek: 12400000, runsWeek: 47, costWeek: 38.6, activeNow: 1,
|
||||
byProject: [
|
||||
{ id: 'p1', name: 'maestro', hue: 140, active: 1, runs: 32, tokens: 8900000 },
|
||||
{ id: 'p2', name: 'blog-engine', hue: 38, active: 0, runs: 9, tokens: 2100000 },
|
||||
{ id: 'p3', name: 'dotfiles', hue: 265, active: 0, runs: 6, tokens: 1400000 },
|
||||
],
|
||||
},
|
||||
user: { name: 'jun', handle: '@jun', plan: 'Claude Max', initial: 'J', hue: 200 },
|
||||
quota: {
|
||||
five: { pct: 50, reset: '3h 8m' },
|
||||
week: { pct: 58, reset: '16h 38m' },
|
||||
},
|
||||
approvals: [
|
||||
{
|
||||
id: 'a1', gate: 'spec', taskId: 't4', title: '重构 sync 模块', meta: 'maestro · MED',
|
||||
docLabel: '改动方案(SPEC)',
|
||||
doc: '把轮询改为 WS 推送:\n1. store 层加 events 订阅接口\n2. daemon 广播 status.changed\n3. web 端断线重连 + 指数退避\n\n为什么:轮询 2s 间隔在多项目下放大为 N 路请求;WS 已有依赖,无新增包。',
|
||||
},
|
||||
],
|
||||
tasks: [
|
||||
{ id: 't1', title: '接入 Claude Agent SDK', cplx: 'hard', status: 'decomposed', prio: 'P0', children: [
|
||||
{ id: 't1a', title: 'PoC:worktree 起 headless CC', cplx: 'medium', status: 'done', prio: 'P0' },
|
||||
{ id: 't1b', title: 'executor 封装 + verify 钩子', cplx: 'medium', status: 'executing', prio: 'P0' },
|
||||
{ id: 't1c', title: '失败重试 n 次 → needs_attention', cplx: 'easy', status: 'blocked', prio: 'P1', deps: ['t1b'] },
|
||||
]},
|
||||
{ id: 't4', title: '重构 sync 模块', cplx: 'medium', status: 'spec_review', prio: 'P1',
|
||||
doc: '把轮询改为 WS 推送:store 层加 events 订阅…' },
|
||||
{ id: 't5', title: '看板移动端单栏适配', cplx: 'easy', status: 'ready', prio: 'P2',
|
||||
ops: '将执行的操作:@media 1100px 断点改单栏;侧栏折叠为顶部条。' },
|
||||
{ id: 't6', title: '事件流 append-only 审计导出', cplx: 'easy', status: 'done', prio: 'P2' },
|
||||
],
|
||||
archived: [
|
||||
{ id: 't6', title: '事件流 append-only 审计导出', cplx: 'easy', status: 'done', subs: 0, time: '2 小时前', timeFull: '2026-06-13 04:21:09',
|
||||
detail: {
|
||||
attrs: [['id', 't6'], ['复杂度', 'EASY'], ['优先级', 'P2'], ['状态', '完成'], ['深度', '1'], ['创建时间', '2026-06-12 22:03:44'], ['最后更新', '2026-06-13 04:21:09']],
|
||||
ops: '将执行的操作:store 层加 events 导出接口(JSONL);CLI 加 maestro events export。',
|
||||
runs: [{ kind: '执行', status: '成功', span: '2026-06-13 03:58:12 → 2026-06-13 04:11:47 · 时长 13 分 35 秒', ref: '转录 runs/r-0613-0358.jsonl' }],
|
||||
result: { branch: 'maestro/t6-events-export', commits: ['a3f9c21 feat(store): events export JSONL'], diff: '+182 −12 · 4 files' },
|
||||
approvals: [{ gate: '结果评审', action: 'accept', actor: 'you', at: '2026-06-13 04:21:09' }],
|
||||
timeline: [
|
||||
{ time: '06-12 22:03', text: '任务创建', who: 'you' },
|
||||
{ time: '06-13 03:58', text: 'run 开始(执行)', who: '编排器', color: 'var(--cyan)' },
|
||||
{ time: '06-13 04:11', text: '执行中 → 待审/合', who: '编排器', color: 'var(--violet)' },
|
||||
{ time: '06-13 04:21', text: '审批通过(结果评审)· 合并', who: 'you', color: 'var(--green)' },
|
||||
],
|
||||
} },
|
||||
{ id: 't3', title: '旧 todo.json 导入器', cplx: 'medium', status: 'done', subs: 2, time: '昨天', timeFull: '2026-06-12 09:14:02',
|
||||
detail: {
|
||||
attrs: [['id', 't3'], ['复杂度', 'MED'], ['优先级', 'P1'], ['状态', '完成'], ['深度', '1'], ['创建时间', '2026-06-10 11:40:12'], ['最后更新', '2026-06-12 09:14:02']],
|
||||
spec: '读旧 todo/todo.json,tier1/2/3 → hard/medium/easy,导入为一个项目;重复标题跳过。',
|
||||
runs: [{ kind: '执行', status: '成功', span: '2026-06-12 08:31:00 → 2026-06-12 08:54:18 · 时长 23 分 18 秒', ref: '转录 runs/r-0612-0831.jsonl' }],
|
||||
approvals: [
|
||||
{ gate: '方案评审', action: 'reject', actor: 'you', at: '2026-06-11 19:02:51', reason: '未处理重复导入' },
|
||||
{ gate: '方案评审', action: 'accept', actor: 'you', at: '2026-06-12 07:48:20' },
|
||||
{ gate: '结果评审', action: 'accept', actor: 'you', at: '2026-06-12 09:14:02' },
|
||||
],
|
||||
timeline: [
|
||||
{ time: '06-10 11:40', text: '任务创建', who: 'you' },
|
||||
{ time: '06-11 19:02', text: '审批驳回(方案评审):未处理重复导入', who: 'you', color: 'var(--red)' },
|
||||
{ time: '06-12 07:48', text: '审批通过(方案评审)', who: 'you', color: 'var(--green)' },
|
||||
{ time: '06-12 08:31', text: 'run 开始(执行)', who: '编排器', color: 'var(--cyan)' },
|
||||
{ time: '06-12 09:14', text: '审批通过(结果评审)· 合并', who: 'you', color: 'var(--green)' },
|
||||
],
|
||||
} },
|
||||
{ id: 't2', title: '远程看板(可选)调研', cplx: 'easy', status: 'cancelled', subs: 0, time: '3 天前', timeFull: '2026-06-10 16:02:33',
|
||||
detail: {
|
||||
attrs: [['id', 't2'], ['复杂度', 'EASY'], ['优先级', 'P2'], ['状态', '取消'], ['深度', '1'], ['创建时间', '2026-06-09 10:12:00'], ['最后更新', '2026-06-10 16:02:33']],
|
||||
timeline: [
|
||||
{ time: '06-09 10:12', text: '任务创建', who: 'you' },
|
||||
{ time: '06-10 16:02', text: '可执行 → 取消', who: 'you', color: 'var(--faint)' },
|
||||
],
|
||||
} },
|
||||
],
|
||||
events: [
|
||||
{ type: 'run.started', time: '06:12:09', detail: 'executor · 把轮询改为 WS 推送 · wt-127' },
|
||||
{ type: 'approval.requested', time: '06:10:44', detail: 'spec_review · 重构 sync 模块' },
|
||||
{ type: 'approval.rejected', time: '05:58:02', detail: '重构 sync 模块 ↳ 缺少回滚方案' },
|
||||
{ type: 'status.changed', time: '05:31:18', detail: 'PoC:worktree 起 headless CC:执行中 → 完成' },
|
||||
{ type: 'task.created', time: '05:02:51', detail: '看板移动端单栏适配' },
|
||||
{ type: 'project.synced', time: '04:48:00', detail: 'maestro · 3 项导入' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
// MAESTRO 调度台 · 界面语言包(zh / en / es / ja / fr)— UI chrome 文案;任务/事件内容为用户数据不翻译
|
||||
window.MAESTRO_LANGS = [
|
||||
['zh', '中文'], ['en', 'English'], ['es', 'Español'], ['ja', '日本語'], ['fr', 'Français'],
|
||||
];
|
||||
window.MAESTRO_I18N = {
|
||||
zh: {
|
||||
logoSub: '多项目任务调度台',
|
||||
projects: '项目', newProject: '+ 新建', connected: '已连接 · :4517',
|
||||
expandSide: '展开侧栏', collapseSide: '折叠侧栏',
|
||||
badgeGate: '项待审批', badgeReady: '待执行', badgeRun: '执行中', badgeBlocked: '被阻塞', badgeTotal: '总量',
|
||||
badgeGateTip: '等待你裁决的审批闸', badgeReadyTip: '可执行(依赖已满足)+ 排队中',
|
||||
badgeRunTip: 'agent 正在执行', badgeBlockedTip: '被依赖阻塞(依赖完成后自动放行)',
|
||||
badgeTotalTip: '未完成任务总量(不含 done / 已拆解容器 / 取消)',
|
||||
sync: '同步 todo', lastSync: '上次同步 06:12', config: '配置', configTip: '项目配置(并发 / 工作模式 / todo 同步)',
|
||||
archive: '已归档', archiveTip: '点击查看完整详情', archiveSubs: '含 {n} 子任务', archiveDetail: '归档详情',
|
||||
pagePrev: '‹ 上一页', pageNext: '下一页 ›', pageOf: '第 {a} / {b} 页', perPage: '每页',
|
||||
attrsLabel: '属性', runsLabel: '执行历史', resultLabel: '执行结果', approvalsLabel: '审批记录', timelineLabel: '状态流转时间线',
|
||||
branch: '分支', accepted: '接受', rejected: '驳回', approver: '审批人', opinion: '意见', closeTip: '关闭(Esc)',
|
||||
lang: '中文', langTip: '切换语言 / Language', themeLight: '浅色', themeDark: '深色', themeTip: '切换 light/dark',
|
||||
maxConcurrency: '最大并发', workMode: '工作模式', current: '当前', save: '保存配置', collapse: '收起',
|
||||
autonomy: { manual: '手动', 'auto-easy': '自动 · Easy', 'auto-approved': '自动 · 已批准' },
|
||||
agentSection: 'Agent 执行', agentEmpty: '无运行中的 agent', running: 'RUNNING', since: '起',
|
||||
projRunning: '运行中', projPaused: '暂停', projBlocked: '阻塞', projIdle: '空闲',
|
||||
projPendingTip: '{n} 项待你审批',
|
||||
gAgents: '全局 Agent', gAgentsDetail: '{p}/{P} 个项目运行 · {a} 个 agent',
|
||||
apActive: '运行', apRuns: '次运行', apTokens: 'token', apWeek: '本周', apCost: '花费', apTotal: '所有项目', apPerProj: '按项目', apIdle: '空闲',
|
||||
gConfig: '全局配置', gConfigDetail: '并发上限 {n} · daemon {v}',
|
||||
gUserPlan: '订阅', gUserSignOut: '退出登录', gUserSettings: '账户设置',
|
||||
gateExpand: '展开', gateCollapse: '折叠', gateDblTip: '双击全屏阅读',
|
||||
quota: '额度', quotaWeek: '周', quotaReset: '{t} 后重置',
|
||||
projLogo: '项目 Logo', logoPh: '图片 URL 或仓库内相对路径;留空=自动', verifyCmd: '校验命令', verifyPh: 'npm test · go test ./...(可空)', model: '模型', modelPh: 'claude-opus-4-5(可空)',
|
||||
searchPh: '⌕ 搜索标题…', clearFilter: '✕ 清除筛选', matchCount: '{n} 项匹配',
|
||||
fgroups: { todo: '待办', doing: '进行中', gate: '待审批', bad: '异常', hold: '挂起', done: '完成' },
|
||||
fullRead: '全屏阅读', depsLabel: 'DEPS · 依赖(全部完成才可执行)', depDone: '完成', depWait: '等待',
|
||||
gateSection: '审批闸 · 等待裁决', accept: '✓ 通过', reject: '✕ 驳回', confirmReject: '确认驳回',
|
||||
rejectPlaceholder: '改进意见(必填)',
|
||||
taskSection: '任务树', newTask: '+ 新建任务', titleLabel: '标题', titlePlaceholder: '要做什么',
|
||||
complexity: '复杂度', cplxAuto: '智能', parentTask: '父任务', topLevel: '(顶层)', priority: '优先级',
|
||||
p0: 'P0 · 高', p1: 'P1 · 中', p2: 'P2 · 低', create: '创建任务', cancel: '取消',
|
||||
depsWait: '待依赖', specLabel: '改动方案(SPEC)', opsLabel: '将执行的操作(OPERATIONS)',
|
||||
pendingDoc: '待 Claude Code 产出(经 MCP 写入并提交评审)',
|
||||
emptyTasks: '该项目暂无任务 —— 点「+ 新建任务」或经 MCP 创建', empty: '空',
|
||||
events: '事件流', expandEvents: '展开事件流', collapseEvents: '折叠事件流',
|
||||
toastCreated: '任务已创建 · init', toastAccepted: '已通过 · ', toastRejected: '已驳回 · 退回重做',
|
||||
toastSynced: 'todo 已同步 · 0 项变更', toastSaved: '配置已保存', toastModal: '原型:新建项目模态未接',
|
||||
gatePassed: ' 通过',
|
||||
status: { init: '新建', analyzing: '分析拆解中', plan_review: '待确认拆解', decomposed: '已拆解', speccing: '写方案中', spec_review: '待确认方案', ready: '可执行', blocked: '被依赖阻塞', queued: '排队中', executing: '执行中', exec_review: '待审/合', failed: '失败', needs_attention: '需人工', done: '完成', paused: '暂停', cancelled: '取消' },
|
||||
gates: { plan: '拆解评审', spec: '方案评审', exec: '结果评审' },
|
||||
eventTypes: { 'task.created': '任务创建', 'task.updated': '任务更新', 'status.changed': '状态变更', 'approval.requested': '请求审批', 'approval.granted': '审批通过', 'approval.rejected': '审批驳回', 'run.started': '运行开始', 'run.finished': '运行结束', 'project.synced': 'todo 同步' },
|
||||
},
|
||||
en: {
|
||||
logoSub: 'MULTI-PROJECT ORCHESTRATOR',
|
||||
projects: 'PROJECTS', newProject: '+ New', connected: 'Connected · :4517',
|
||||
expandSide: 'Expand sidebar', collapseSide: 'Collapse sidebar',
|
||||
badgeGate: 'pending review', badgeReady: 'to run', badgeRun: 'running', badgeBlocked: 'blocked', badgeTotal: 'total',
|
||||
badgeGateTip: 'Approval gates awaiting your decision', badgeReadyTip: 'Ready (deps met) + queued',
|
||||
badgeRunTip: 'Agents currently executing', badgeBlockedTip: 'Blocked (auto-released when deps complete)',
|
||||
badgeTotalTip: 'Open tasks (excl. done / decomposed containers / cancelled)',
|
||||
sync: 'Sync todo', lastSync: 'Last sync 06:12', config: 'Config', configTip: 'Project config (concurrency / autonomy / todo sync)',
|
||||
archive: 'Archive', archiveTip: 'Click for full detail', archiveSubs: '{n} subtasks', archiveDetail: 'ARCHIVE',
|
||||
pagePrev: '‹ Prev', pageNext: 'Next ›', pageOf: 'Page {a} / {b}', perPage: 'Per page',
|
||||
attrsLabel: 'ATTRIBUTES', runsLabel: 'RUN HISTORY', resultLabel: 'RESULT', approvalsLabel: 'APPROVALS', timelineLabel: 'STATUS TIMELINE',
|
||||
branch: 'Branch', accepted: 'accepted', rejected: 'rejected', approver: 'Approver', opinion: 'Feedback', closeTip: 'Close (Esc)',
|
||||
lang: 'English', langTip: 'Switch language', themeLight: 'Light', themeDark: 'Dark', themeTip: 'Toggle light/dark',
|
||||
maxConcurrency: 'Max concurrency', workMode: 'Autonomy', current: 'Current', save: 'Save config', collapse: 'Close',
|
||||
autonomy: { manual: 'Manual', 'auto-easy': 'Auto · Easy', 'auto-approved': 'Auto · Approved' },
|
||||
agentSection: 'Agent runs', agentEmpty: 'No running agents', running: 'RUNNING', since: '',
|
||||
projRunning: 'Running', projPaused: 'Paused', projBlocked: 'Blocked', projIdle: 'Idle',
|
||||
projPendingTip: '{n} awaiting your review',
|
||||
gAgents: 'Agents', gAgentsDetail: '{p}/{P} projects · {a} agents',
|
||||
apActive: 'active', apRuns: 'runs', apTokens: 'tokens', apWeek: 'this week', apCost: 'cost', apTotal: 'All projects', apPerProj: 'BY PROJECT', apIdle: 'idle',
|
||||
gConfig: 'Settings', gConfigDetail: 'Max concurrency {n} · daemon {v}',
|
||||
gUserPlan: 'Plan', gUserSignOut: 'Sign out', gUserSettings: 'Account settings',
|
||||
gateExpand: 'Expand', gateCollapse: 'Collapse', gateDblTip: 'Double-click for fullscreen',
|
||||
quota: 'Quota', quotaWeek: 'week', quotaReset: 'resets in {t}',
|
||||
projLogo: 'Project logo', logoPh: 'Image URL or repo-relative path; empty = auto', verifyCmd: 'Verify command', verifyPh: 'npm test · go test ./... (optional)', model: 'Model', modelPh: 'claude-opus-4-5 (optional)',
|
||||
searchPh: '⌕ Search titles…', clearFilter: '✕ Clear filters', matchCount: '{n} matches',
|
||||
fgroups: { todo: 'To do', doing: 'In progress', gate: 'Pending review', bad: 'Issues', hold: 'On hold', done: 'Done' },
|
||||
fullRead: 'Read fullscreen', depsLabel: 'DEPS (all must complete to run)', depDone: 'done', depWait: 'waiting',
|
||||
gateSection: 'Approval gates · awaiting decision', accept: '✓ Accept', reject: '✕ Reject', confirmReject: 'Confirm reject',
|
||||
rejectPlaceholder: 'Improvement feedback (required)',
|
||||
taskSection: 'Task tree', newTask: '+ New task', titleLabel: 'Title', titlePlaceholder: 'What needs doing',
|
||||
complexity: 'Complexity', cplxAuto: 'AUTO', parentTask: 'Parent', topLevel: '(top level)', priority: 'Priority',
|
||||
p0: 'P0 · high', p1: 'P1 · medium', p2: 'P2 · low', create: 'Create task', cancel: 'Cancel',
|
||||
depsWait: 'deps', specLabel: 'CHANGE SPEC', opsLabel: 'PLANNED OPERATIONS',
|
||||
pendingDoc: 'Awaiting Claude Code output (written via MCP, then submitted for review)',
|
||||
emptyTasks: 'No tasks in this project — hit "+ New task" or create via MCP', empty: 'EMPTY',
|
||||
events: 'Events', expandEvents: 'Expand events', collapseEvents: 'Collapse events',
|
||||
toastCreated: 'Task created · init', toastAccepted: 'Accepted · ', toastRejected: 'Rejected · sent back',
|
||||
toastSynced: 'Todo synced · 0 changes', toastSaved: 'Config saved', toastModal: 'Prototype: new-project modal not wired',
|
||||
gatePassed: ' approved',
|
||||
status: { init: 'New', analyzing: 'Analyzing', plan_review: 'Plan review', decomposed: 'Decomposed', speccing: 'Drafting spec', spec_review: 'Spec review', ready: 'Ready', blocked: 'Blocked', queued: 'Queued', executing: 'Executing', exec_review: 'Review & merge', failed: 'Failed', needs_attention: 'Needs attention', done: 'Done', paused: 'Paused', cancelled: 'Cancelled' },
|
||||
gates: { plan: 'PLAN REVIEW', spec: 'SPEC REVIEW', exec: 'EXEC REVIEW' },
|
||||
eventTypes: { 'task.created': 'Task created', 'task.updated': 'Task updated', 'status.changed': 'Status changed', 'approval.requested': 'Approval requested', 'approval.granted': 'Approval granted', 'approval.rejected': 'Approval rejected', 'run.started': 'Run started', 'run.finished': 'Run finished', 'project.synced': 'Todo synced' },
|
||||
},
|
||||
es: {
|
||||
logoSub: 'ORQUESTADOR MULTIPROYECTO',
|
||||
projects: 'PROYECTOS', newProject: '+ Nuevo', connected: 'Conectado · :4517',
|
||||
expandSide: 'Expandir panel', collapseSide: 'Plegar panel',
|
||||
badgeGate: 'por revisar', badgeReady: 'por ejecutar', badgeRun: 'en curso', badgeBlocked: 'bloqueadas', badgeTotal: 'total',
|
||||
badgeGateTip: 'Puertas de aprobación esperando tu decisión', badgeReadyTip: 'Listas (deps cumplidas) + en cola',
|
||||
badgeRunTip: 'Agents en ejecución', badgeBlockedTip: 'Bloqueadas (se liberan al completar deps)',
|
||||
badgeTotalTip: 'Tareas abiertas (sin done / contenedores / canceladas)',
|
||||
sync: 'Sincronizar todo', lastSync: 'Última sinc. 06:12', config: 'Config', configTip: 'Configuración (concurrencia / modo / sinc. todo)',
|
||||
archive: 'Archivo', archiveTip: 'Clic para ver detalle', archiveSubs: '{n} subtareas', archiveDetail: 'ARCHIVO',
|
||||
pagePrev: '‹ Anterior', pageNext: 'Siguiente ›', pageOf: 'Página {a} / {b}', perPage: 'Por página',
|
||||
attrsLabel: 'ATRIBUTOS', runsLabel: 'HISTORIAL DE RUNS', resultLabel: 'RESULTADO', approvalsLabel: 'APROBACIONES', timelineLabel: 'LÍNEA DE TIEMPO',
|
||||
branch: 'Rama', accepted: 'aceptada', rejected: 'rechazada', approver: 'Aprobador', opinion: 'Comentario', closeTip: 'Cerrar (Esc)',
|
||||
lang: 'Español', langTip: 'Cambiar idioma', themeLight: 'Claro', themeDark: 'Oscuro', themeTip: 'Cambiar tema',
|
||||
maxConcurrency: 'Concurrencia máx.', workMode: 'Modo', current: 'Actual', save: 'Guardar', collapse: 'Cerrar',
|
||||
autonomy: { manual: 'manual', 'auto-easy': 'auto · Easy', 'auto-approved': 'auto · aprob.' },
|
||||
agentSection: 'Ejecución de agents', agentEmpty: 'Sin agents en ejecución', running: 'RUNNING', since: '',
|
||||
projRunning: 'En curso', projPaused: 'Pausado', projBlocked: 'Bloqueado', projIdle: 'Inactivo',
|
||||
projPendingTip: '{n} esperan tu revisión',
|
||||
gAgents: 'Agents globales', gAgentsDetail: '{p}/{P} proyectos · {a} agents',
|
||||
apActive: 'activos', apRuns: 'ejecuciones', apTokens: 'tokens', apWeek: 'esta semana', apCost: 'coste', apTotal: 'Todos los proyectos', apPerProj: 'POR PROYECTO', apIdle: 'inactivo',
|
||||
gConfig: 'Config global', gConfigDetail: 'Concurrencia máx {n} · daemon {v}',
|
||||
gUserPlan: 'Plan', gUserSignOut: 'Cerrar sesión', gUserSettings: 'Ajustes de cuenta',
|
||||
gateExpand: 'Expandir', gateCollapse: 'Plegar', gateDblTip: 'Doble clic para pantalla completa',
|
||||
quota: 'Cuota', quotaWeek: 'semana', quotaReset: 'reinicio en {t}',
|
||||
projLogo: 'Logo del proyecto', logoPh: 'URL o ruta relativa; vacío = auto', verifyCmd: 'Comando de verificación', verifyPh: 'npm test · go test ./... (opcional)', model: 'Modelo', modelPh: 'claude-opus-4-5 (opcional)',
|
||||
searchPh: '⌕ Buscar títulos…', clearFilter: '✕ Limpiar filtros', matchCount: '{n} coincidencias',
|
||||
fgroups: { todo: 'Pendientes', doing: 'En curso', gate: 'Por revisar', bad: 'Incidencias', hold: 'En pausa', done: 'Hechas' },
|
||||
fullRead: 'Pantalla completa', depsLabel: 'DEPS (todas deben completarse)', depDone: 'hecha', depWait: 'esperando',
|
||||
gateSection: 'Puertas de aprobación · pendientes', accept: '✓ Aceptar', reject: '✕ Rechazar', confirmReject: 'Confirmar rechazo',
|
||||
rejectPlaceholder: 'Comentario de mejora (obligatorio)',
|
||||
taskSection: 'Árbol de tareas', newTask: '+ Nueva tarea', titleLabel: 'Título', titlePlaceholder: 'Qué hay que hacer',
|
||||
complexity: 'Complejidad', cplxAuto: 'AUTO', parentTask: 'Padre', topLevel: '(raíz)', priority: 'Prioridad',
|
||||
p0: 'P0 · alta', p1: 'P1 · media', p2: 'P2 · baja', create: 'Crear tarea', cancel: 'Cancelar',
|
||||
depsWait: 'deps', specLabel: 'SPEC DE CAMBIOS', opsLabel: 'OPERACIONES PREVISTAS',
|
||||
pendingDoc: 'Esperando salida de Claude Code (vía MCP, luego a revisión)',
|
||||
emptyTasks: 'Sin tareas — pulsa "+ Nueva tarea" o crea vía MCP', empty: 'VACÍO',
|
||||
events: 'Eventos', expandEvents: 'Expandir eventos', collapseEvents: 'Plegar eventos',
|
||||
toastCreated: 'Tarea creada · init', toastAccepted: 'Aceptada · ', toastRejected: 'Rechazada · devuelta',
|
||||
toastSynced: 'Todo sincronizado · 0 cambios', toastSaved: 'Config guardada', toastModal: 'Prototipo: modal no conectado',
|
||||
gatePassed: ' aprobada',
|
||||
status: { init: 'Nueva', analyzing: 'Analizando', plan_review: 'Revisión de plan', decomposed: 'Descompuesta', speccing: 'Redactando spec', spec_review: 'Revisión de spec', ready: 'Lista', blocked: 'Bloqueada', queued: 'En cola', executing: 'Ejecutando', exec_review: 'Revisar y fusionar', failed: 'Fallida', needs_attention: 'Requiere atención', done: 'Hecha', paused: 'Pausada', cancelled: 'Cancelada' },
|
||||
gates: { plan: 'REVISIÓN DE PLAN', spec: 'REVISIÓN DE SPEC', exec: 'REVISIÓN DE RESULTADO' },
|
||||
eventTypes: { 'task.created': 'Tarea creada', 'task.updated': 'Tarea actualizada', 'status.changed': 'Cambio de estado', 'approval.requested': 'Aprobación solicitada', 'approval.granted': 'Aprobación concedida', 'approval.rejected': 'Aprobación rechazada', 'run.started': 'Run iniciado', 'run.finished': 'Run terminado', 'project.synced': 'Todo sincronizado' },
|
||||
},
|
||||
ja: {
|
||||
logoSub: 'マルチプロジェクト・オーケストレーター',
|
||||
projects: 'プロジェクト', newProject: '+ 新規', connected: '接続済み · :4517',
|
||||
expandSide: 'サイドバーを開く', collapseSide: 'サイドバーを畳む',
|
||||
badgeGate: '件 承認待ち', badgeReady: '実行待ち', badgeRun: '実行中', badgeBlocked: 'ブロック中', badgeTotal: '合計',
|
||||
badgeGateTip: 'あなたの裁定を待つ承認ゲート', badgeReadyTip: '実行可能(依存解決済み)+ キュー',
|
||||
badgeRunTip: 'agent が実行中', badgeBlockedTip: '依存でブロック(解決後に自動開放)',
|
||||
badgeTotalTip: '未完了タスク数(done / 分解済み / キャンセル除く)',
|
||||
sync: 'todo 同期', lastSync: '最終同期 06:12', config: '設定', configTip: 'プロジェクト設定(並列数 / モード / todo 同期)',
|
||||
archive: 'アーカイブ', archiveTip: 'クリックで詳細表示', archiveSubs: 'サブタスク {n} 件', archiveDetail: 'アーカイブ詳細',
|
||||
pagePrev: '‹ 前へ', pageNext: '次へ ›', pageOf: '{a} / {b} ページ', perPage: '表示件数',
|
||||
attrsLabel: '属性', runsLabel: '実行履歴', resultLabel: '実行結果', approvalsLabel: '承認記録', timelineLabel: 'ステータス・タイムライン',
|
||||
branch: 'ブランチ', accepted: '承認', rejected: '却下', approver: '承認者', opinion: 'コメント', closeTip: '閉じる(Esc)',
|
||||
lang: '日本語', langTip: '言語を切替', themeLight: 'ライト', themeDark: 'ダーク', themeTip: 'テーマ切替',
|
||||
maxConcurrency: '最大並列数', workMode: 'モード', current: '現在', save: '保存', collapse: '閉じる',
|
||||
autonomy: { manual: '手動', 'auto-easy': '自動 · Easy', 'auto-approved': '自動 · 承認済' },
|
||||
agentSection: 'Agent 実行', agentEmpty: '実行中の agent はありません', running: 'RUNNING', since: '開始',
|
||||
projRunning: '実行中', projPaused: '一時停止', projBlocked: 'ブロック', projIdle: 'アイドル',
|
||||
projPendingTip: '{n} 件があなたの承認待ち',
|
||||
gAgents: 'グローバル Agent', gAgentsDetail: '{p}/{P} プロジェクト · {a} 個の agent',
|
||||
apActive: '実行中', apRuns: '回実行', apTokens: 'token', apWeek: '今週', apCost: 'コスト', apTotal: '全プロジェクト', apPerProj: 'プロジェクト別', apIdle: 'アイドル',
|
||||
gConfig: 'グローバル設定', gConfigDetail: '並列上限 {n} · daemon {v}',
|
||||
gUserPlan: 'プラン', gUserSignOut: 'ログアウト', gUserSettings: 'アカウント設定',
|
||||
gateExpand: '展開', gateCollapse: '折り畳む', gateDblTip: 'ダブルクリックで全画面',
|
||||
quota: 'クォータ', quotaWeek: '週', quotaReset: '{t} 後にリセット',
|
||||
projLogo: 'プロジェクトロゴ', logoPh: '画像 URL またはリポジトリ相対パス;空=自動', verifyCmd: '検証コマンド', verifyPh: 'npm test · go test ./...(任意)', model: 'モデル', modelPh: 'claude-opus-4-5(任意)',
|
||||
searchPh: '⌕ タイトルを検索…', clearFilter: '✕ フィルタをクリア', matchCount: '{n} 件一致',
|
||||
fgroups: { todo: '未着手', doing: '進行中', gate: '承認待ち', bad: '異常', hold: '保留', done: '完了' },
|
||||
fullRead: '全画面で読む', depsLabel: 'DEPS · 依存(全完了で実行可)', depDone: '完了', depWait: '待機',
|
||||
gateSection: '承認ゲート · 裁定待ち', accept: '✓ 承認', reject: '✕ 却下', confirmReject: '却下を確定',
|
||||
rejectPlaceholder: '改善フィードバック(必須)',
|
||||
taskSection: 'タスクツリー', newTask: '+ 新規タスク', titleLabel: 'タイトル', titlePlaceholder: '何をしますか',
|
||||
complexity: '複雑度', cplxAuto: '智能', parentTask: '親タスク', topLevel: '(トップ)', priority: '優先度',
|
||||
p0: 'P0 · 高', p1: 'P1 · 中', p2: 'P2 · 低', create: 'タスク作成', cancel: 'キャンセル',
|
||||
depsWait: '依存待ち', specLabel: 'SPEC · 変更案', opsLabel: 'OPERATIONS · 操作',
|
||||
pendingDoc: 'Claude Code の出力待ち(MCP 経由で書き込み後レビューへ)',
|
||||
emptyTasks: 'タスクなし — 「+ 新規タスク」または MCP で作成', empty: '空',
|
||||
events: 'イベント', expandEvents: 'イベントを開く', collapseEvents: 'イベントを畳む',
|
||||
toastCreated: 'タスク作成済み · init', toastAccepted: '承認済み · ', toastRejected: '却下 · 差し戻し',
|
||||
toastSynced: 'todo 同期完了 · 変更 0 件', toastSaved: '設定を保存しました', toastModal: 'プロトタイプ:新規モーダル未接続',
|
||||
gatePassed: ' 承認',
|
||||
status: { init: '新規', analyzing: '分析・分解中', plan_review: '分解確認待ち', decomposed: '分解済み', speccing: '方針作成中', spec_review: '方針確認待ち', ready: '実行可能', blocked: '依存ブロック', queued: 'キュー待ち', executing: '実行中', exec_review: 'レビュー/マージ', failed: '失敗', needs_attention: '要対応', done: '完了', paused: '一時停止', cancelled: 'キャンセル' },
|
||||
gates: { plan: '分解レビュー', spec: '方針レビュー', exec: '結果レビュー' },
|
||||
eventTypes: { 'task.created': 'タスク作成', 'task.updated': 'タスク更新', 'status.changed': 'ステータス変更', 'approval.requested': '承認リクエスト', 'approval.granted': '承認', 'approval.rejected': '却下', 'run.started': 'run 開始', 'run.finished': 'run 終了', 'project.synced': 'todo 同期' },
|
||||
},
|
||||
fr: {
|
||||
logoSub: 'ORCHESTRATEUR MULTI-PROJETS',
|
||||
projects: 'PROJETS', newProject: '+ Nouveau', connected: 'Connecté · :4517',
|
||||
expandSide: 'Déplier le panneau', collapseSide: 'Replier le panneau',
|
||||
badgeGate: 'à approuver', badgeReady: 'à exécuter', badgeRun: 'en cours', badgeBlocked: 'bloquées', badgeTotal: 'total',
|
||||
badgeGateTip: "Portes d'approbation en attente de décision", badgeReadyTip: 'Prêtes (deps OK) + en file',
|
||||
badgeRunTip: 'Agents en cours d’exécution', badgeBlockedTip: 'Bloquées (libérées quand les deps aboutissent)',
|
||||
badgeTotalTip: 'Tâches ouvertes (hors done / conteneurs / annulées)',
|
||||
sync: 'Synchroniser todo', lastSync: 'Dernière sync 06:12', config: 'Config', configTip: 'Config projet (concurrence / mode / sync todo)',
|
||||
archive: 'Archives', archiveTip: 'Cliquer pour le détail', archiveSubs: '{n} sous-tâches', archiveDetail: 'ARCHIVE',
|
||||
pagePrev: '‹ Préc.', pageNext: 'Suiv. ›', pageOf: 'Page {a} / {b}', perPage: 'Par page',
|
||||
attrsLabel: 'ATTRIBUTS', runsLabel: 'HISTORIQUE DES RUNS', resultLabel: 'RÉSULTAT', approvalsLabel: 'APPROBATIONS', timelineLabel: 'CHRONOLOGIE',
|
||||
branch: 'Branche', accepted: 'acceptée', rejected: 'rejetée', approver: 'Approbateur', opinion: 'Retour', closeTip: 'Fermer (Échap)',
|
||||
lang: 'Français', langTip: 'Changer de langue', themeLight: 'Clair', themeDark: 'Sombre', themeTip: 'Basculer le thème',
|
||||
maxConcurrency: 'Concurrence max', workMode: 'Mode', current: 'Actuel', save: 'Enregistrer', collapse: 'Fermer',
|
||||
autonomy: { manual: 'manual', 'auto-easy': 'auto · Easy', 'auto-approved': 'auto · approuv.' },
|
||||
agentSection: 'Exécutions agent', agentEmpty: 'Aucun agent en cours', running: 'RUNNING', since: '',
|
||||
projRunning: 'En cours', projPaused: 'En pause', projBlocked: 'Bloqué', projIdle: 'Inactif',
|
||||
projPendingTip: '{n} en attente de votre revue',
|
||||
gAgents: 'Agents globaux', gAgentsDetail: '{p}/{P} projets · {a} agents',
|
||||
apActive: 'actifs', apRuns: 'exécutions', apTokens: 'tokens', apWeek: 'cette semaine', apCost: 'coût', apTotal: 'Tous les projets', apPerProj: 'PAR PROJET', apIdle: 'inactif',
|
||||
gConfig: 'Config globale', gConfigDetail: 'Concurrence max {n} · daemon {v}',
|
||||
gUserPlan: 'Forfait', gUserSignOut: 'Se déconnecter', gUserSettings: 'Paramètres du compte',
|
||||
gateExpand: 'Déplier', gateCollapse: 'Replier', gateDblTip: 'Double-clic pour le plein écran',
|
||||
quota: 'Quota', quotaWeek: 'semaine', quotaReset: 'reset dans {t}',
|
||||
projLogo: 'Logo du projet', logoPh: 'URL ou chemin relatif ; vide = auto', verifyCmd: 'Commande de vérification', verifyPh: 'npm test · go test ./... (optionnel)', model: 'Modèle', modelPh: 'claude-opus-4-5 (optionnel)',
|
||||
searchPh: '⌕ Rechercher…', clearFilter: '✕ Effacer les filtres', matchCount: '{n} résultats',
|
||||
fgroups: { todo: 'À faire', doing: 'En cours', gate: 'À approuver', bad: 'Anomalies', hold: 'En attente', done: 'Terminées' },
|
||||
fullRead: 'Plein écran', depsLabel: 'DEPS (toutes requises)', depDone: 'terminée', depWait: 'en attente',
|
||||
gateSection: "Portes d'approbation · en attente", accept: '✓ Accepter', reject: '✕ Rejeter', confirmReject: 'Confirmer le rejet',
|
||||
rejectPlaceholder: "Retour d'amélioration (obligatoire)",
|
||||
taskSection: 'Arbre des tâches', newTask: '+ Nouvelle tâche', titleLabel: 'Titre', titlePlaceholder: 'Que faut-il faire',
|
||||
complexity: 'Complexité', cplxAuto: 'AUTO', parentTask: 'Parent', topLevel: '(racine)', priority: 'Priorité',
|
||||
p0: 'P0 · haute', p1: 'P1 · moyenne', p2: 'P2 · basse', create: 'Créer la tâche', cancel: 'Annuler',
|
||||
depsWait: 'deps', specLabel: 'SPEC DES CHANGEMENTS', opsLabel: 'OPÉRATIONS PRÉVUES',
|
||||
pendingDoc: 'En attente de la sortie de Claude Code (via MCP, puis revue)',
|
||||
emptyTasks: 'Aucune tâche — « + Nouvelle tâche » ou via MCP', empty: 'VIDE',
|
||||
events: 'Événements', expandEvents: 'Déplier les événements', collapseEvents: 'Replier les événements',
|
||||
toastCreated: 'Tâche créée · init', toastAccepted: 'Acceptée · ', toastRejected: 'Rejetée · renvoyée',
|
||||
toastSynced: 'Todo synchronisé · 0 changement', toastSaved: 'Config enregistrée', toastModal: 'Prototype : modale non câblée',
|
||||
gatePassed: ' approuvée',
|
||||
status: { init: 'Nouvelle', analyzing: 'Analyse', plan_review: 'Revue du plan', decomposed: 'Décomposée', speccing: 'Rédaction spec', spec_review: 'Revue de spec', ready: 'Prête', blocked: 'Bloquée', queued: 'En file', executing: 'En exécution', exec_review: 'Revue & fusion', failed: 'Échouée', needs_attention: 'À traiter', done: 'Terminée', paused: 'En pause', cancelled: 'Annulée' },
|
||||
gates: { plan: 'REVUE DU PLAN', spec: 'REVUE DE SPEC', exec: 'REVUE DU RÉSULTAT' },
|
||||
eventTypes: { 'task.created': 'Tâche créée', 'task.updated': 'Tâche mise à jour', 'status.changed': "Changement d'état", 'approval.requested': 'Approbation demandée', 'approval.granted': 'Approbation accordée', 'approval.rejected': 'Approbation rejetée', 'run.started': 'Run démarré', 'run.finished': 'Run terminé', 'project.synced': 'Todo synchronisé' },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
<!-- @dsCard group="Console" viewport="1440x900" name="Web 调度台" subtitle="三栏看板整屏复刻:项目 · 审批闸+任务树 · 事件流(可点击)" -->
|
||||
<!-- @startingPoint section="Screens" subtitle="Maestro Web 调度台整屏" viewport="1440x900" -->
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MAESTRO · 任务调度台</title>
|
||||
<link rel="stylesheet" href="../../styles.css">
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
<script src="../../_ds_bundle.js"></script>
|
||||
<script src="data.js"></script>
|
||||
<script src="i18n.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body { background: var(--bg); color: var(--ink); font-family: var(--mono); font-size: 13px; line-height: 1.55; overflow: hidden; }
|
||||
body::before {
|
||||
content: ''; position: fixed; inset: 0; z-index: 999; pointer-events: none;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(0,0,0,.16) 0 1px, transparent 1px 3px),
|
||||
radial-gradient(ellipse at 50% 40%, transparent 55%, rgba(0,0,0,.5));
|
||||
opacity: .5;
|
||||
}
|
||||
[data-theme="light"] body::before { opacity: .12; }
|
||||
@keyframes maestro-locate {
|
||||
0%, 35% { background: var(--amber-dim); box-shadow: inset 2px 0 0 var(--amber); }
|
||||
100% { background: transparent; box-shadow: none; }
|
||||
}
|
||||
::selection { background: var(--green-dim); color: #fff; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--line); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--green-dim); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel" src="Sidebar.jsx"></script>
|
||||
<script type="text/babel" src="EventPanel.jsx"></script>
|
||||
<script type="text/babel" src="Topbar.jsx"></script>
|
||||
<script type="text/babel" src="GateSection.jsx"></script>
|
||||
<script type="text/babel" src="TaskTree.jsx"></script>
|
||||
<script type="text/babel" src="ArchiveSection.jsx"></script>
|
||||
<script type="text/babel">
|
||||
const { Toast } = window.MaestroDesignSystem_a6a290;
|
||||
const M = window.MAESTRO_MOCK;
|
||||
const I18N = window.MAESTRO_I18N;
|
||||
|
||||
function App() {
|
||||
const [currentId, setCurrentId] = React.useState('p1');
|
||||
const [approvals, setApprovals] = React.useState(M.approvals);
|
||||
const [events, setEvents] = React.useState(M.events);
|
||||
const [showConfig, setShowConfig] = React.useState(false);
|
||||
const [archiveItem, setArchiveItem] = React.useState(null);
|
||||
const [toasts, setToasts] = React.useState([]);
|
||||
const [lang, setLang] = React.useState(() => {
|
||||
const saved = localStorage.getItem('maestro-kit-lang');
|
||||
return I18N[saved] ? saved : 'zh';
|
||||
});
|
||||
const [theme, setTheme] = React.useState(() => localStorage.getItem('maestro-kit-theme') || 'dark');
|
||||
const t = I18N[lang];
|
||||
React.useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
localStorage.setItem('maestro-kit-theme', theme);
|
||||
}, [theme]);
|
||||
React.useEffect(() => { localStorage.setItem('maestro-kit-lang', lang); }, [lang]);
|
||||
const [collapsed, setCollapsed] = React.useState(() => localStorage.getItem('maestro-kit-sidebar') === 'collapsed');
|
||||
const [evCollapsed, setEvCollapsed] = React.useState(() => localStorage.getItem('maestro-kit-events') === 'collapsed');
|
||||
const toggleEvents = () => setEvCollapsed((c) => {
|
||||
localStorage.setItem('maestro-kit-events', c ? 'open' : 'collapsed');
|
||||
return !c;
|
||||
});
|
||||
const toggleCollapse = () => setCollapsed((c) => {
|
||||
localStorage.setItem('maestro-kit-sidebar', c ? 'open' : 'collapsed');
|
||||
return !c;
|
||||
});
|
||||
// 栏宽可拖拽(带最小/最大约束,持久化);中栏保证 ≥ CENTER_MIN,避免被两侧挤压
|
||||
const SIDE_MIN = 200, SIDE_MAX = 420, EV_MIN = 240, EV_MAX = 520, CENTER_MIN = 480;
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const sideMax = () => Math.min(SIDE_MAX, window.innerWidth - (evCollapsed ? 52 : evW) - CENTER_MIN);
|
||||
const evMax = () => Math.min(EV_MAX, window.innerWidth - (collapsed ? 52 : sideW) - CENTER_MIN);
|
||||
const [sideW, setSideW] = React.useState(() => clamp(Number(localStorage.getItem('maestro-kit-sidew')) || 232, SIDE_MIN, SIDE_MAX));
|
||||
const [evW, setEvW] = React.useState(() => clamp(Number(localStorage.getItem('maestro-kit-evw')) || 320, EV_MIN, EV_MAX));
|
||||
const [dragging, setDragging] = React.useState(null);
|
||||
React.useEffect(() => { localStorage.setItem('maestro-kit-sidew', sideW); }, [sideW]);
|
||||
React.useEffect(() => { localStorage.setItem('maestro-kit-evw', evW); }, [evW]);
|
||||
const startDrag = (which) => (e) => {
|
||||
e.preventDefault();
|
||||
setDragging(which);
|
||||
const startX = e.clientX;
|
||||
const startW = which === 'side' ? sideW : evW;
|
||||
const onMove = (ev) => {
|
||||
if (which === 'side') setSideW(clamp(startW + (ev.clientX - startX), SIDE_MIN, Math.max(SIDE_MIN, sideMax())));
|
||||
else setEvW(clamp(startW - (ev.clientX - startX), EV_MIN, Math.max(EV_MIN, evMax())));
|
||||
};
|
||||
const onUp = () => {
|
||||
setDragging(null);
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
document.body.style.userSelect = 'none';
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
};
|
||||
const Resizer = ({ which }) => (
|
||||
<div onPointerDown={startDrag(which)} title="拖拽调整宽度"
|
||||
style={{
|
||||
position: 'absolute', top: 0, bottom: 0, width: 9, cursor: 'col-resize', zIndex: 40,
|
||||
...(which === 'side' ? { left: sideW - 4 } : { right: evW - 4 }),
|
||||
display: 'flex', justifyContent: 'center',
|
||||
}}>
|
||||
<span style={{ width: 1, background: dragging === which ? 'var(--green)' : 'var(--line-soft)', boxShadow: dragging === which ? '0 0 8px var(--green)' : 'none', transition: 'background .12s' }}></span>
|
||||
</div>
|
||||
);
|
||||
const project = M.projects.find((p) => p.id === currentId);
|
||||
const isMain = currentId === 'p1';
|
||||
const tasks = isMain ? M.tasks : [];
|
||||
const agents = isMain ? M.agents : [];
|
||||
const gates = isMain ? approvals : [];
|
||||
|
||||
const toast = (kind, text) => {
|
||||
const id = Date.now();
|
||||
setToasts((t) => [...t, { id, kind, text }]);
|
||||
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2600);
|
||||
};
|
||||
const now = () => new Date().toTimeString().slice(0, 8);
|
||||
const decide = (a, action, reason) => {
|
||||
setApprovals((list) => list.filter((x) => x.id !== a.id));
|
||||
if (action === 'accept') {
|
||||
setEvents((ev) => [{ type: 'approval.granted', time: now(), detail: a.title + ' · ' + a.gate + '_review' + t.gatePassed }, ...ev]);
|
||||
toast('ok', t.toastAccepted + a.title);
|
||||
} else {
|
||||
setEvents((ev) => [{ type: 'approval.rejected', time: now(), detail: a.title + ' ↳ ' + reason }, ...ev]);
|
||||
toast('warn', t.toastRejected);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', display: 'grid', gridTemplateColumns: (collapsed ? '52px' : sideW + 'px') + ' minmax(0,1fr) ' + (evCollapsed ? '52px' : evW + 'px'), height: '100vh' }}>
|
||||
{!collapsed ? <Resizer which="side" /> : null}
|
||||
{!evCollapsed ? <Resizer which="ev" /> : null}
|
||||
<window.MaestroKitSidebar projects={M.projects} currentId={currentId} t={t}
|
||||
global={M.global} user={M.user} summary={M.agentSummary} onGlobalConfig={() => toast('warn', t.toastModal)}
|
||||
collapsed={collapsed} onToggleCollapse={toggleCollapse}
|
||||
onSelect={setCurrentId} onNewProject={() => toast('warn', t.toastModal)} />
|
||||
<main style={{ overflowY: 'auto', padding: '0 22px 60px' }}>
|
||||
<window.MaestroKitTopbar project={project} t={t} theme={theme}
|
||||
onToggleTheme={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
||||
lang={lang} onSelectLang={setLang}
|
||||
counts={{ gate: gates.length, ready: isMain ? 1 : 0, run: agents.length, blocked: isMain ? 1 : 0, total: isMain ? 5 : 0 }}
|
||||
onSync={() => toast('ok', t.toastSynced)}
|
||||
onToggleConfig={() => setShowConfig(!showConfig)} />
|
||||
{showConfig ? <window.MaestroKitConfigPanel project={project} t={t}
|
||||
onSave={() => { setShowConfig(false); toast('ok', t.toastSaved); }}
|
||||
onSync={() => toast('ok', t.toastSynced)}
|
||||
onClose={() => setShowConfig(false)} /> : null}
|
||||
<window.MaestroKitAgentSection agents={agents} quota={isMain ? M.quota : null} t={t} />
|
||||
<window.MaestroKitGateSection approvals={gates} onDecide={decide} t={t} />
|
||||
{tasks.length ? (
|
||||
<window.MaestroKitTaskSection tasks={tasks} onToast={toast} t={t} />
|
||||
) : (
|
||||
<div style={{ padding: '46px 0', textAlign: 'center', color: 'var(--faint)', border: '1px dashed var(--line)', borderRadius: 6, marginTop: 18 }}>
|
||||
<b style={{ display: 'block', fontSize: 15, color: 'var(--muted)', marginBottom: 6, letterSpacing: '.2em' }}>{t.empty}</b>
|
||||
{t.emptyTasks}
|
||||
</div>
|
||||
)}
|
||||
{isMain ? <window.MaestroKitArchiveSection items={M.archived} t={t} onOpen={setArchiveItem} /> : null}
|
||||
</main>
|
||||
<window.MaestroKitEventPanel events={events} collapsed={evCollapsed} onToggleCollapse={toggleEvents} t={t} />
|
||||
{archiveItem ? <window.MaestroKitArchiveModal item={archiveItem} t={t} onClose={() => setArchiveItem(null)} /> : null}
|
||||
<div style={{ position: 'fixed', bottom: 18, left: '50%', transform: 'translateX(-50%)', zIndex: 1200, display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
|
||||
{toasts.map((t) => <Toast key={t.id} kind={t.kind}>{t.text}</Toast>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user