From 6962ea2eb13c64a64b22fbc9c833647f3d31dbe7 Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Thu, 18 Jun 2026 14:25:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(todo):=20=E4=BC=98=E5=8C=96=E6=82=AC?= =?UTF-8?q?=E6=B5=AE=E5=BE=85=E5=8A=9E=E9=9D=A2=E6=9D=BF=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将悬浮待办面板集成到聊天组件上方,避免重复渲染 - 调整状态样式配置,简化颜色和样式管理 - 实现待办面板位置拖拽功能,支持位置持久化保存 - 优化折叠与展开交互,改进分组标题和列表项显示样式 - 设计迷你和完整两种面板展现形态,提升界面灵活性 - 添加刷新按钮及自动展开待办新条目功能 - 精简和改进待办项展示,提高内容可读性和界面美观度 --- web/src/App.tsx | 14 +- web/src/components/Chat/ChatContainer.tsx | 8 +- web/src/components/Panel/TodoPanel.tsx | 301 ++++++++++++---------- 3 files changed, 183 insertions(+), 140 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 67ae375..a9dfb9e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -663,6 +663,13 @@ function App() { onNavigateToSubAgent={handleNavigateToSubAgent} onStop={handleStopExecution} showThinking={showThinking} + todoPanel={ + + } /> @@ -731,13 +738,6 @@ function App() { )} - {/* 悬浮 Todo 面板 */} - - {/* 系统配置页面 */} {configPageOpen && ( setConfigPageOpen(false)} onSaveConnection={handleSaveConnection} /> diff --git a/web/src/components/Chat/ChatContainer.tsx b/web/src/components/Chat/ChatContainer.tsx index b6cf5dd..c23aa2d 100644 --- a/web/src/components/Chat/ChatContainer.tsx +++ b/web/src/components/Chat/ChatContainer.tsx @@ -11,6 +11,8 @@ interface ChatContainerProps { onNavigateToSubAgent?: (taskId: string, description: string) => void onStop?: () => void showThinking?: boolean + /** 浮动待办面板,绝对定位在消息区域上方 */ + todoPanel?: React.ReactNode } export function ChatContainer({ @@ -22,11 +24,13 @@ export function ChatContainer({ onNavigateToSubAgent, onStop, showThinking = true, + todoPanel, }: ChatContainerProps) { return ( - - + + + {todoPanel} void } -/* ── status helpers ───────────────────────────────────── */ +/* ── status config ────────────────────────────────────── */ -interface StatusStyle { label: string; border: string; dot: string; text: string; icon: string } +interface StatusCfg { label: string; color: string; dot: string } -const STATUS: Record = { - in_progress: { label: '进行中', border: 'border-amber-400/60', dot: 'bg-amber-400 shadow-[0_0_6px_#fbbf24]', text: 'text-amber-300', icon: '●' }, - pending: { label: '待处理', border: 'border-slate-500/50', dot: 'bg-slate-500', text: 'text-slate-400', icon: '○' }, - completed: { label: '已完成', border: 'border-emerald-400/40', dot: 'bg-emerald-400', text: 'text-emerald-400', icon: '✓' }, - cancelled: { label: '已取消', border: 'border-red-400/40', dot: 'bg-red-400', text: 'text-red-400', icon: '✕' }, +const STATUS: Record = { + in_progress: { label: '进行中', color: 'text-amber-400', dot: 'bg-amber-400' }, + pending: { label: '待处理', color: 'text-slate-400', dot: 'bg-slate-500' }, + completed: { label: '已完成', color: 'text-emerald-400', dot: 'bg-emerald-400' }, + cancelled: { label: '已取消', color: 'text-slate-500', dot: 'bg-slate-600' }, } -function statusStyle(s: string): StatusStyle { - return STATUS[s] ?? { label: s, border: 'border-[var(--border-color)]', dot: 'bg-[var(--text-muted)]', text: 'text-[var(--text-muted)]', icon: '?' } +function statusCfg(s: string): StatusCfg { + return STATUS[s] ?? { label: s, color: 'text-slate-400', dot: 'bg-slate-500' } } -const PRIORITY: Record = { - high: 'text-rose-400', - medium: 'text-amber-400', - low: 'text-slate-400', -} - -function priorityDot(p: string) { return PRIORITY[p] ?? 'text-slate-400' } - -/* ── group helpers ────────────────────────────────────── */ - const GROUP_ORDER = ['in_progress', 'pending', 'completed', 'cancelled'] -const COLLAPSED_DEFAULT = new Set(['completed', 'cancelled']) function groupTodos(todos: TodoItemSummary[]): Map { const map = new Map() @@ -50,34 +39,49 @@ function groupTodos(todos: TodoItemSummary[]): Map { function PulseDot() { return ( - + - + ) } +/* ── position persistence ─────────────────────────────── */ + +const POS_KEY = 'picobot-todo-pos' + +function loadPos(): { x: number; y: number } { + try { + const raw = localStorage.getItem(POS_KEY) + if (raw) return JSON.parse(raw) + } catch { /* ignore */ } + return { x: 0, y: 0 } +} + +function savePos(pos: { x: number; y: number }) { + try { localStorage.setItem(POS_KEY, JSON.stringify(pos)) } catch { /* ignore */ } +} + /* ── TodoPanel ────────────────────────────────────────── */ export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProps) { const [expanded, setExpanded] = useState(() => { - try { return localStorage.getItem('picobot-todo-panel-open') === 'true' } catch { return false } + try { return localStorage.getItem('picobot-todo-expanded') === 'true' } catch { return false } }) - const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set(COLLAPSED_DEFAULT)) + const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set(['completed', 'cancelled'])) + const [pos, setPos] = useState(loadPos) const prevTodoIdsRef = useRef>(new Set()) + const dragRef = useRef<{ startX: number; startY: number; startPos: { x: number; y: number }; moved: boolean } | null>(null) - // 持久化展开状态 useEffect(() => { - localStorage.setItem('picobot-todo-panel-open', String(expanded)) + localStorage.setItem('picobot-todo-expanded', String(expanded)) }, [expanded]) - // 当有新 todo 出现时自动展开 + // auto-expand on new items useEffect(() => { const newIds = new Set(todos.map(t => t.id)) const hasNewItems = todos.some(t => !prevTodoIdsRef.current.has(t.id)) - if (hasNewItems && todos.length > 0) { - setExpanded(true) - } + if (hasNewItems && todos.length > 0) setExpanded(true) prevTodoIdsRef.current = newIds }, [todos]) @@ -93,122 +97,157 @@ export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProp }) }, []) - // ── 空状态 ── - if (totalCount === 0) { - return ( - - sendCommand(requestTodoList())} - className="flex items-center gap-2 px-3 py-2 rounded-xl bg-[var(--bg-tertiary)]/70 backdrop-blur-sm border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-accent)] transition-all duration-300 shadow-lg text-xs" - title="刷新待办" - > - - 待办 - - - ) - } + const handleRefresh = useCallback(() => sendCommand(requestTodoList()), [sendCommand, requestTodoList]) + + /* ── drag handling ──────────────────────────────────── */ + + const handleDragStart = useCallback((e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest('button')) return + e.preventDefault() + + const startX = e.clientX + const startY = e.clientY + const startPos = { ...pos } + dragRef.current = { startX, startY, startPos, moved: false } + + const handleMove = (ev: MouseEvent) => { + if (!dragRef.current) return + const dx = ev.clientX - dragRef.current.startX + const dy = ev.clientY - dragRef.current.startY + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragRef.current.moved = true + setPos({ + x: Math.max(0, dragRef.current.startPos.x - dx), + y: Math.max(0, dragRef.current.startPos.y + dy), + }) + } + + const handleUp = () => { + document.removeEventListener('mousemove', handleMove) + document.removeEventListener('mouseup', handleUp) + document.body.style.userSelect = '' + document.body.style.cursor = '' + if (dragRef.current) { + setPos(prev => { savePos(prev); return prev }) + dragRef.current = null + } + } + + document.addEventListener('mousemove', handleMove) + document.addEventListener('mouseup', handleUp) + document.body.style.userSelect = 'none' + document.body.style.cursor = 'grabbing' + }, [pos]) + + /* ── minimized: circle button ───────────────────────── */ - // ── 折叠态 ── if (!expanded) { return ( - - setExpanded(true)} - className="flex items-center gap-2 px-3 py-2 rounded-xl bg-[var(--bg-tertiary)]/80 backdrop-blur-sm border border-[var(--border-color)] hover:border-[var(--accent-cyan)]/30 transition-all duration-300 shadow-lg group" + + - {inProgressCount > 0 ? : } - - 待办 ({totalCount}) - - - + { if (totalCount > 0) setExpanded(true); else handleRefresh() }} + className="relative w-12 h-12 rounded-full bg-[var(--bg-tertiary)]/90 backdrop-blur-md border border-[var(--border-color)] hover:border-[var(--accent-cyan)]/40 shadow-lg hover:shadow-[0_0_20px_var(--shadow-glow-sm)] transition-all duration-300 group" + title="待办" + > + + + + ToDo + + + {totalCount > 0 && ( + 0 + ? 'bg-amber-400 text-black' + : 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-color)]' + }`}> + {totalCount} + + )} + {inProgressCount > 0 && ( + + )} + + ) } - // ── 展开态 ── + /* ── expanded: full card ────────────────────────────── */ + return ( - - {/* 标题栏 */} - - - - 待办事项 - 共 {totalCount} 项 + + + {/* title bar (drag handle) */} + + ⠿ + + 待办 + {totalCount} + {inProgressCount > 0 && } + + + + + setExpanded(false)} className="p-1 rounded text-[var(--text-muted)]/50 hover:text-[var(--text-secondary)] transition-colors" title="缩小"> + + + - setExpanded(false)} - className="p-1 rounded hover:bg-[var(--overlay-subtle)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors" - > - - - - {/* 列表区 */} - - {GROUP_ORDER.map(status => { - const items = grouped.get(status) - if (!items || items.length === 0) return null + {/* list */} + + {GROUP_ORDER.map(status => { + const items = grouped.get(status) + if (!items || items.length === 0) return null - const style = statusStyle(status) - const isCollapsed = collapsedGroups.has(status) - const isTerminal = status === 'completed' || status === 'cancelled' + const cfg = statusCfg(status) + const isCollapsed = collapsedGroups.has(status) - return ( - - {/* 分组标题 */} - toggleGroup(status)} - className="flex items-center gap-1.5 w-full px-1 py-1 text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors" - > - {isCollapsed ? ( - - ) : ( - - )} - {style.icon} - {style.label} - ({items.length}) - + return ( + + toggleGroup(status)} + className="flex items-center gap-1.5 w-full py-0.5 group" + > + + {cfg.label} + {items.length} + + {isCollapsed + ? + : + } + + - {/* 卡片列表 */} - {!isCollapsed && ( - - {items.map(item => ( - - - - + {!isCollapsed && ( + + {items.map(item => ( + + + {item.content} - - - {item.content} - - - - ))} - - )} - - ) - })} - - - {/* 底部刷新 */} - - sendCommand(requestTodoList())} - className="text-[10px] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors" - > - 刷新 - + ))} + + )} + + ) + })} + )
- {item.content} -