import { useState, useEffect, useRef, useMemo, memo } from 'react'; import { LogService, LogEntry } from '@esengine/editor-core'; import { LogLevel } from '@esengine/ecs-framework'; import { Trash2, AlertCircle, Info, AlertTriangle, XCircle, Bug, Search, Maximize2, ChevronRight, ChevronDown, Wifi } from 'lucide-react'; import { JsonViewer } from './JsonViewer'; import '../styles/ConsolePanel.css'; interface ConsolePanelProps { logService: LogService; } interface ParsedLogData { isJSON: boolean; jsonStr?: string; extracted?: { prefix: string; json: string; suffix: string } | null; } const LogEntryItem = memo(({ log, isExpanded, onToggleExpand, onOpenJsonViewer, parsedData }: { log: LogEntry; isExpanded: boolean; onToggleExpand: (id: number) => void; onOpenJsonViewer: (jsonStr: string) => void; parsedData: ParsedLogData; }) => { const getLevelIcon = (level: LogLevel) => { switch (level) { case LogLevel.Debug: return ; case LogLevel.Info: return ; case LogLevel.Warn: return ; case LogLevel.Error: case LogLevel.Fatal: return ; default: return ; } }; const getLevelClass = (level: LogLevel): string => { switch (level) { case LogLevel.Debug: return 'log-entry-debug'; case LogLevel.Info: return 'log-entry-info'; case LogLevel.Warn: return 'log-entry-warn'; case LogLevel.Error: case LogLevel.Fatal: return 'log-entry-error'; default: return ''; } }; const 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}`; }; const formatMessage = (message: string, isExpanded: boolean, parsedData: ParsedLogData): JSX.Element => { const MAX_PREVIEW_LENGTH = 200; const { isJSON, jsonStr, extracted } = parsedData; const shouldTruncate = message.length > MAX_PREVIEW_LENGTH && !isExpanded; return (
{shouldTruncate ? ( <> {extracted && extracted.prefix && {extracted.prefix} } {message.substring(0, MAX_PREVIEW_LENGTH)}... ) : ( {message} )}
{isJSON && jsonStr && ( )}
); }; const shouldShowExpander = log.message.length > 200; return (
{shouldShowExpander && (
onToggleExpand(log.id)} > {isExpanded ? : }
)}
{getLevelIcon(log.level)}
{formatTime(log.timestamp)}
[{log.source === 'remote' ? '🌐 Remote' : log.source}]
{log.clientId && (
{log.clientId}
)}
{formatMessage(log.message, isExpanded, parsedData)}
); }); LogEntryItem.displayName = 'LogEntryItem'; const MAX_LOGS = 1000; export function ConsolePanel({ logService }: ConsolePanelProps) { const [logs, setLogs] = useState([]); const [filter, setFilter] = 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 [expandedLogs, setExpandedLogs] = useState>(new Set()); const [jsonViewerData, setJsonViewerData] = useState(null); const logContainerRef = useRef(null); useEffect(() => { setLogs(logService.getLogs().slice(-MAX_LOGS)); const unsubscribe = logService.subscribe((entry) => { setLogs((prev) => { const newLogs = [...prev, entry]; if (newLogs.length > MAX_LOGS) { return newLogs.slice(-MAX_LOGS); } return newLogs; }); }); return unsubscribe; }, [logService]); useEffect(() => { if (autoScroll && logContainerRef.current) { logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; } }, [logs, autoScroll]); const handleClear = () => { logService.clear(); setLogs([]); }; const handleScroll = () => { if (logContainerRef.current) { const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current; const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10; setAutoScroll(isAtBottom); } }; const toggleLevelFilter = (level: LogLevel) => { const newFilter = new Set(levelFilter); if (newFilter.has(level)) { newFilter.delete(level); } else { newFilter.add(level); } setLevelFilter(newFilter); }; // 使用ref保存缓存,避免每次都重新计算 const parsedLogsCacheRef = useRef>(new Map()); const extractJSON = useMemo(() => { return (message: string): { prefix: string; json: string; suffix: string } | null => { // 快速路径:如果消息太短,直接返回 if (message.length < 2) return null; const jsonStartChars = ['{', '[']; let startIndex = -1; for (const char of jsonStartChars) { const index = message.indexOf(char); if (index !== -1 && (startIndex === -1 || index < startIndex)) { startIndex = index; } } if (startIndex === -1) return null; // 使用栈匹配算法,更高效地找到JSON边界 const startChar = message[startIndex]; const endChar = startChar === '{' ? '}' : ']'; let depth = 0; let inString = false; let escape = false; for (let i = startIndex; i < message.length; i++) { const char = message[i]; if (escape) { escape = false; continue; } if (char === '\\') { escape = true; continue; } if (char === '"') { inString = !inString; continue; } if (inString) continue; if (char === startChar) { depth++; } else if (char === endChar) { depth--; if (depth === 0) { // 找到匹配的结束符 const possibleJson = message.substring(startIndex, i + 1); try { JSON.parse(possibleJson); return { prefix: message.substring(0, startIndex).trim(), json: possibleJson, suffix: message.substring(i + 1).trim() }; } catch { return null; } } } } return null; }; }, []); const parsedLogsCache = useMemo(() => { const cache = parsedLogsCacheRef.current; // 只处理新增的日志 for (const log of logs) { // 如果已经缓存过,跳过 if (cache.has(log.id)) continue; try { JSON.parse(log.message); cache.set(log.id, { isJSON: true, jsonStr: log.message, extracted: null }); } catch { const extracted = extractJSON(log.message); if (extracted) { try { JSON.parse(extracted.json); cache.set(log.id, { isJSON: true, jsonStr: extracted.json, extracted }); } catch { cache.set(log.id, { isJSON: false, extracted }); } } else { cache.set(log.id, { isJSON: false, extracted: null }); } } } // 清理不再需要的缓存(日志被删除) const logIds = new Set(logs.map((log) => log.id)); for (const cachedId of cache.keys()) { if (!logIds.has(cachedId)) { cache.delete(cachedId); } } return cache; }, [logs, extractJSON]); const filteredLogs = useMemo(() => { return logs.filter((log) => { if (!levelFilter.has(log.level)) return false; if (showRemoteOnly && log.source !== 'remote') return false; if (filter && !log.message.toLowerCase().includes(filter.toLowerCase())) { return false; } return true; }); }, [logs, levelFilter, showRemoteOnly, filter]); const toggleLogExpand = (logId: number) => { const newExpanded = new Set(expandedLogs); if (newExpanded.has(logId)) { newExpanded.delete(logId); } else { newExpanded.add(logId); } setExpandedLogs(newExpanded); }; const openJsonViewer = (jsonStr: string) => { try { const parsed = JSON.parse(jsonStr); setJsonViewerData(parsed); } catch { console.error('Failed to parse JSON:', jsonStr); } }; 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]); return (
setFilter(e.target.value)} />
{filteredLogs.length === 0 ? (

No logs to display

) : ( filteredLogs.map((log) => ( )) )}
{jsonViewerData && ( setJsonViewerData(null)} /> )} {!autoScroll && ( )}
); }