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 */}
{/* Filter Dropdown */}
{showFilterMenu && (
)}
{/* 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 && (
)}
);
}