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:
@@ -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 helper,content-type 由浏览器带 boundary)
|
||||
uploadAttachments: async (taskId, files) => {
|
||||
const fd = new FormData();
|
||||
|
||||
+20
-1
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user