Files
maestro/design/ui_kits/console/TaskTree.jsx
T
wangjia 8d3192e7cb app接线: 上传去重响应/已存附件删除/缩略图白名单对齐后端 (tsk_sTHRFtFoHf-G)
- createTask 消费后端去重后的权威 attachments 列表,按入库数报数并提示去重数
- 附件删除 toast 语义由 warn 调整为 ok(attDeleted 为成功语义)
- TaskDetail 缩略图 isImg 收敛到 KIT_ATT_SAFE_INLINE 硬白名单(png/jpeg/gif/webp),
  与后端 src/api/server.ts SAFE_INLINE 一字不差,svg/html 不走 <img src=服务端URL> inline,
  钉死同源存储型 XSS
- i18n 新增 attUploaded/attDeduped 两个带 {n} 占位的 key,覆盖 zh/en/es/ja/fr

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:42:46 +08:00

670 lines
40 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' }];
// 与后端 src/api/server.ts 的 SAFE_INLINE 一字不差:只有这些类型后端才 inline 回原图,
// 其余(尤其 image/svg+xml、text/html)强制 attachment+octet-stream 下载。前端缩略图必须对齐同一硬白名单,
// 否则对 svg 用 <img src=服务端URL> 会拿到 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 (
<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 }) {
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 (
<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}
</div>
{/* 附件画廊:图片缩略图(点击新标签预览)+ 下载 + 删除(确认后调用 taskOps.delAttachment */}
{attachments.length ? (
<div onClick={(e) => e.stopPropagation()}>
<div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 6 }}>{t.attSection} · {attachments.length}</div>
<div className="m-nt-grid">
{attachments.map((a) => {
const dn = attDiskName(a);
const isImg = KIT_ATT_SAFE_INLINE.has(a.type || '');
const url = attUrl(task.id, dn);
return (
<div key={dn || a.name} className="m-nt-chip">
<a className="m-att-thumb-link" href={url} target="_blank" rel="noopener noreferrer" title={a.name}>
<span className="m-nt-thumb">
{isImg ? <img src={url} alt={a.name} /> : <span className="m-nt-ext">{(a.name.includes('.') ? a.name.split('.').pop() : (a.type || 'file').split('/').pop() || 'file').toUpperCase()}</span>}
</span>
</a>
<span className="m-nt-name" title={a.name}>{a.name}</span>
<span className="m-att-act">
<a className="m-att-btn" href={attUrl(task.id, dn, true)} download={a.name}> {t.attDownload}</a>
{ops.delAttachment ? (
<button type="button" className="m-att-btn is-del"
onClick={() => { if (confirm(t.attDeleteConfirm.replace('{name}', a.name))) ops.delAttachment(task.id, dn); }}></button>
) : null}
</span>
</div>
);
})}
</div>
</div>
) : null}
{/* 编辑表单 */}
{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); }
.m-att-thumb-link { display: block; width: 92px; height: 64px; }
.m-att-act { display: flex; gap: 6px; align-items: center; }
.m-att-btn { font-family: var(--mono); font-size: 10px; line-height: 1; padding: 3px 6px; border: 1px solid var(--line); border-radius: var(--radius-sm, 4px); background: var(--panel); color: var(--muted); cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; transition: color .12s, border-color .12s, background .12s; }
.m-att-btn:hover { color: var(--green); border-color: var(--green-dim); background: var(--panel-2); }
.m-att-btn.is-del:hover { color: var(--red); border-color: var(--red-dim); }
`;
document.head.appendChild(s);
}
// 已建任务附件:磁盘文件名(后端 :name 标识符 = basename(att.path),非展示名 att.name
function attDiskName(a) { return (a.path || '').split('/').pop() || a.name || ''; }
// 附件预览/下载 URL(UI kit 内联,零外部依赖;与 app/src/api.js attachmentUrl 同构)
function attUrl(taskId, name, download) {
return `/api/tasks/${taskId}/attachments/${encodeURIComponent(name)}${download ? '?download=1' : ''}`;
}
// 三来源(选择/粘贴/拖拽)统一去重键: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 [warn, setWarn] = React.useState('');
// 为图片预生成缩略图 objectURL(幂等,按去重键缓存)——只在 addFiles 调用,render 仅读,避免严格模式下重复 render 泄漏
const ensureThumb = (f) => {
if (!f.type || !f.type.startsWith('image/')) return;
const key = ntFileKey(f);
const map = urlMapRef.current;
if (!map.has(key)) map.set(key, URL.createObjectURL(f));
};
const thumbUrl = (f) => (f.type && f.type.startsWith('image/')) ? (urlMapRef.current.get(ntFileKey(f)) || null) : null;
// 组件卸载时释放全部 objectURL
React.useEffect(() => () => {
urlMapRef.current.forEach((u) => URL.revokeObjectURL(u));
urlMapRef.current.clear();
}, []);
// 合并新文件并按复合键去重(剪贴板无名图先补名)
const addFiles = (incoming) => {
if (!incoming || !incoming.length) return;
const MAX_BYTES = 25 * 1024 * 1024; // 单文件软上限,避免超大截图卡住上传
const named = Array.from(incoming).map((f, i) => ntNamed(f, i));
const ok = named.filter((f) => f.size <= MAX_BYTES);
setWarn(ok.length < named.length ? t.attTooLarge : '');
if (!ok.length) return;
ok.forEach(ensureThumb); // 预生成缩略图,render 不再产生副作用
setFiles((prev) => {
const seen = new Set(prev.map(ntFileKey));
const merged = prev.slice();
ok.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>
{warn ? <span className="m-nt-hint" style={{ color: 'var(--amber)' }}>{warn}</span> : null}
{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 });