import { useState, useRef, useEffect, useMemo } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Slider } from '@/components/ui/slider' import { Card } from '@/components/ui/card' import { Calendar } from '@/components/ui/calendar' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Search, RefreshCw, Download, Filter, Trash2, Pause, Play, Calendar as CalendarIcon, X, Type, ChevronDown, ChevronUp } from 'lucide-react' import { cn } from '@/lib/utils' import { logWebSocket, type LogEntry } from '@/lib/log-websocket' import { format } from 'date-fns' import { zhCN } from 'date-fns/locale' // 字号配置 type FontSize = 'xs' | 'sm' | 'base' const fontSizeConfig: Record = { xs: { label: '小', rowHeight: 28, class: 'text-[10px] sm:text-xs' }, sm: { label: '中', rowHeight: 36, class: 'text-xs sm:text-sm' }, base: { label: '大', rowHeight: 44, class: 'text-sm sm:text-base' }, } export function LogViewerPage() { const [logs, setLogs] = useState([]) const [searchQuery, setSearchQuery] = useState('') const [levelFilter, setLevelFilter] = useState('all') const [moduleFilter, setModuleFilter] = useState('all') const [dateFrom, setDateFrom] = useState(undefined) const [dateTo, setDateTo] = useState(undefined) const [autoScroll, setAutoScroll] = useState(true) const [connected, setConnected] = useState(false) const [fontSize, setFontSize] = useState('xs') // 默认使用小字号以显示更多信息 const [lineSpacing, setLineSpacing] = useState(4) // 行间距,默认4px(紧凑) const [filtersOpen, setFiltersOpen] = useState(false) // 控制折叠面板,默认折叠 const parentRef = useRef(null) // 订阅全局 WebSocket 连接 useEffect(() => { // 初始化时加载缓存的日志 const cachedLogs = logWebSocket.getAllLogs() setLogs(cachedLogs) // 订阅日志消息 - 直接使用全局缓存而不是组件状态 const unsubscribeLogs = logWebSocket.onLog(() => { // 每次收到新日志,重新从全局缓存加载 setLogs(logWebSocket.getAllLogs()) }) // 订阅连接状态 const unsubscribeConnection = logWebSocket.onConnectionChange((isConnected) => { setConnected(isConnected) }) // 清理订阅 return () => { unsubscribeLogs() unsubscribeConnection() } }, []) // 获取所有唯一的模块名(过滤掉空字符串) const uniqueModules = useMemo(() => { const modules = new Set(logs.map(log => log.module).filter(m => m && m.trim() !== '')) return Array.from(modules).sort() }, [logs]) // 日志级别颜色映射 const getLevelColor = (level: LogEntry['level']) => { switch (level) { case 'DEBUG': return 'text-muted-foreground' case 'INFO': return 'text-blue-500 dark:text-blue-400' case 'WARNING': return 'text-yellow-600 dark:text-yellow-500' case 'ERROR': return 'text-red-600 dark:text-red-500' case 'CRITICAL': return 'text-red-700 dark:text-red-400 font-bold' default: return 'text-foreground' } } const getLevelBgColor = (level: LogEntry['level']) => { switch (level) { case 'DEBUG': return 'bg-gray-800/30 dark:bg-gray-800/50' case 'INFO': return 'bg-blue-900/20 dark:bg-blue-500/20' case 'WARNING': return 'bg-yellow-900/20 dark:bg-yellow-500/20' case 'ERROR': return 'bg-red-900/20 dark:bg-red-500/20' case 'CRITICAL': return 'bg-red-900/30 dark:bg-red-600/30' default: return 'bg-gray-800/20 dark:bg-gray-800/30' } } // 刷新日志(刷新页面) const handleRefresh = () => { window.location.reload() } // 清空日志 const handleClear = () => { logWebSocket.clearLogs() // 清空全局缓存 setLogs([]) } // 导出日志为 TXT 格式 const handleExport = () => { // 格式化日志为文本 const logText = filteredLogs.map(log => `${log.timestamp} [${log.level.padEnd(8)}] [${log.module}] ${log.message}` ).join('\n') const dataBlob = new Blob([logText], { type: 'text/plain;charset=utf-8' }) const url = URL.createObjectURL(dataBlob) const link = document.createElement('a') link.href = url link.download = `logs-${format(new Date(), 'yyyy-MM-dd-HHmmss')}.txt` link.click() URL.revokeObjectURL(url) } // 切换自动滚动 const toggleAutoScroll = () => { setAutoScroll(!autoScroll) } // 清除时间筛选 const clearDateFilter = () => { setDateFrom(undefined) setDateTo(undefined) } // 过滤日志 const filteredLogs = useMemo(() => { return logs.filter((log) => { // 搜索过滤 const matchesSearch = searchQuery === '' || log.message.toLowerCase().includes(searchQuery.toLowerCase()) || log.module.toLowerCase().includes(searchQuery.toLowerCase()) // 级别过滤 const matchesLevel = levelFilter === 'all' || log.level === levelFilter // 模块过滤 const matchesModule = moduleFilter === 'all' || log.module === moduleFilter // 时间过滤 let matchesDate = true if (dateFrom || dateTo) { const logDate = new Date(log.timestamp) if (dateFrom) { const fromDate = new Date(dateFrom) fromDate.setHours(0, 0, 0, 0) matchesDate = matchesDate && logDate >= fromDate } if (dateTo) { const toDate = new Date(dateTo) toDate.setHours(23, 59, 59, 999) matchesDate = matchesDate && logDate <= toDate } } return matchesSearch && matchesLevel && matchesModule && matchesDate }) }, [logs, searchQuery, levelFilter, moduleFilter, dateFrom, dateTo]) // 虚拟滚动配置 - 根据字号和行间距动态计算行高 const estimatedRowHeight = fontSizeConfig[fontSize].rowHeight + lineSpacing const rowVirtualizer = useVirtualizer({ count: filteredLogs.length, getScrollElement: () => parentRef.current, estimateSize: () => estimatedRowHeight, overscan: 50, // 增加预渲染数量以减少快速滚动时的空白 }) // 用于追踪是否是程序触发的滚动 const isAutoScrollingRef = useRef(false) // 用于追踪上一次的日志数量 const prevLogCountRef = useRef(filteredLogs.length) // 检测用户滚动行为,当用户向上滚动时禁用自动滚动 useEffect(() => { const scrollElement = parentRef.current if (!scrollElement) return const handleScroll = () => { // 如果是程序触发的滚动,忽略 if (isAutoScrollingRef.current) return const { scrollTop, scrollHeight, clientHeight } = scrollElement const distanceFromBottom = scrollHeight - scrollTop - clientHeight // 如果距离底部超过 100px,说明用户在向上查看,禁用自动滚动 if (distanceFromBottom > 100 && autoScroll) { setAutoScroll(false) } // 如果用户滚动到接近底部(小于 50px),可以重新启用自动滚动 else if (distanceFromBottom < 50 && !autoScroll) { setAutoScroll(true) } } scrollElement.addEventListener('scroll', handleScroll, { passive: true }) return () => scrollElement.removeEventListener('scroll', handleScroll) }, [autoScroll]) // 自动滚动到底部 useEffect(() => { // 只有在日志数量增加时才滚动(避免删除日志时触发) const logCountIncreased = filteredLogs.length > prevLogCountRef.current prevLogCountRef.current = filteredLogs.length if (autoScroll && filteredLogs.length > 0 && logCountIncreased) { isAutoScrollingRef.current = true rowVirtualizer.scrollToIndex(filteredLogs.length - 1, { align: 'end', behavior: 'auto', }) // 稍后重置标志,给滚动事件处理一些时间 requestAnimationFrame(() => { requestAnimationFrame(() => { isAutoScrollingRef.current = false }) }) } }, [filteredLogs.length, autoScroll, rowVirtualizer]) return (
{/* 顶部操作面板 - 紧凑设计,默认折叠 */}
{/* 标题和连接状态 */}

