7a6c826610
补齐前端缺失的任务管理闭环(均接现有后端端点,无需后端改动):
- api.js:patchTask / cancelTask / requeueTask / deleteTask
- app.jsx:taskOps{patch,cancel,requeue,del},统一带刷新 + toast
- TaskDetail 操作工具条:✎编辑 / ⌨接管 / ↻重投 / ⊘取消 / 🗑删除(删除带确认;
重投/取消按状态显隐)
- 编辑表单:标题 + 优先级 + 依赖(点选其他任务)→ PATCH /tasks/:id
- 修复:行内复杂度选择器(CplxPicker)从「仅本地 state」改为真正 PATCH 落库
验证:build 通过;建临时任务实测——工具条/编辑表单渲染 0 错误、DELETE 成功
(deleted:1)、PATCH 在途守卫正确拒绝;测试残留已清理。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
404 lines
23 KiB
React
404 lines
23 KiB
React
// 任务树:筛选栏(搜索+复杂度+状态分组)+ 折叠层级 + 复杂度可点换档 + 依赖可视化跳转 + 展开详情;新建任务表单
|
||
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, 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} />
|
||
<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>
|
||
<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;
|
||
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 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}>
|
||
<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: 4, fontSize: 11, color: 'var(--muted)', letterSpacing: '.08em' }}>
|
||
<span>{t.attachLabel || '附件 · 图片/文件(可选)'}</span>
|
||
<input type="file" multiple onChange={(e) => setFiles([...e.target.files])}
|
||
style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--ink)' }} />
|
||
</label>
|
||
{files.length ? (
|
||
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--faint)' }}>
|
||
{files.map((f) => f.name).join(' · ')}
|
||
</div>
|
||
) : null}
|
||
</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 { 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 })); // 乐观显示
|
||
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;
|
||
});
|
||
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={(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>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
Object.assign(window, { MaestroKitTaskSection: TaskSection });
|