feat: 添加 Todo 面板,支持待办事项的展示与管理
This commit is contained in:
parent
ce6dce81f4
commit
3f32079f92
@ -5,6 +5,7 @@ import { TopicList } from './components/Sidebar/TopicList'
|
||||
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
|
||||
import { MemoryPanel } from './components/Panel/MemoryPanel'
|
||||
import { SkillList } from './components/Panel/SkillList'
|
||||
import { TodoPanel } from './components/Panel/TodoPanel'
|
||||
import { ConnectionStatus } from './components/ConnectionStatus'
|
||||
import { ChannelSelector } from './components/Header/ChannelSelector'
|
||||
import { SessionSelector } from './components/Header/SessionSelector'
|
||||
@ -42,6 +43,8 @@ function App() {
|
||||
// 技能
|
||||
skills,
|
||||
requestSkillList,
|
||||
todos,
|
||||
requestTodoList,
|
||||
// 定时任务
|
||||
schedulerJobs,
|
||||
sidebarTab,
|
||||
@ -677,6 +680,13 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 悬浮 Todo 面板 */}
|
||||
<TodoPanel
|
||||
todos={todos}
|
||||
requestTodoList={requestTodoList}
|
||||
sendCommand={sendMemoryCommand}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
223
web/src/components/Panel/TodoPanel.tsx
Normal file
223
web/src/components/Panel/TodoPanel.tsx
Normal file
@ -0,0 +1,223 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { ClipboardList, ChevronUp, ChevronDown, Circle } from 'lucide-react'
|
||||
import type { TodoItemSummary, Command } from '../../types/protocol'
|
||||
|
||||
interface TodoPanelProps {
|
||||
todos: TodoItemSummary[]
|
||||
requestTodoList: () => Command
|
||||
sendCommand: (cmd: Command) => void
|
||||
}
|
||||
|
||||
/* ── status helpers ───────────────────────────────────── */
|
||||
|
||||
interface StatusStyle { label: string; border: string; dot: string; text: string; icon: string }
|
||||
|
||||
const STATUS: Record<string, StatusStyle> = {
|
||||
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: '✕' },
|
||||
}
|
||||
|
||||
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: '?' }
|
||||
}
|
||||
|
||||
const PRIORITY: Record<string, string> = {
|
||||
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<string, TodoItemSummary[]> {
|
||||
const map = new Map<string, TodoItemSummary[]>()
|
||||
for (const t of todos) {
|
||||
const list = map.get(t.status) ?? []
|
||||
list.push(t)
|
||||
map.set(t.status, list)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/* ── pulse dot ────────────────────────────────────────── */
|
||||
|
||||
function PulseDot() {
|
||||
return (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-400 shadow-[0_0_6px_#fbbf24]" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── TodoPanel ────────────────────────────────────────── */
|
||||
|
||||
export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProps) {
|
||||
const [expanded, setExpanded] = useState(() => {
|
||||
try { return localStorage.getItem('picobot-todo-panel-open') === 'true' } catch { return false }
|
||||
})
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set(COLLAPSED_DEFAULT))
|
||||
const prevTodoIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
// 持久化展开状态
|
||||
useEffect(() => {
|
||||
localStorage.setItem('picobot-todo-panel-open', String(expanded))
|
||||
}, [expanded])
|
||||
|
||||
// 当有新 todo 出现时自动展开
|
||||
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)
|
||||
}
|
||||
prevTodoIdsRef.current = newIds
|
||||
}, [todos])
|
||||
|
||||
const grouped = groupTodos(todos)
|
||||
const inProgressCount = grouped.get('in_progress')?.length ?? 0
|
||||
const totalCount = todos.length
|
||||
|
||||
const toggleGroup = useCallback((status: string) => {
|
||||
setCollapsedGroups(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(status)) next.delete(status); else next.add(status)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ── 空状态 ──
|
||||
if (totalCount === 0) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50">
|
||||
<button
|
||||
onClick={() => 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="刷新待办"
|
||||
>
|
||||
<ClipboardList className="h-3.5 w-3.5" />
|
||||
<span>待办</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 折叠态 ──
|
||||
if (!expanded) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50">
|
||||
<button
|
||||
onClick={() => 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 ? <PulseDot /> : <ClipboardList className="h-3.5 w-3.5 text-[var(--text-muted)]" />}
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)] group-hover:text-[var(--accent-cyan)] transition-colors">
|
||||
待办 ({totalCount})
|
||||
</span>
|
||||
<ChevronUp className="h-3.5 w-3.5 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 展开态 ──
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 w-72 max-h-[60vh] flex flex-col rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)]/95 backdrop-blur-md shadow-2xl overflow-hidden transition-all duration-300">
|
||||
{/* 标题栏 */}
|
||||
<div className="shrink-0 flex items-center justify-between px-3 py-2.5 border-b border-[var(--border-color)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-[var(--accent-cyan)]" />
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">待办事项</span>
|
||||
<span className="text-[11px] text-[var(--text-muted)] tabular-nums">共 {totalCount} 项</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
className="p-1 rounded hover:bg-[var(--overlay-subtle)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 列表区 */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-hide px-2 py-2 space-y-2">
|
||||
{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'
|
||||
|
||||
return (
|
||||
<div key={status}>
|
||||
{/* 分组标题 */}
|
||||
<button
|
||||
onClick={() => 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 ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
<span className={style.text}>{style.icon}</span>
|
||||
<span>{style.label}</span>
|
||||
<span className="tabular-nums">({items.length})</span>
|
||||
</button>
|
||||
|
||||
{/* 卡片列表 */}
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-1">
|
||||
{items.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`rounded-lg border-l-2 ${style.border} border border-[var(--border-color)] bg-[var(--overlay-hover)]/50 px-2.5 py-1.5 transition-colors hover:border-[var(--border-accent)]`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={`mt-0.5 shrink-0 ${priorityDot(item.priority)}`}>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-[13px] leading-snug text-[var(--text-primary)] break-words ${
|
||||
isTerminal ? 'line-through opacity-50' : ''
|
||||
}`}>
|
||||
{item.content}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={`text-[10px] ${priorityDot(item.priority)}`}>
|
||||
{item.priority}
|
||||
</span>
|
||||
<span className={`text-[10px] ${style.text}`}>
|
||||
{style.icon} {style.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 底部刷新 */}
|
||||
<div className="shrink-0 border-t border-[var(--border-color)] px-3 py-1.5">
|
||||
<button
|
||||
onClick={() => sendCommand(requestTodoList())}
|
||||
className="text-[10px] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -20,6 +20,8 @@ import type {
|
||||
MemoryList,
|
||||
SkillSummary,
|
||||
SkillList,
|
||||
TodoItemSummary,
|
||||
TodoList,
|
||||
SchedulerJobList,
|
||||
SchedulerJobSummary,
|
||||
SchedulerJobSessionLookup,
|
||||
@ -92,6 +94,10 @@ interface UseChatReturn {
|
||||
skills: SkillSummary[]
|
||||
requestSkillList: () => Command
|
||||
|
||||
// Todo 状态
|
||||
todos: TodoItemSummary[]
|
||||
requestTodoList: () => Command
|
||||
|
||||
// 定时任务状态
|
||||
schedulerJobs: SchedulerJobSummary[]
|
||||
sidebarTab: 'topics' | 'scheduler'
|
||||
@ -140,6 +146,7 @@ export function useChat(): UseChatReturn {
|
||||
const [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null)
|
||||
const [memories, setMemories] = useState<MemorySummary[]>([])
|
||||
const [skills, setSkills] = useState<SkillSummary[]>([])
|
||||
const [todos, setTodos] = useState<TodoItemSummary[]>([])
|
||||
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
|
||||
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
|
||||
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
|
||||
@ -572,6 +579,11 @@ export function useChat(): UseChatReturn {
|
||||
setSkills(msg.skills)
|
||||
break
|
||||
}
|
||||
case 'todo_list': {
|
||||
const msg = message as TodoList
|
||||
setTodos(msg.todos)
|
||||
break
|
||||
}
|
||||
|
||||
case 'channel_list': {
|
||||
const msg = message as ChannelList
|
||||
@ -750,6 +762,10 @@ export function useChat(): UseChatReturn {
|
||||
return { type: 'list_skills' }
|
||||
}, [])
|
||||
|
||||
const requestTodoList = useCallback((): Command => {
|
||||
return { type: 'list_todos' }
|
||||
}, [])
|
||||
|
||||
// 定时任务方法
|
||||
const requestSchedulerJobList = useCallback((): Command => {
|
||||
return { type: 'list_scheduler_jobs' }
|
||||
@ -838,6 +854,8 @@ export function useChat(): UseChatReturn {
|
||||
deleteMemory,
|
||||
skills,
|
||||
requestSkillList,
|
||||
todos,
|
||||
requestTodoList,
|
||||
schedulerJobs,
|
||||
sidebarTab,
|
||||
setSidebarTab,
|
||||
|
||||
@ -201,6 +201,21 @@ export interface SkillList {
|
||||
skills: SkillSummary[]
|
||||
}
|
||||
|
||||
export interface TodoItemSummary {
|
||||
id: string
|
||||
content: string
|
||||
status: string
|
||||
priority: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export interface TodoList {
|
||||
type: 'todo_list'
|
||||
todos: TodoItemSummary[]
|
||||
scope_key: string
|
||||
}
|
||||
|
||||
export interface SchedulerJobSessionLookup {
|
||||
channel: string
|
||||
chat_id: string
|
||||
@ -259,6 +274,7 @@ export type WsOutbound =
|
||||
| SchedulerJobList
|
||||
| MemoryList
|
||||
| SkillList
|
||||
| TodoList
|
||||
| ExecutionCancelled
|
||||
| Pong
|
||||
|
||||
@ -372,6 +388,10 @@ export interface ListSkillsCommand {
|
||||
type: 'list_skills'
|
||||
}
|
||||
|
||||
export interface ListTodosCommand {
|
||||
type: 'list_todos'
|
||||
}
|
||||
|
||||
export type Command =
|
||||
| CreateSessionCommand
|
||||
| ListSessionsCommand
|
||||
@ -394,6 +414,7 @@ export type Command =
|
||||
| UpdateMemoryCommand
|
||||
| DeleteMemoryCommand
|
||||
| ListSkillsCommand
|
||||
| ListTodosCommand
|
||||
|
||||
// ============================================================================
|
||||
// UI Types
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user