日志查看器

实时查看和分析麦麦运行日志

{/* 连接状态指示器 */}
{connected ? '已连接' : '未连接'}
{/* 控制栏 - 可折叠 */}
{/* 第一行:始终显示 - 搜索、快捷操作、展开按钮 */}
{/* 搜索框 */}
setSearchQuery(e.target.value)} className="pl-8 h-8 text-xs sm:text-sm" />
{/* 快捷操作按钮 */}
{/* 展开/收起按钮 */}
{/* 日志数量显示 */}
{filteredLogs.length} / {logs.length} 条日志
{/* 可折叠的筛选区域 */} {/* 级别和模块筛选 */}
{/* 时间筛选 */}
{(dateFrom || dateTo) && ( )}
{/* 显示设置 */}
{/* 字号调整 */}
字号
{(Object.keys(fontSizeConfig) as FontSize[]).map((size) => ( ))}
{/* 行间距调整 */}
行距 setLineSpacing(value)} min={0} max={12} step={2} className="flex-1" /> {lineSpacing}px
{/* 额外操作按钮(移动端) */}
{/* 日志终端 - 占据剩余所有空间 */}
{filteredLogs.length === 0 ? (
暂无日志数据
) : ( rowVirtualizer.getVirtualItems().map((virtualRow) => { const log = filteredLogs[virtualRow.index] return (
{/* 移动端:垂直布局 */}
{/* 第一行:时间戳和级别 */}
{log.timestamp} [{log.level}]
{/* 第二行:模块名 */}
{log.module}
{/* 第三行:消息内容 */}
{log.message}
{/* 平板/桌面端:水平布局 */}
{/* 时间戳 */} {log.timestamp} {/* 日志级别 */} [{log.level}] {/* 模块名 */} {log.module} {/* 消息内容 */} {log.message}
) }) )}
) }