Files
maestro/design/ui_kits/console/TaskTree.jsx
T

616 lines
36 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 任务树:筛选栏(搜索+复杂度+状态分组)+ 折叠层级 + 复杂度可点换档 + 依赖可视化跳转 + 展开详情;新建任务表单
// 状态全序(下拉选项顺序:待办 → 进行 → 容器 → 审批 → 异常 → 挂起 → 终态)
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: 20, background: 'var(--bg)', boxShadow: '0 1px 0 var(--line-soft), 0 6px 10px -8px rgba(0,0,0,.6)' } : {}),
}}>
<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); }
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>
);
}
// 多选下拉:触发器显示「标签 ·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 (
<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>
) : 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>
);
}
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 (
<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} />
{task.frozen
? <StatusChip status="paused" label={t.frozenPending} />
: <StatusChip status={task.status} label={t.status[task.status]} />}
</div>
{expanded ? <TaskDetail task={task} t={t} byId={byId} onJump={onJump} onTakeover={onTakeover} taskOps={taskOps} /> : 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} onTakeover={onTakeover} taskOps={taskOps} />
))}
</div>
) : null}
</div>
);
}
function TaskDetail({ task, t, byId, onJump, onTakeover, taskOps }) {
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 (
<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 }}>
{/* 操作工具条:编辑 / 接管 / 重投 / 取消 / 删除 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }} onClick={(e) => e.stopPropagation()}>
{ops.patch ? <button onClick={() => setEditing(!editing)} style={btn(editing ? 'green' : 'muted')}> 编辑</button> : null}
{onTakeover ? <button onClick={() => onTakeover(task)} style={btn('cyan')}> 人工接管</button> : null}
{ops.requeue && canRequeue ? <button onClick={() => ops.requeue(task.id)} style={btn('green')}> 重投</button> : null}
{ops.cancel && canCancel ? <button onClick={() => ops.cancel(task.id)} style={btn('amber')}> 取消</button> : null}
{ops.del ? <button onClick={() => { if (confirm('删除任务「' + task.title + '」?子任务一并删除,不可恢复。')) ops.del(task.id); }} style={btn('red')}>🗑 删除</button> : null}
{attachments.length ? <span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--faint)' }}>📎 {attachments.map((a) => a.name).join(' · ')}</span> : null}
</div>
{/* 编辑表单 */}
{editing ? (
<div style={{ display: 'grid', gap: 12, padding: 12, border: '1px solid var(--line)', borderRadius: 6, background: 'var(--bg-deep)' }} onClick={(e) => e.stopPropagation()}>
<Input label="标题" value={eTitle} onChange={setETitle} style={{ width: '100%' }} />
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<Select label={t.priority} value={ePrio} onChange={setEPrio} options={[{ value: '0', label: t.p0 }, { value: '1', label: t.p1 }, { value: '2', label: t.p2 }]} />
</div>
{candidates.length ? (
<div>
<div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.12em', marginBottom: 6 }}>依赖点选其他任务</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 120, overflowY: 'auto' }}>
{candidates.map((c) => {
const on = eDeps.includes(c.id);
return (
<button key={c.id} onClick={() => toggleDep(c.id)}
style={{ fontFamily: 'var(--mono)', fontSize: 11, cursor: 'pointer', padding: '2px 8px', borderRadius: 3, border: '1px solid ' + (on ? 'var(--green-dim)' : 'var(--line)'), background: on ? 'var(--green-dim)' : 'transparent', color: on ? '#fff' : 'var(--muted)', maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{on ? '✓ ' : ''}{c.title}
</button>
);
})}
</div>
</div>
) : null}
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<Button onClick={() => setEditing(false)}>{t.cancel}</Button>
<Button variant="solid" disabled={!eTitle.trim()} onClick={saveEdit}>{t.save}</Button>
</div>
</div>
) : null}
{label ? (
<div>
<div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 4 }}>{label}</div>
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', padding: '10px 14px', borderRadius: 4 }}><window.MaestroRichDoc md={text} /></div>
</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>
);
}
// 新建任务面板的附件区样式(dropzone 高亮 / 缩略图 chip / 删除角标),一次性注入避免污染全局
function ensureNewTaskCss() {
if (document.getElementById('maestro-kit-newtask-css')) return;
const s = document.createElement('style');
s.id = 'maestro-kit-newtask-css';
s.textContent = `
.m-nt-drop { display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px dashed var(--line); border-radius: var(--radius-sm, 4px); background: var(--bg-deep); transition: border-color .12s, background .12s; }
.m-nt-drop.is-over { border-color: var(--green-dim); background: var(--panel-2); }
.m-nt-hint { font-size: 10.5px; color: var(--faint); letter-spacing: .04em; }
.m-nt-hint.is-over { color: var(--green); }
.m-nt-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.m-nt-chip { position: relative; width: 92px; display: flex; flex-direction: column; gap: 3px; }
.m-nt-thumb { width: 92px; height: 64px; border: 1px solid var(--line); border-radius: var(--radius-sm, 4px); background: var(--panel-2); overflow: hidden; display: flex; align-items: center; justify-content: center; }
.m-nt-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.m-nt-ext { font-family: var(--mono); font-size: 12px; color: var(--cyan); text-transform: uppercase; letter-spacing: .06em; }
.m-nt-name { font-size: 10px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.m-nt-size { font-size: 9.5px; color: var(--faint); font-family: var(--mono); }
.m-nt-del { position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; padding: 0; line-height: 1; border: 1px solid var(--line); border-radius: 50%; background: var(--panel); color: var(--muted); cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; transition: color .12s, border-color .12s, background .12s; }
.m-nt-del:hover { color: var(--red); border-color: var(--red-dim); background: var(--panel-2); }
`;
document.head.appendChild(s);
}
// 三来源(选择/粘贴/拖拽)统一去重键:name+size+lastModified
function ntFileKey(f) { return (f.name || '') + '' + f.size + '' + f.lastModified; }
// 由 MIME 推断扩展名(剪贴板图片常无文件名)
function ntExtFromType(type) {
if (!type) return '';
const sub = String(type).split('/')[1] || '';
const m = { jpeg: 'jpg', svg: 'svg', 'svg+xml': 'svg' };
return sub ? '.' + (m[sub] || sub) : '';
}
// 为无名文件(粘贴的截图)补一个稳定文件名
function ntNamed(f, i) {
if (f.name) return f;
const name = 'pasted-' + Date.now() + (i ? '-' + i : '') + ntExtFromType(f.type);
try { return new File([f], name, { type: f.type, lastModified: f.lastModified }); } catch { return f; }
}
// chip 角标显示的扩展名文字
function ntExtLabel(f) {
const fromName = f.name && f.name.includes('.') ? f.name.split('.').pop() : '';
return (fromName || ntExtFromType(f.type).replace(/^\./, '') || 'file').toUpperCase();
}
function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
ensureNewTaskCss();
const { Button, Input, Select, ComplexitySeg } = window.MaestroDesignSystem_a6a290;
const [title, setTitle] = React.useState('');
const [complexity, setComplexity] = React.useState('auto');
const [priority, setPriority] = React.useState('1');
const [parentId, setParentId] = React.useState('');
const [files, setFiles] = React.useState([]);
const [dragOver, setDragOver] = React.useState(false);
// 图片缩略图 objectURL 缓存(按去重键),卸载/移除时显式 revoke 防泄漏
const urlMapRef = React.useRef(new Map());
const thumbUrl = (f) => {
if (!f.type || !f.type.startsWith('image/')) return null;
const key = ntFileKey(f);
const map = urlMapRef.current;
if (!map.has(key)) map.set(key, URL.createObjectURL(f));
return map.get(key);
};
// 组件卸载时释放全部 objectURL
React.useEffect(() => () => {
urlMapRef.current.forEach((u) => URL.revokeObjectURL(u));
urlMapRef.current.clear();
}, []);
// 合并新文件并按复合键去重(剪贴板无名图先补名)
const addFiles = (incoming) => {
if (!incoming || !incoming.length) return;
const named = Array.from(incoming).map((f, i) => ntNamed(f, i));
setFiles((prev) => {
const seen = new Set(prev.map(ntFileKey));
const merged = prev.slice();
named.forEach((f) => { const k = ntFileKey(f); if (!seen.has(k)) { seen.add(k); merged.push(f); } });
return merged;
});
};
const removeFile = (key) => {
setFiles((prev) => prev.filter((f) => ntFileKey(f) !== key));
const map = urlMapRef.current;
if (map.has(key)) { URL.revokeObjectURL(map.get(key)); map.delete(key); }
};
const onPaste = (e) => {
const items = (e.clipboardData && e.clipboardData.items) || [];
const imgs = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === 'file' && it.type && it.type.startsWith('image/')) {
const f = it.getAsFile();
if (f) imgs.push(f);
}
}
if (imgs.length) { e.preventDefault(); addFiles(imgs); }
};
const submit = (e) => {
e.preventDefault();
if (!title.trim()) return;
onCreate({ title: title.trim(), complexity, priority: Number(priority), parentId: parentId || null, files });
};
// 扁平化任务树供父任务下拉(含子任务)
const flat = [];
const walk = (list, prefix) => (list || []).forEach((tk) => { flat.push({ value: tk.id, label: prefix + tk.title }); walk(tk.children, prefix + '— '); });
walk(tasks, '');
return (
<form style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 6, padding: 14, marginBottom: 14 }}
onSubmit={submit} onPaste={onPaste}>
<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} value={title} onChange={setTitle} 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} onChange={setComplexity} />
</label>
<Select label={t.priority} defaultValue="1" onChange={setPriority} 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%' }} onChange={setParentId} options={[{ value: '', label: t.topLevel }, ...flat]} />
</span>
</div>
<div style={{ marginTop: 12 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 11, color: 'var(--muted)', letterSpacing: '.08em' }}>
<span>{t.attachLabel}</span>
<div className={'m-nt-drop' + (dragOver ? ' is-over' : '')}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragEnter={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false); }}
onDrop={(e) => { e.preventDefault(); addFiles(e.dataTransfer.files); setDragOver(false); }}>
<input type="file" multiple
onChange={(e) => { addFiles(e.target.files); e.target.value = ''; }}
style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--ink)' }} />
<span className={'m-nt-hint' + (dragOver ? ' is-over' : '')}>{dragOver ? t.dropHint : t.pasteHint}</span>
{files.length ? (
<div className="m-nt-grid">
{files.map((f) => {
const key = ntFileKey(f);
const url = thumbUrl(f);
return (
<div key={key} className="m-nt-chip">
<button type="button" className="m-nt-del" title={t.removeFile}
onClick={() => removeFile(key)}>×</button>
<div className="m-nt-thumb">
{url ? <img src={url} alt={f.name} /> : <span className="m-nt-ext">{ntExtLabel(f)}</span>}
</div>
<span className="m-nt-name" title={f.name}>{f.name}</span>
<span className="m-nt-size">{(f.size / 1024).toFixed(0)}KB</span>
</div>
);
})}
</div>
) : null}
</div>
</label>
</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, 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 (
<section>
<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, MaestroKitNewTaskPanel: NewTaskPanel });