// 任务树:筛选栏(搜索+复杂度+状态分组)+ 折叠层级 + 复杂度可点换档 + 依赖可视化跳转 + 展开详情;新建任务表单 // 状态全序(下拉选项顺序:待办 → 进行 → 容器 → 审批 → 异常 → 挂起 → 终态) 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' }]; // 与后端 src/api/server.ts 的 SAFE_INLINE 一字不差:只有这些类型后端才 inline 回原图, // 其余(尤其 image/svg+xml、text/html)强制 attachment+octet-stream 下载。前端缩略图必须对齐同一硬白名单, // 否则对 svg 用 会拿到 octet-stream 出坏图,且策略漂移时埋同源存储型 XSS 隐患。 const KIT_ATT_SAFE_INLINE = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); // 主显示区各区块标题通用折叠按钮(▾ 展开 / ▸ 折叠);暴露到 window 供 Agent/审批闸/归档复用 function KitFoldBtn({ folded, onClick, t }) { return ( ); } 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 ; if (name === 'gate') return ; if (name === 'tasks') return ; if (name === 'archive') return ; return null; } // 统一区块标题:图标 + 名称 + 数量徽标 + 折叠按钮(四块主显示区共用,视觉一致) function KitSectionHead({ icon, title, count, accent = 'var(--green)', folded, onToggleFold, t, sticky }) { return (
{title} {count != null ? ( {count} ) : null}
); } 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); } 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 ( e.stopPropagation()}> {open ? ( {['hard', 'medium', 'easy'].map((v) => ( ))} ) : null} ); } // 多选下拉:触发器显示「标签 ·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 ( {open ? ( {n ? ( ) : null} {ordered.map((o) => { const on = selected.has(o.value); return ( ); })} ) : null} ); } 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 (
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', }} /> toggle('status', v)} onClear={() => clearOne('status')} /> toggle('cplx', v)} onClear={() => clearOne('cplx')} accent="var(--amber)" /> toggle('prio', v)} onClear={() => clearOne('prio')} accent="var(--red)" /> toggle('assignee', v)} onClear={() => clearOne('assignee')} accent="var(--violet)" /> {filtering ? {t.matchCount.replace('{n}', matchCount)} : null} {filtering ? : null}
); } function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleOpen, t, byId, cplxOf, onChangeCplx, flashId, onJump, visibleSet, onTakeover, taskOps }) { 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 (
0 ? { borderLeft: '1px solid var(--line-soft)' } : null}>
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', }}> { 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 ? '▶' : '·'} {task.title} {task.id} {task.deps ? ( {t.depsWait} {task.deps.length} ) : null} {task.prio} {task.frozen ? : }
{expanded ? : null} {kids.length && open ? (
{kids.map((k) => ( ))}
) : null}
); } function TaskDetail({ task, t, byId, onJump, onTakeover, taskOps }) { ensureNewTaskCss(); const { Button, Input, Select } = window.MaestroDesignSystem_a6a290; const label = task.doc ? t.specLabel : task.ops ? t.opsLabel : null; const text = task.doc || task.ops; const attachments = (task._raw && task._raw.attachments) || []; const ops = taskOps || {}; const st = task.status; const canCancel = !['done', 'cancelled'].includes(st); const canRequeue = ['failed', 'needs_attention', 'blocked', 'cancelled'].includes(st); const [editing, setEditing] = React.useState(false); const [eTitle, setETitle] = React.useState(task.title); const [ePrio, setEPrio] = React.useState(String((task.prio || 'P1').replace('P', ''))); const [eDeps, setEDeps] = React.useState(task.deps || []); const btn = (color) => ({ fontFamily: 'var(--mono)', fontSize: 12, cursor: 'pointer', background: 'transparent', color: `var(--${color})`, border: `1px solid var(--${color}-dim)`, borderRadius: 'var(--radius-sm,4px)', padding: '3px 10px' }); const candidates = [...byId.values()].filter((x) => x.id !== task.id); const toggleDep = (id) => setEDeps((d) => (d.includes(id) ? d.filter((x) => x !== id) : [...d, id])); const saveEdit = () => { if (ops.patch) ops.patch(task.id, { title: eTitle.trim(), priority: Number(ePrio), deps: eDeps }); setEditing(false); }; return (
{/* 操作工具条:编辑 / 接管 / 重投 / 取消 / 删除 */}
e.stopPropagation()}> {ops.patch ? : null} {onTakeover ? : null} {ops.requeue && canRequeue ? : null} {ops.cancel && canCancel ? : null} {ops.del ? : null}
{/* 附件画廊:图片缩略图(点击新标签预览)+ 下载 + 删除(确认后调用 taskOps.delAttachment) */} {attachments.length ? (
e.stopPropagation()}>
{t.attSection} · {attachments.length}
{attachments.map((a) => { const dn = attDiskName(a); const isImg = KIT_ATT_SAFE_INLINE.has(a.type || ''); const url = attUrl(task.id, dn); return (
{isImg ? {a.name} : {(a.name.includes('.') ? a.name.split('.').pop() : (a.type || 'file').split('/').pop() || 'file').toUpperCase()}} {a.name} ↓ {t.attDownload} {ops.delAttachment ? ( ) : null}
); })}
) : null} {/* 编辑表单 */} {editing ? (
e.stopPropagation()}>
); } function TaskSection({ tasks, onToast, onCreate, onTakeover, taskOps, t }) { const [expandedId, setExpandedId] = React.useState(null); const [openIds, setOpenIds] = React.useState(() => new Set(['t1'])); 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 [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]); const cplxOf = (task) => cplxOverride[task.id] || task.cplx; const onChangeCplx = (id, v) => { setCplxOverride((m) => ({ ...m, [id]: v })); // 乐观显示 if (taskOps && taskOps.patch) taskOps.patch(id, { complexity: v }); // 落库 }; const toggleOpen = (id) => setOpenIds((prev) => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); // 四维多选筛选: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 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)) && (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; 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, statusSet, cplxSet, prioSet, assigneeSet, 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 (
{!folded ? (
{roots.map((tk) => ( ))}
) : null}
); } Object.assign(window, { MaestroKitTaskSection: TaskSection, MaestroKitNewTaskPanel: NewTaskPanel });