401 lines
14 KiB
TypeScript
401 lines
14 KiB
TypeScript
/**
|
||
* 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<TaskStatus, string> = {
|
||
pending: '待处理',
|
||
in_progress: '进行中',
|
||
completed: '已完成',
|
||
blocked: '阻塞',
|
||
cancelled: '已取消',
|
||
};
|
||
|
||
const STATUS_COLORS: Record<TaskStatus, string> = {
|
||
pending: '#f59e0b',
|
||
in_progress: '#a855f7',
|
||
completed: '#22c55e',
|
||
blocked: '#ef4444',
|
||
cancelled: '#64748b',
|
||
};
|
||
|
||
const PRIORITY_LABELS: Record<TaskPriority, string> = {
|
||
low: '低',
|
||
medium: '中',
|
||
high: '高',
|
||
critical: '紧急',
|
||
};
|
||
|
||
const PRIORITY_COLORS: Record<TaskPriority, string> = {
|
||
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<MetonaTask[]>([]);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [showAddForm, setShowAddForm] = useState(false);
|
||
const [newTitle, setNewTitle] = useState('');
|
||
const [newPriority, setNewPriority] = useState<TaskPriority>('medium');
|
||
const [expandedId, setExpandedId] = useState<string | null>(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 (
|
||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
||
<Stack direction="row" spacing={1} sx={{ mb: 1.5, flexShrink: 0, alignItems: 'center' }}>
|
||
<ListChecks size={14} style={{ color: '#22d3ee' }} />
|
||
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}>
|
||
任务列表
|
||
</Typography>
|
||
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
|
||
{tasks.length} 项
|
||
</Typography>
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => setShowAddForm(!showAddForm)}
|
||
sx={{ p: 0.25, color: 'primary.main', '&:hover': { bgcolor: 'action.hover' } }}
|
||
title="新增任务"
|
||
>
|
||
<Plus size={14} />
|
||
</IconButton>
|
||
</Stack>
|
||
|
||
{error && (
|
||
<Typography
|
||
variant="caption"
|
||
sx={{
|
||
mb: 1, px: 1, py: 0.5, fontSize: 10,
|
||
color: 'error.main', bgcolor: 'error.main' + '14',
|
||
borderRadius: 1, flexShrink: 0,
|
||
}}
|
||
>
|
||
{error}
|
||
</Typography>
|
||
)}
|
||
|
||
<Collapse in={showAddForm} sx={{ flexShrink: 0 }}>
|
||
<Box sx={{ mb: 1.5, p: 1, borderRadius: 1, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}>
|
||
<TextField
|
||
size="small"
|
||
fullWidth
|
||
value={newTitle}
|
||
onChange={(e) => setNewTitle(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
|
||
placeholder="任务标题..."
|
||
sx={{ mb: 1, '& .MuiOutlinedInput-root': { fontSize: 11, height: 30, borderRadius: 1 } }}
|
||
/>
|
||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||
<Select
|
||
size="small"
|
||
value={newPriority}
|
||
onChange={(e) => setNewPriority(e.target.value as TaskPriority)}
|
||
sx={{ flex: 1, height: 28, fontSize: 11, '& .MuiSelect-select': { py: 0.5, px: 1 } }}
|
||
>
|
||
{PRIORITY_OPTIONS.map((p) => (
|
||
<MenuItem key={p} value={p} sx={{ fontSize: 11, py: 0.5 }}>
|
||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||
<Circle size={8} style={{ color: PRIORITY_COLORS[p] }} />
|
||
<Typography variant="caption" sx={{ fontSize: 11 }}>{PRIORITY_LABELS[p]}</Typography>
|
||
</Stack>
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
<Button
|
||
variant="contained"
|
||
size="small"
|
||
onClick={handleCreate}
|
||
disabled={creating || !newTitle.trim()}
|
||
sx={{ height: 28, fontSize: 11, minWidth: 60, textTransform: 'none' }}
|
||
>
|
||
{creating ? '...' : '创建'}
|
||
</Button>
|
||
<IconButton
|
||
size="small"
|
||
onClick={() => { setShowAddForm(false); setNewTitle(''); setNewPriority('medium'); }}
|
||
sx={{ p: 0.5, color: 'text.secondary' }}
|
||
>
|
||
<Trash2 size={12} />
|
||
</IconButton>
|
||
</Stack>
|
||
</Box>
|
||
</Collapse>
|
||
|
||
<Box sx={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||
{!sessionId ? (
|
||
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}>
|
||
请先选择会话
|
||
</Typography>
|
||
) : tasks.length === 0 ? (
|
||
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.disabled' }}>
|
||
暂无任务,点击 + 创建
|
||
</Typography>
|
||
) : (
|
||
<List disablePadding dense>
|
||
{tasks.map((task) => (
|
||
<TaskItemRow
|
||
key={task.id}
|
||
task={task}
|
||
expanded={expandedId === task.id}
|
||
onToggleComplete={() => handleToggleComplete(task)}
|
||
onToggleExpand={() => toggleExpand(task.id)}
|
||
onDelete={() => handleDelete(task.id)}
|
||
/>
|
||
))}
|
||
</List>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// ===== 子组件 =====
|
||
|
||
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 (
|
||
<ListItem
|
||
disableGutters
|
||
disablePadding
|
||
sx={{
|
||
display: 'block',
|
||
px: 0.5, py: 0.25,
|
||
borderRadius: 1,
|
||
'&:hover': { bgcolor: 'action.hover' },
|
||
'&:hover .delete-btn': { opacity: 1 },
|
||
}}
|
||
>
|
||
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'flex-start' }}>
|
||
<ListItemIcon sx={{ minWidth: 20, mt: 0.25 }}>
|
||
<Checkbox
|
||
size="small"
|
||
checked={isCompleted}
|
||
onChange={onToggleComplete}
|
||
sx={{ p: 0.25, '& .MuiSvgIcon-root': { fontSize: 14 } }}
|
||
/>
|
||
</ListItemIcon>
|
||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||
<Box
|
||
onClick={onToggleExpand}
|
||
sx={{
|
||
fontSize: 11, lineHeight: 1.3, cursor: 'pointer',
|
||
color: isCompleted ? 'text.disabled' : 'text.primary',
|
||
textDecoration: isCompleted ? 'line-through' : 'none',
|
||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{task.title}
|
||
</Box>
|
||
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center', mt: 0.25, flexWrap: 'wrap' }}>
|
||
<Chip
|
||
label={STATUS_LABELS[task.status]}
|
||
size="small"
|
||
sx={{
|
||
height: 14, fontSize: 9,
|
||
bgcolor: statusColor + '22', color: statusColor,
|
||
'& .MuiChip-label': { px: 0.5 },
|
||
}}
|
||
/>
|
||
<Chip
|
||
label={PRIORITY_LABELS[task.priority]}
|
||
size="small"
|
||
sx={{
|
||
height: 14, fontSize: 9,
|
||
bgcolor: priorityColor + '22', color: priorityColor,
|
||
'& .MuiChip-label': { px: 0.5 },
|
||
}}
|
||
/>
|
||
<Typography
|
||
variant="caption"
|
||
component="span"
|
||
sx={{ fontSize: 9, color: 'text.disabled', fontFamily: 'monospace' }}
|
||
>
|
||
{formatTime(task.created_at)}
|
||
</Typography>
|
||
</Stack>
|
||
</Box>
|
||
<ChevronRight
|
||
size={12}
|
||
style={{
|
||
color: 'text.disabled',
|
||
transition: 'transform 150ms',
|
||
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||
cursor: 'pointer',
|
||
marginTop: 4,
|
||
}}
|
||
onClick={onToggleExpand}
|
||
/>
|
||
<IconButton
|
||
className="delete-btn"
|
||
size="small"
|
||
onClick={onDelete}
|
||
sx={{ opacity: 0, transition: 'opacity 150ms', p: 0.25, mt: 0.25, '&:hover': { color: 'error.main' } }}
|
||
>
|
||
<Trash2 size={11} />
|
||
</IconButton>
|
||
</Stack>
|
||
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||
<Box sx={{ pl: 3.5, pr: 1, py: 0.5, fontSize: 11, color: 'text.secondary', lineHeight: 1.4, wordBreak: 'break-word' }}>
|
||
{task.description ? task.description : (
|
||
<Typography variant="caption" sx={{ fontSize: 10, color: 'text.disabled', fontStyle: 'italic' }}>
|
||
无描述
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
</Collapse>
|
||
</ListItem>
|
||
);
|
||
}
|