feat(app): 任务生命周期操作 UI(编辑/取消/重投/删除 + 复杂度落库)

补齐前端缺失的任务管理闭环(均接现有后端端点,无需后端改动):

- 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>
This commit is contained in:
wangjia
2026-06-24 17:26:29 +08:00
parent 638f2f64f0
commit 7a6c826610
3 changed files with 87 additions and 18 deletions
+4
View File
@@ -32,6 +32,10 @@ export const api = {
patchProject: (pid, body) => http('PATCH', `/api/projects/${pid}`, body),
createProject: (body) => http('POST', '/api/projects', body),
takeover: (taskId) => http('POST', `/api/tasks/${taskId}/takeover`),
patchTask: (taskId, body) => http('PATCH', `/api/tasks/${taskId}`, body),
cancelTask: (taskId) => http('POST', `/api/tasks/${taskId}/cancel`),
requeueTask: (taskId) => http('POST', `/api/tasks/${taskId}/requeue`),
deleteTask: (taskId) => http('DELETE', `/api/tasks/${taskId}`),
// 附件上传走 multipart(不能用 JSON helpercontent-type 由浏览器带 boundary
uploadAttachments: async (taskId, files) => {
const fd = new FormData();
+20 -1
View File
@@ -311,6 +311,25 @@ export function App() {
setTakeoverInfo({ ...info, title: task.title });
} catch (e) { toast('warn', '接管失败:' + e.message); }
};
// 任务生命周期操作(编辑落库 / 取消 / 重投 / 删除),统一带刷新
const taskOps = {
patch: async (taskId, body) => {
try { await api.patchTask(taskId, body); toast('ok', '已更新'); loadProject(currentId); }
catch (e) { toast('warn', '更新失败:' + e.message); }
},
cancel: async (taskId) => {
try { await api.cancelTask(taskId); toast('warn', '已取消'); loadProject(currentId); loadGlobal(); }
catch (e) { toast('warn', '取消失败:' + e.message); }
},
requeue: async (taskId) => {
try { await api.requeueTask(taskId); toast('ok', '已重投'); loadProject(currentId); loadGlobal(); }
catch (e) { toast('warn', '重投失败:' + e.message); }
},
del: async (taskId) => {
try { await api.deleteTask(taskId); toast('warn', '已删除'); loadProject(currentId); loadGlobal(); }
catch (e) { toast('warn', '删除失败:' + e.message); }
},
};
const [showNewProject, setShowNewProject] = React.useState(false);
const createProject = async (body) => {
try {
@@ -342,7 +361,7 @@ export function App() {
<window.MaestroKitAgentSection agents={agents} quota={quota} t={t} onOpenStream={setStreamAgent} />
<window.MaestroKitGateSection approvals={gates} onDecide={decide} t={t} />
{tasks.length ? (
<window.MaestroKitTaskSection tasks={tasks} onToast={toast} onCreate={createTask} onTakeover={takeover} t={t} />
<window.MaestroKitTaskSection tasks={tasks} onToast={toast} onCreate={createTask} onTakeover={takeover} taskOps={taskOps} t={t} />
) : (
<div style={{ padding: '46px 0', textAlign: 'center', color: 'var(--faint)', border: '1px dashed var(--line)', borderRadius: 6, marginTop: 18 }}>
<b style={{ display: 'block', fontSize: 15, color: 'var(--muted)', marginBottom: 6, letterSpacing: '.2em' }}>{t.empty}</b>
+63 -17
View File
@@ -100,7 +100,7 @@ function FilterBar({ t, kw, setKw, cplxSet, toggleCplx, statusSet, toggleGroup,
);
}
function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleOpen, t, byId, cplxOf, onChangeCplx, flashId, onJump, visibleSet, onTakeover }) {
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; // 筛选时自动展开可见节点
@@ -140,13 +140,13 @@ function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleO
<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} /> : null}
{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} />
byId={byId} cplxOf={cplxOf} onChangeCplx={onChangeCplx} flashId={flashId} onJump={onJump} visibleSet={visibleSet} onTakeover={onTakeover} taskOps={taskOps} />
))}
</div>
) : null}
@@ -154,23 +154,66 @@ function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleO
);
}
function TaskDetail({ task, t, byId, onJump, onTakeover }) {
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 }}>
{onTakeover || attachments.length ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
{onTakeover ? (
<button onClick={(e) => { e.stopPropagation(); onTakeover(task); }}
style={{ fontFamily: 'var(--mono)', fontSize: 12, cursor: 'pointer', background: 'transparent', color: 'var(--cyan)', border: '1px solid var(--cyan-dim)', borderRadius: 'var(--radius-sm,4px)', padding: '3px 10px' }}>
人工接管
</button>
) : null}
{attachments.length ? (
<span style={{ fontSize: 11, color: 'var(--faint)' }}>📎 {attachments.map((a) => a.name).join(' · ')}</span>
{/* 操作工具条:编辑 / 接管 / 重投 / 取消 / 删除 */}
<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 ? (
@@ -267,7 +310,7 @@ function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
);
}
function TaskSection({ tasks, onToast, onCreate, onTakeover, t }) {
function TaskSection({ tasks, onToast, onCreate, onTakeover, taskOps, t }) {
const { Button, SectionHead } = window.MaestroDesignSystem_a6a290;
const [expandedId, setExpandedId] = React.useState(null);
const [openIds, setOpenIds] = React.useState(() => new Set(['t1']));
@@ -279,7 +322,10 @@ function TaskSection({ tasks, onToast, onCreate, onTakeover, t }) {
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 }));
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;
});
@@ -347,7 +393,7 @@ function TaskSection({ tasks, onToast, onCreate, onTakeover, t }) {
{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} />
byId={byId} cplxOf={cplxOf} onChangeCplx={onChangeCplx} flashId={flashId} onJump={onJump} visibleSet={visibleSet} onTakeover={onTakeover} taskOps={taskOps} />
))}
</div>
</section>