/** * TaskList — 任务列表 * * 显示当前会话的任务,支持新增、完成切换、删除、展开查看描述。 */ import { useState, useEffect, useCallback, useRef } from 'react'; import { Box, Typography, Stack, List, ListItem, ListItemIcon, Checkbox, Chip, IconButton, TextField, Button, Select, MenuItem, Collapse, } from '@mui/material'; import { ListChecks, Plus, Trash2, ChevronRight, Circle } from 'lucide-react'; import { useAgentStore } from '@renderer/stores/agent-store'; import { formatTime } from '@renderer/lib/formatters'; // ===== 类型 ===== type TaskStatus = MetonaTask['status']; type TaskPriority = MetonaTask['priority']; // ===== 常量 ===== const STATUS_LABELS: Record = { pending: '待处理', in_progress: '进行中', completed: '已完成', blocked: '阻塞', cancelled: '已取消', }; const STATUS_COLORS: Record = { pending: '#f59e0b', in_progress: '#a855f7', completed: '#22c55e', blocked: '#ef4444', cancelled: '#64748b', }; const PRIORITY_LABELS: Record = { low: '低', medium: '中', high: '高', critical: '紧急', }; const PRIORITY_COLORS: Record = { low: '#64748b', medium: '#3b82f6', high: '#f97316', critical: '#ef4444', }; const PRIORITY_OPTIONS: TaskPriority[] = ['low', 'medium', 'high', 'critical']; // ===== 主组件 ===== export function TaskList(): React.JSX.Element { const sessionId = useAgentStore((s) => s.currentSessionId); const [tasks, setTasks] = useState([]); const [error, setError] = useState(null); const [showAddForm, setShowAddForm] = useState(false); const [newTitle, setNewTitle] = useState(''); const [newPriority, setNewPriority] = useState('medium'); const [expandedId, setExpandedId] = useState(null); const [creating, setCreating] = useState(false); // M-51 修复: 竞态保护 ref,防止快速切换会话时旧请求覆盖新数据 const loadReqIdRef = useRef(0); const loadTasks = useCallback(async (sid?: string) => { if (!window.metona?.tasks?.list) return; const reqId = ++loadReqIdRef.current; try { setError(null); const res = await window.metona.tasks.list(sid); // 竞态保护:若已被新请求取代或组件已卸载,放弃本次结果 if (loadReqIdRef.current !== reqId) return; if (!res.success) { setError('加载任务失败'); return; } const list = (res.data ?? []).slice().sort((a, b) => { if (a.order_idx !== b.order_idx) return a.order_idx - b.order_idx; return a.created_at - b.created_at; }); setTasks(list); } catch (err) { if (loadReqIdRef.current !== reqId) return; setError((err as Error).message ?? '加载任务失败'); } }, []); useEffect(() => { loadTasks(sessionId ?? undefined); // cleanup: 使当前请求失效(防止卸载后 setState + 竞态) return () => { loadReqIdRef.current++; }; }, [sessionId, loadTasks]); // P2(v0.3.13): 订阅任务变更事件(Agent 通过 task_manager 工具写入后自动刷新) useEffect(() => { if (!window.metona?.tasks?.onTaskChanged) return; const unsubscribe = window.metona.tasks.onTaskChanged((changedSessionId) => { // 只刷新当前会话的任务 if (changedSessionId === sessionId) { loadTasks(sessionId ?? undefined); } }); return unsubscribe; }, [sessionId, loadTasks]); const handleCreate = async () => { if (!sessionId) { // 用户主动操作失败应用 toast(与项目惯例一致) import('metona-toast').then((mod) => mod.default.error('请先选择或创建会话')).catch(() => {}); return; } if (!newTitle.trim()) { // 表单校验保留 inline error(让用户看到具体哪个字段有问题) setError('任务标题不能为空'); return; } if (!window.metona?.tasks?.create) return; setCreating(true); setError(null); try { const res = await window.metona.tasks.create({ sessionId, title: newTitle.trim(), priority: newPriority, }); if (!res.success) { import('metona-toast').then((mod) => mod.default.error(res.error ?? '创建任务失败')).catch(() => {}); return; } setNewTitle(''); setNewPriority('medium'); setShowAddForm(false); await loadTasks(sessionId); } catch (err) { import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '创建任务失败')).catch(() => {}); } finally { setCreating(false); } }; const handleToggleComplete = async (task: MetonaTask) => { if (!window.metona?.tasks?.update) return; if (!sessionId) return; const nextStatus: TaskStatus = task.status === 'completed' ? 'pending' : 'completed'; try { const res = await window.metona.tasks.update(task.id, { status: nextStatus }, sessionId); if (!res.success) { import('metona-toast').then((mod) => mod.default.error(res.error ?? '更新任务失败')).catch(() => {}); return; } await loadTasks(sessionId ?? undefined); } catch (err) { import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '更新任务失败')).catch(() => {}); } }; const handleDelete = async (id: string) => { if (!window.metona?.tasks?.delete) return; if (!sessionId) return; try { const res = await window.metona.tasks.delete(id, sessionId); if (!res.success) { import('metona-toast').then((mod) => mod.default.error(res.error ?? '删除任务失败')).catch(() => {}); return; } if (expandedId === id) setExpandedId(null); await loadTasks(sessionId ?? undefined); } catch (err) { import('metona-toast').then((mod) => mod.default.error((err as Error).message ?? '删除任务失败')).catch(() => {}); } }; const toggleExpand = (id: string) => { setExpandedId(expandedId === id ? null : id); }; return ( 任务列表 {tasks.length} 项 setShowAddForm(!showAddForm)} sx={{ p: 0.25, color: 'primary.main', '&:hover': { bgcolor: 'action.hover' } }} title="新增任务" > {error && ( {error} )} setNewTitle(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }} placeholder="任务标题..." sx={{ mb: 1, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1 } }} /> { setShowAddForm(false); setNewTitle(''); setNewPriority('medium'); }} sx={{ p: 0.5, color: 'text.secondary' }} > {!sessionId ? ( 请先选择会话 ) : tasks.length === 0 ? ( 暂无任务,点击 + 创建 ) : ( {tasks.map((task) => ( handleToggleComplete(task)} onToggleExpand={() => toggleExpand(task.id)} onDelete={() => handleDelete(task.id)} /> ))} )} ); } // ===== 子组件 ===== function TaskItemRow({ task, expanded, onToggleComplete, onToggleExpand, onDelete, }: { task: MetonaTask; expanded: boolean; onToggleComplete: () => void; onToggleExpand: () => void; onDelete: () => void; }): React.JSX.Element { const isCompleted = task.status === 'completed'; const statusColor = STATUS_COLORS[task.status]; const priorityColor = PRIORITY_COLORS[task.priority]; return ( {task.title} {formatTime(task.created_at)} {task.description ? task.description : ( 无描述 )} ); }