feat(console): 任务树筛选/折叠/统一标题 + 侧栏全局Agent卡片/拖拽排序/全局设置弹框/记忆项目 + 状态点真实化

任务树:四维多选下拉筛选(状态/复杂度/优先级/执行者)、已选置顶、新建任务按钮移右上角(0任务也能建)。
四块主区(Agent/审批闸/任务树/归档)统一标题组件(图标+名称+数量徽标)+ 折叠按钮(localStorage 记忆)。
侧栏:全局 Agent 弹层改常驻卡片(项目默认3+折叠+按活跃排序)、顶部项目拖拽排序、全局设置移入用户菜单弹框(保存/取消,落库)、记忆当前项目。
adaptProject 用后端 summary 的 running/attention 判状态点颜色(青/琥珀/灰)+ 紫色关注数徽标;agent 标题不再越界;i18n 5 语言补全。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-29 11:30:32 +08:00
parent 43fa1f7dbb
commit 8765e61498
9 changed files with 430 additions and 194 deletions
+175 -75
View File
@@ -1,12 +1,63 @@
// 任务树:筛选栏(搜索+复杂度+状态分组)+ 折叠层级 + 复杂度可点换档 + 依赖可视化跳转 + 展开详情;新建任务表单
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']],
// 状态全序(下拉选项顺序:待办 → 进行 → 容器 → 审批 → 异常 → 挂起 → 终态)
const KIT_STATUS_ORDER = [
'init', 'ready', 'blocked',
'analyzing', 'speccing', 'queued', 'executing', 'decomposed',
'plan_review', 'spec_review', 'exec_review',
'failed', 'needs_attention',
'paused', 'done', 'cancelled',
];
const KIT_CPLX_OPTS = [{ value: 'hard', label: 'HARD' }, { value: 'medium', label: 'MED' }, { value: 'easy', label: 'EASY' }];
const KIT_PRIO_OPTS = [{ value: 'P0', label: 'P0' }, { value: 'P1', label: 'P1' }, { value: 'P2', label: 'P2' }];
// 主显示区各区块标题通用折叠按钮(▾ 展开 / ▸ 折叠);暴露到 window 供 Agent/审批闸/归档复用
function KitFoldBtn({ folded, onClick, t }) {
return (
<button type="button" onClick={onClick} title={folded ? (t.gateExpand || '展开') : (t.gateCollapse || '折叠')} style={{
background: 'transparent', border: '1px solid var(--line)', color: 'var(--muted)', cursor: 'pointer',
fontSize: 10, fontFamily: 'var(--mono)', lineHeight: 1, padding: '3px 8px', borderRadius: 'var(--radius-sm,4px)',
}}
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)'; }}>
{folded ? '▸' : '▾'}
</button>
);
}
window.MaestroKitFoldBtn = KitFoldBtn;
// 区块图标集(24 网格 / stroke currentColor,继承标题 accent 色)
function KitIcon({ name }) {
const p = { width: 15, height: 15, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', style: { flex: 'none', display: 'block' } };
if (name === 'agent') return <svg {...p}><polyline points="3 12 7 12 10 5 14 19 17 12 21 12" /></svg>;
if (name === 'gate') return <svg {...p}><path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6z" /><path d="M9 12l2 2 4-4" /></svg>;
if (name === 'tasks') return <svg {...p}><path d="M9 6h11M9 12h11M9 18h11" /><path d="M4.5 6h.01M4.5 12h.01M4.5 18h.01" /></svg>;
if (name === 'archive') return <svg {...p}><rect x="3" y="4" width="18" height="4" rx="1" /><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8" /><path d="M10 12h4" /></svg>;
return null;
}
// 统一区块标题:图标 + 名称 + 数量徽标 + 折叠按钮(四块主显示区共用,视觉一致)
function KitSectionHead({ icon, title, count, accent = 'var(--green)', folded, onToggleFold, t, sticky }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 9,
fontFamily: 'var(--mono)', fontSize: 11, fontWeight: 600, letterSpacing: '.2em',
color: 'var(--muted)', textTransform: 'uppercase', padding: '16px 0 10px',
...(sticky ? { position: 'sticky', top: 0, zIndex: 5, background: 'linear-gradient(var(--bg) 78%, transparent)' } : {}),
}}>
<span style={{ color: accent, flex: 'none', display: 'inline-flex', alignItems: 'center' }}><KitIcon name={icon} /></span>
<span style={{ whiteSpace: 'nowrap' }}>{title}</span>
{count != null ? (
<span style={{
flex: 'none', fontFamily: 'var(--mono)', fontSize: 10, fontWeight: 700, letterSpacing: '.02em',
color: accent, background: 'var(--panel-2)', borderRadius: 10, padding: '1px 7px', lineHeight: '15px',
minWidth: 10, textAlign: 'center',
}}>{count}</span>
) : null}
<span style={{ marginLeft: 'auto', flex: 'none' }}><KitFoldBtn folded={folded} onClick={onToggleFold} t={t} /></span>
</div>
);
}
window.MaestroKitSectionHead = KitSectionHead;
function kitFlatten(tasks, map = new Map()) {
for (const tk of tasks) { map.set(tk.id, tk); if (tk.children) kitFlatten(tk.children, map); }
@@ -53,49 +104,94 @@ function CplxPicker({ task, cplx, onChange }) {
);
}
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>
);
// 多选下拉:触发器显示「标签 ·N」,菜单内勾选切换(不随单击关闭);点外 / Esc 关闭
function KitMultiSelect({ label, options, selected, onToggle, onClear, accent = 'var(--green)' }) {
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); };
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); };
}, [open]);
const n = selected.size;
// 已选项统一排到菜单最前(打开时按「选中优先」重排,保持各自原相对顺序)
const ordered = open
? [...options.filter((o) => selected.has(o.value)), ...options.filter((o) => !selected.has(o.value))]
: options;
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 ref={ref} style={{ position: 'relative', display: 'inline-flex', flex: 'none' }}>
<button type="button" onClick={() => setOpen(!open)} style={{
fontFamily: 'var(--mono)', fontSize: 11.5, letterSpacing: '.03em',
background: n ? 'rgba(95,221,125,.08)' : 'var(--bg-deep)',
color: n ? accent : 'var(--muted)',
border: '1px solid ' + (n ? 'var(--green-dim)' : 'var(--line)'),
borderRadius: 'var(--radius-sm, 4px)', padding: '5px 9px', cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap', transition: 'all .1s',
}}>
<span>{label}</span>
{n ? <span style={{
fontSize: 9.5, fontWeight: 700, background: accent, color: 'var(--bg-deep)',
borderRadius: 8, padding: '0 5px', lineHeight: '14px', minWidth: 8, textAlign: 'center',
}}>{n}</span> : null}
<span style={{ fontSize: 8, color: 'var(--faint)', transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .12s' }}></span>
</button>
{open ? (
<span style={{
position: 'absolute', top: 'calc(100% + 5px)', left: 0, zIndex: 80, minWidth: 152,
display: 'grid', gap: 1, 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', maxHeight: 320, overflowY: 'auto',
}}>
{n ? (
<button type="button" onClick={onClear} style={{
fontFamily: 'var(--mono)', fontSize: 11, textAlign: 'left', background: 'transparent',
color: 'var(--faint)', border: 'none', borderRadius: 4, padding: '5px 8px', cursor: 'pointer',
marginBottom: 2, borderBottom: '1px solid var(--line)',
}}> 清空</button>
) : null}
{ordered.map((o) => {
const on = selected.has(o.value);
return (
<button type="button" key={o.value} onClick={() => onToggle(o.value)} style={{
fontFamily: 'var(--mono)', fontSize: 12, textAlign: 'left',
background: on ? 'var(--panel-2)' : 'transparent',
color: on ? accent : 'var(--ink)', fontWeight: on ? 700 : 400,
border: 'none', borderRadius: 4, padding: '6px 9px 6px 7px', cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 7, whiteSpace: 'nowrap',
}}
onMouseEnter={(e) => { if (!on) e.currentTarget.style.background = 'var(--panel)'; }}
onMouseLeave={(e) => { if (!on) e.currentTarget.style.background = 'transparent'; }}>
<span style={{ flex: 'none', width: 10, color: accent, fontSize: 10 }}>{on ? '✓' : ''}</span>{o.label}
</button>
);
})}
</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>
) : null}
</span>
);
}
function FilterBar({ t, kw, setKw, sets, toggle, clearOne, matchCount, filtering, onClear }) {
const { Button } = window.MaestroDesignSystem_a6a290;
const statusOpts = KIT_STATUS_ORDER.map((s) => ({ value: s, label: t.status[s] }));
const assigneeOpts = [{ value: 'agent', label: t.assignees.agent }, { value: 'human', label: t.assignees.human }];
return (
<div style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 6, padding: '8px 10px', marginBottom: 12, 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: 180, padding: '5px 9px',
background: 'var(--bg-deep)', color: 'var(--ink)', border: '1px solid var(--line)',
borderRadius: 'var(--radius-sm, 4px)', outline: 'none',
}} />
<KitMultiSelect label={t.fStatus} options={statusOpts} selected={sets.status} onToggle={(v) => toggle('status', v)} onClear={() => clearOne('status')} />
<KitMultiSelect label={t.complexity} options={KIT_CPLX_OPTS} selected={sets.cplx} onToggle={(v) => toggle('cplx', v)} onClear={() => clearOne('cplx')} accent="var(--amber)" />
<KitMultiSelect label={t.priority} options={KIT_PRIO_OPTS} selected={sets.prio} onToggle={(v) => toggle('prio', v)} onClear={() => clearOne('prio')} accent="var(--red)" />
<KitMultiSelect label={t.fAssignee} options={assigneeOpts} selected={sets.assignee} onToggle={(v) => toggle('assignee', v)} onClear={() => clearOne('assignee')} accent="var(--violet)" />
<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>
);
}
@@ -311,13 +407,15 @@ function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
}
function TaskSection({ tasks, onToast, onCreate, onTakeover, taskOps, 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 [folded, setFolded] = React.useState(() => localStorage.getItem('maestro-kit-fold-tasks') === '1');
const toggleFold = () => setFolded((f) => { localStorage.setItem('maestro-kit-fold-tasks', f ? '0' : '1'); return !f; });
const [kw, setKw] = React.useState('');
const [cplxSet, setCplxSet] = React.useState(() => new Set());
const [statusSet, setStatusSet] = React.useState(() => new Set());
const [cplxSet, setCplxSet] = React.useState(() => new Set());
const [prioSet, setPrioSet] = React.useState(() => new Set());
const [assigneeSet, setAssigneeSet] = React.useState(() => new Set());
const [cplxOverride, setCplxOverride] = React.useState({});
const [flashId, setFlashId] = React.useState(null);
const byId = React.useMemo(() => kitFlatten(tasks), [tasks]);
@@ -329,25 +427,25 @@ function TaskSection({ tasks, onToast, onCreate, onTakeover, taskOps, t }) {
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) => {
// 四维多选筛选:status / cplx / prio / assignee
const setters = { status: setStatusSet, cplx: setCplxSet, prio: setPrioSet, assignee: setAssigneeSet };
const sets = { status: statusSet, cplx: cplxSet, prio: prioSet, assignee: assigneeSet };
const toggle = (key, v) => setters[key]((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 clearOne = (key) => setters[key](new Set());
const filtering = kw.trim() !== '' || statusSet.size > 0 || cplxSet.size > 0 || prioSet.size > 0 || assigneeSet.size > 0;
const clearFilters = () => { setKw(''); setStatusSet(new Set()); setCplxSet(new Set()); setPrioSet(new Set()); setAssigneeSet(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())) &&
(statusSet.size === 0 || statusSet.has(tk.status)) &&
(cplxSet.size === 0 || cplxSet.has(cplxOverride[tk.id] || tk.cplx)) &&
(statusSet.size === 0 || statusSet.has(tk.status));
(prioSet.size === 0 || prioSet.has(tk.prio)) &&
(assigneeSet.size === 0 || assigneeSet.has((tk._raw && tk._raw.assignee) || ''));
const vis = new Set(); const ctx = new Set(); let count = 0;
const walk = (tk) => {
let childHit = false;
@@ -360,7 +458,7 @@ function TaskSection({ tasks, onToast, onCreate, onTakeover, taskOps, t }) {
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]);
}, [filtering, kw, statusSet, cplxSet, prioSet, assigneeSet, tasks, cplxOverride]);
// 依赖跳转:展开所有祖先 + flash 定位
const onJump = (id) => {
@@ -384,20 +482,22 @@ function TaskSection({ tasks, onToast, onCreate, onTakeover, taskOps, t }) {
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={(payload) => { setShowNew(false); onCreate ? onCreate(payload) : 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} onTakeover={onTakeover} taskOps={taskOps} />
))}
</div>
<KitSectionHead icon="tasks" title={t.taskSection} count={byId.size} accent="var(--green)" folded={folded} onToggleFold={toggleFold} t={t} sticky />
{!folded ? (
<React.Fragment>
<FilterBar t={t} kw={kw} setKw={setKw} sets={sets} toggle={toggle} clearOne={clearOne}
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} onTakeover={onTakeover} taskOps={taskOps} />
))}
</div>
</React.Fragment>
) : null}
</section>
);
}
Object.assign(window, { MaestroKitTaskSection: TaskSection });
Object.assign(window, { MaestroKitTaskSection: TaskSection, MaestroKitNewTaskPanel: NewTaskPanel });