import { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react'; import { LogService, LogEntry } from '@esengine/editor-core'; import { LogLevel } from '@esengine/esengine'; import { Search, Filter, Settings, X, Trash2, ChevronDown, Bug, Info, AlertTriangle, XCircle, AlertCircle, Wifi, Pause, Play, Copy } from 'lucide-react'; import '../styles/OutputLogPanel.css'; interface OutputLogPanelProps { logService: LogService; locale?: string; onClose?: () => void; } const MAX_LOGS = 1000; function formatTime(date: Date): string { const hours = date.getHours().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0'); const seconds = date.getSeconds().toString().padStart(2, '0'); const ms = date.getMilliseconds().toString().padStart(3, '0'); return `${hours}:${minutes}:${seconds}.${ms}`; } function getLevelIcon(level: LogLevel, size: number = 14) { switch (level) { case LogLevel.Debug: return ; case LogLevel.Info: return ; case LogLevel.Warn: return ; case LogLevel.Error: case LogLevel.Fatal: return ; default: return ; } } function getLevelClass(level: LogLevel): string { switch (level) { case LogLevel.Debug: return 'output-log-entry-debug'; case LogLevel.Info: return 'output-log-entry-info'; case LogLevel.Warn: return 'output-log-entry-warn'; case LogLevel.Error: case LogLevel.Fatal: return 'output-log-entry-error'; default: return ''; } } /** * 尝试从消息中提取堆栈信息 */ function extractStackTrace(message: string): { message: string; stack: string | null } { const stackPattern = /\n\s*at\s+/; if (stackPattern.test(message)) { const lines = message.split('\n'); const messageLines: string[] = []; const stackLines: string[] = []; let inStack = false; for (const line of lines) { if (line.trim().startsWith('at ') || inStack) { inStack = true; stackLines.push(line); } else { messageLines.push(line); } } return { message: messageLines.join('\n').trim(), stack: stackLines.length > 0 ? stackLines.join('\n') : null }; } return { message, stack: null }; } const LogEntryItem = memo(({ log, isExpanded, onToggle, onCopy }: { log: LogEntry; isExpanded: boolean; onToggle: () => void; onCopy: () => void; }) => { // 优先使用 log.stack,否则尝试从 message 中提取 const { message, stack } = useMemo(() => { if (log.stack) { return { message: log.message, stack: log.stack }; } return extractStackTrace(log.message); }, [log.message, log.stack]); const hasStack = !!stack; return (
{getLevelIcon(log.level)}
{formatTime(log.timestamp)}
[{log.source === 'remote' ? 'Remote' : log.source}]
{message}
{isExpanded && stack && (
调用堆栈:
{stack.split('\n').filter(line => line.trim()).map((line, index) => (
{line}
))}
)}
); }); LogEntryItem.displayName = 'LogEntryItem'; export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLogPanelProps) { const [logs, setLogs] = useState(() => logService.getLogs().slice(-MAX_LOGS)); const [searchQuery, setSearchQuery] = useState(''); const [levelFilter, setLevelFilter] = useState>(new Set([ LogLevel.Debug, LogLevel.Info, LogLevel.Warn, LogLevel.Error, LogLevel.Fatal ])); const [showRemoteOnly, setShowRemoteOnly] = useState(false); const [autoScroll, setAutoScroll] = useState(true); const [showFilterMenu, setShowFilterMenu] = useState(false); const [showSettingsMenu, setShowSettingsMenu] = useState(false); const [expandedLogIds, setExpandedLogIds] = useState>(new Set()); const logContainerRef = useRef(null); const filterMenuRef = useRef(null); const settingsMenuRef = useRef(null); useEffect(() => { const unsubscribe = logService.subscribe((entry) => { setLogs((prev) => { const newLogs = [...prev, entry]; return newLogs.length > MAX_LOGS ? newLogs.slice(-MAX_LOGS) : newLogs; }); }); return unsubscribe; }, [logService]); useEffect(() => { if (autoScroll && logContainerRef.current) { logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; } }, [logs, autoScroll]); useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (filterMenuRef.current && !filterMenuRef.current.contains(e.target as Node)) { setShowFilterMenu(false); } if (settingsMenuRef.current && !settingsMenuRef.current.contains(e.target as Node)) { setShowSettingsMenu(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const handleScroll = useCallback(() => { if (logContainerRef.current) { const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current; const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10; setAutoScroll(isAtBottom); } }, []); const handleClear = useCallback(() => { logService.clear(); setLogs([]); setExpandedLogIds(new Set()); }, [logService]); const toggleLevelFilter = useCallback((level: LogLevel) => { setLevelFilter((prev) => { const newFilter = new Set(prev); if (newFilter.has(level)) { newFilter.delete(level); } else { newFilter.add(level); } return newFilter; }); }, []); const toggleLogExpanded = useCallback((logId: string) => { setExpandedLogIds(prev => { const newSet = new Set(prev); if (newSet.has(logId)) { newSet.delete(logId); } else { newSet.add(logId); } return newSet; }); }, []); const handleCopyLog = useCallback((log: LogEntry) => { navigator.clipboard.writeText(log.message); }, []); const filteredLogs = useMemo(() => { return logs.filter((log) => { if (!levelFilter.has(log.level)) return false; if (showRemoteOnly && log.source !== 'remote') return false; if (searchQuery) { const query = searchQuery.toLowerCase(); if (!log.message.toLowerCase().includes(query) && !log.source.toLowerCase().includes(query)) { return false; } } return true; }); }, [logs, levelFilter, showRemoteOnly, searchQuery]); const levelCounts = useMemo(() => ({ [LogLevel.Debug]: logs.filter((l) => l.level === LogLevel.Debug).length, [LogLevel.Info]: logs.filter((l) => l.level === LogLevel.Info).length, [LogLevel.Warn]: logs.filter((l) => l.level === LogLevel.Warn).length, [LogLevel.Error]: logs.filter((l) => l.level === LogLevel.Error || l.level === LogLevel.Fatal).length }), [logs]); const remoteLogCount = useMemo(() => logs.filter((l) => l.source === 'remote').length , [logs]); const activeFilterCount = useMemo(() => { let count = 0; if (!levelFilter.has(LogLevel.Debug)) count++; if (!levelFilter.has(LogLevel.Info)) count++; if (!levelFilter.has(LogLevel.Warn)) count++; if (!levelFilter.has(LogLevel.Error)) count++; if (showRemoteOnly) count++; return count; }, [levelFilter, showRemoteOnly]); return (
{/* Toolbar */}
setSearchQuery(e.target.value)} /> {searchQuery && ( )}
{/* Filter Dropdown */}
{showFilterMenu && (
{locale === 'zh' ? '日志级别' : 'Log Levels'}
)}
{/* Auto Scroll Toggle */} {/* Settings Dropdown */}
{showSettingsMenu && (
)}
{/* Close Button */} {onClose && ( )}
{/* Log Content */}
{filteredLogs.length === 0 ? (

{searchQuery ? (locale === 'zh' ? '没有匹配的日志' : 'No matching logs') : (locale === 'zh' ? '暂无日志' : 'No logs to display') }

) : ( filteredLogs.map((log, index) => ( toggleLogExpanded(String(log.id))} onCopy={() => handleCopyLog(log)} /> )) )}
{/* Status Bar */}
{filteredLogs.length} / {logs.length} {locale === 'zh' ? '条日志' : 'logs'} {!autoScroll && ( )}
); }