日志面板

This commit is contained in:
YHH
2025-10-15 17:21:59 +08:00
parent fb7a1b1282
commit 0a860920ad
5 changed files with 699 additions and 13 deletions

View File

@@ -1,11 +1,12 @@
import { useState, useEffect } from 'react';
import { Core, Scene } from '@esengine/ecs-framework';
import { EditorPluginManager, UIRegistry, MessageHub, SerializerRegistry, EntityStoreService, ComponentRegistry, LocaleService, ProjectService, ComponentDiscoveryService, ComponentLoaderService, PropertyMetadataService } from '@esengine/editor-core';
import { EditorPluginManager, UIRegistry, MessageHub, SerializerRegistry, EntityStoreService, ComponentRegistry, LocaleService, ProjectService, ComponentDiscoveryService, ComponentLoaderService, PropertyMetadataService, LogService } from '@esengine/editor-core';
import { SceneInspectorPlugin } from './plugins/SceneInspectorPlugin';
import { StartupPage } from './components/StartupPage';
import { SceneHierarchy } from './components/SceneHierarchy';
import { EntityInspector } from './components/EntityInspector';
import { AssetBrowser } from './components/AssetBrowser';
import { ConsolePanel } from './components/ConsolePanel';
import { DockContainer, DockablePanel } from './components/DockContainer';
import { TauriAPI } from './api/tauri';
import { useLocale } from './hooks/useLocale';
@@ -27,6 +28,7 @@ function App() {
const [pluginManager, setPluginManager] = useState<EditorPluginManager | null>(null);
const [entityStore, setEntityStore] = useState<EntityStoreService | null>(null);
const [messageHub, setMessageHub] = useState<MessageHub | null>(null);
const [logService, setLogService] = useState<LogService | null>(null);
const { t, locale, changeLocale } = useLocale();
const [status, setStatus] = useState(t('header.status.initializing'));
const [panels, setPanels] = useState<DockablePanel[]>([]);
@@ -48,6 +50,7 @@ function App() {
const componentDiscovery = new ComponentDiscoveryService(messageHub);
const componentLoader = new ComponentLoaderService(messageHub, componentRegistry);
const propertyMetadata = new PropertyMetadataService();
const logService = new LogService();
Core.services.registerInstance(UIRegistry, uiRegistry);
Core.services.registerInstance(MessageHub, messageHub);
@@ -58,6 +61,7 @@ function App() {
Core.services.registerInstance(ComponentDiscoveryService, componentDiscovery);
Core.services.registerInstance(ComponentLoaderService, componentLoader);
Core.services.registerInstance(PropertyMetadataService, propertyMetadata);
Core.services.registerInstance(LogService, logService);
const pluginMgr = new EditorPluginManager();
pluginMgr.initialize(coreInstance, Core.services);
@@ -71,6 +75,7 @@ function App() {
setPluginManager(pluginMgr);
setEntityStore(entityStore);
setMessageHub(messageHub);
setLogService(logService);
setStatus(t('header.status.ready'));
} catch (error) {
console.error('Failed to initialize editor:', error);
@@ -164,7 +169,7 @@ function App() {
};
useEffect(() => {
if (projectLoaded && entityStore && messageHub) {
if (projectLoaded && entityStore && messageHub && logService) {
setPanels([
{
id: 'scene-hierarchy',
@@ -203,21 +208,12 @@ function App() {
id: 'console',
title: locale === 'zh' ? '控制台' : 'Console',
position: 'bottom',
content: (
<div style={{ padding: '12px', height: '100%', overflow: 'auto' }}>
<h4 style={{ margin: '0 0 8px 0', fontSize: '12px', color: '#ffffff' }}>
{locale === 'zh' ? '控制台' : 'Console'}
</h4>
<p style={{ fontSize: '12px', color: '#858585' }}>
{locale === 'zh' ? '控制台输出将显示在这里...' : 'Console output will appear here...'}
</p>
</div>
),
content: <ConsolePanel logService={logService} />,
closable: false
}
]);
}
}, [projectLoaded, entityStore, messageHub, locale, currentProjectPath, t]);
}, [projectLoaded, entityStore, messageHub, logService, locale, currentProjectPath, t]);
const handlePanelMove = (panelId: string, newPosition: any) => {
setPanels(prevPanels =>

View File

@@ -0,0 +1,220 @@
import { useState, useEffect, useRef } from 'react';
import { LogService, LogEntry } from '@esengine/editor-core';
import { LogLevel } from '@esengine/ecs-framework';
import { Trash2, AlertCircle, Info, AlertTriangle, XCircle, Bug, Search, Filter } from 'lucide-react';
import '../styles/ConsolePanel.css';
interface ConsolePanelProps {
logService: LogService;
}
export function ConsolePanel({ logService }: ConsolePanelProps) {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [filter, setFilter] = useState('');
const [levelFilter, setLevelFilter] = useState<Set<LogLevel>>(new Set([
LogLevel.Debug,
LogLevel.Info,
LogLevel.Warn,
LogLevel.Error,
LogLevel.Fatal
]));
const [autoScroll, setAutoScroll] = useState(true);
const logContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setLogs(logService.getLogs());
const unsubscribe = logService.subscribe((entry) => {
setLogs(prev => [...prev, entry]);
});
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);
};
const filteredLogs = logs.filter(log => {
if (!levelFilter.has(log.level)) return false;
if (filter && !log.message.toLowerCase().includes(filter.toLowerCase())) {
return false;
}
return true;
});
const getLevelIcon = (level: LogLevel) => {
switch (level) {
case LogLevel.Debug:
return <Bug size={14} />;
case LogLevel.Info:
return <Info size={14} />;
case LogLevel.Warn:
return <AlertTriangle size={14} />;
case LogLevel.Error:
case LogLevel.Fatal:
return <XCircle size={14} />;
default:
return <AlertCircle size={14} />;
}
};
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 => {
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
};
const levelCounts = {
[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
};
return (
<div className="console-panel">
<div className="console-toolbar">
<div className="console-toolbar-left">
<button
className="console-btn"
onClick={handleClear}
title="Clear console"
>
<Trash2 size={14} />
</button>
<div className="console-search">
<Search size={14} />
<input
type="text"
placeholder="Filter logs..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
</div>
<div className="console-toolbar-right">
<button
className={`console-filter-btn ${levelFilter.has(LogLevel.Debug) ? 'active' : ''}`}
onClick={() => toggleLevelFilter(LogLevel.Debug)}
title="Debug"
>
<Bug size={14} />
{levelCounts[LogLevel.Debug] > 0 && <span>{levelCounts[LogLevel.Debug]}</span>}
</button>
<button
className={`console-filter-btn ${levelFilter.has(LogLevel.Info) ? 'active' : ''}`}
onClick={() => toggleLevelFilter(LogLevel.Info)}
title="Info"
>
<Info size={14} />
{levelCounts[LogLevel.Info] > 0 && <span>{levelCounts[LogLevel.Info]}</span>}
</button>
<button
className={`console-filter-btn ${levelFilter.has(LogLevel.Warn) ? 'active' : ''}`}
onClick={() => toggleLevelFilter(LogLevel.Warn)}
title="Warnings"
>
<AlertTriangle size={14} />
{levelCounts[LogLevel.Warn] > 0 && <span>{levelCounts[LogLevel.Warn]}</span>}
</button>
<button
className={`console-filter-btn ${levelFilter.has(LogLevel.Error) ? 'active' : ''}`}
onClick={() => toggleLevelFilter(LogLevel.Error)}
title="Errors"
>
<XCircle size={14} />
{levelCounts[LogLevel.Error] > 0 && <span>{levelCounts[LogLevel.Error]}</span>}
</button>
</div>
</div>
<div
className="console-content"
ref={logContainerRef}
onScroll={handleScroll}
>
{filteredLogs.length === 0 ? (
<div className="console-empty">
<AlertCircle size={32} />
<p>No logs to display</p>
</div>
) : (
filteredLogs.map(log => (
<div key={log.id} className={`log-entry ${getLevelClass(log.level)}`}>
<div className="log-entry-icon">
{getLevelIcon(log.level)}
</div>
<div className="log-entry-time">
{formatTime(log.timestamp)}
</div>
<div className="log-entry-source">
[{log.source}]
</div>
<div className="log-entry-message">
{log.message}
</div>
</div>
))
)}
</div>
{!autoScroll && (
<button
className="console-scroll-to-bottom"
onClick={() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
setAutoScroll(true);
}
}}
>
Scroll to bottom
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,282 @@
.console-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--color-bg-base);
position: relative;
}
.console-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
background: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border-default);
flex-shrink: 0;
gap: 8px;
}
.console-toolbar-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.console-toolbar-right {
display: flex;
align-items: center;
gap: 4px;
}
.console-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.console-btn:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
.console-search {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
max-width: 300px;
background: var(--color-bg-inset);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-sm);
padding: 4px 8px;
color: var(--color-text-tertiary);
}
.console-search:focus-within {
border-color: var(--color-primary);
color: var(--color-text-primary);
}
.console-search input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--color-text-primary);
font-size: 11px;
font-family: var(--font-family-mono);
}
.console-search input::placeholder {
color: var(--color-text-tertiary);
}
.console-filter-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 10px;
font-weight: 500;
transition: all var(--transition-fast);
opacity: 0.5;
}
.console-filter-btn:hover {
opacity: 1;
background: var(--color-bg-hover);
}
.console-filter-btn.active {
opacity: 1;
border-color: currentColor;
background: rgba(255, 255, 255, 0.05);
}
.console-filter-btn:nth-child(1) {
color: #858585;
}
.console-filter-btn:nth-child(1).active {
color: #a0a0a0;
border-color: #858585;
}
.console-filter-btn:nth-child(2) {
color: #4a9eff;
}
.console-filter-btn:nth-child(2).active {
color: #6eb3ff;
border-color: #4a9eff;
}
.console-filter-btn:nth-child(3) {
color: #ffc107;
}
.console-filter-btn:nth-child(3).active {
color: #ffd54f;
border-color: #ffc107;
}
.console-filter-btn:nth-child(4) {
color: #f44336;
}
.console-filter-btn:nth-child(4).active {
color: #ef5350;
border-color: #f44336;
}
.console-filter-btn span {
font-size: 10px;
font-family: var(--font-family-mono);
}
.console-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
font-family: var(--font-family-mono);
font-size: 11px;
line-height: 1.4;
}
.console-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--color-text-tertiary);
gap: 8px;
}
.console-empty p {
margin: 0;
font-size: 12px;
}
.log-entry {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 4px 12px;
border-bottom: 1px solid var(--color-border-subtle);
transition: background-color var(--transition-fast);
}
.log-entry:hover {
background: rgba(255, 255, 255, 0.02);
}
.log-entry-icon {
display: flex;
align-items: center;
padding-top: 2px;
flex-shrink: 0;
}
.log-entry-time {
color: var(--color-text-tertiary);
font-size: 10px;
white-space: nowrap;
padding-top: 2px;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.log-entry-source {
color: var(--color-text-secondary);
font-size: 10px;
white-space: nowrap;
padding-top: 2px;
flex-shrink: 0;
opacity: 0.7;
}
.log-entry-message {
flex: 1;
color: var(--color-text-primary);
word-break: break-word;
padding-top: 2px;
}
.log-entry-debug {
color: var(--color-text-tertiary);
}
.log-entry-debug .log-entry-icon {
color: #858585;
}
.log-entry-info .log-entry-icon {
color: #4a9eff;
}
.log-entry-warn {
background: rgba(255, 193, 7, 0.05);
}
.log-entry-warn .log-entry-icon {
color: #ffc107;
}
.log-entry-error {
background: rgba(244, 67, 54, 0.05);
}
.log-entry-error .log-entry-icon {
color: #f44336;
}
.console-scroll-to-bottom {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
padding: 6px 12px;
background: var(--color-primary);
color: var(--color-text-inverse);
border: none;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 500;
cursor: pointer;
box-shadow: var(--shadow-md);
transition: all var(--transition-fast);
z-index: 10;
}
.console-scroll-to-bottom:hover {
background: var(--color-primary-hover);
transform: translateX(-50%) translateY(-2px);
box-shadow: var(--shadow-lg);
}
.console-scroll-to-bottom:active {
transform: translateX(-50%) translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.console-btn,
.console-filter-btn,
.log-entry,
.console-scroll-to-bottom {
transition: none;
}
}

View File

@@ -0,0 +1,187 @@
import type { IService } from '@esengine/ecs-framework';
import { Injectable, LogLevel } from '@esengine/ecs-framework';
export interface LogEntry {
id: number;
timestamp: Date;
level: LogLevel;
source: string;
message: string;
args: unknown[];
}
export type LogListener = (entry: LogEntry) => void;
/**
* 编辑器日志服务
*
* 捕获框架和用户代码的所有日志输出并提供给UI层展示
*/
@Injectable()
export class LogService implements IService {
private logs: LogEntry[] = [];
private listeners: Set<LogListener> = new Set();
private nextId = 0;
private maxLogs = 1000;
private originalConsole = {
log: console.log.bind(console),
debug: console.debug.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console)
};
constructor() {
this.interceptConsole();
}
/**
* 拦截控制台输出
*/
private interceptConsole(): void {
console.log = (...args: unknown[]) => {
this.addLog(LogLevel.Info, 'console', this.formatMessage(args), args);
this.originalConsole.log(...args);
};
console.debug = (...args: unknown[]) => {
this.addLog(LogLevel.Debug, 'console', this.formatMessage(args), args);
this.originalConsole.debug(...args);
};
console.info = (...args: unknown[]) => {
this.addLog(LogLevel.Info, 'console', this.formatMessage(args), args);
this.originalConsole.info(...args);
};
console.warn = (...args: unknown[]) => {
this.addLog(LogLevel.Warn, 'console', this.formatMessage(args), args);
this.originalConsole.warn(...args);
};
console.error = (...args: unknown[]) => {
this.addLog(LogLevel.Error, 'console', this.formatMessage(args), args);
this.originalConsole.error(...args);
};
window.addEventListener('error', (event) => {
this.addLog(
LogLevel.Error,
'error',
event.message,
[event.error]
);
});
window.addEventListener('unhandledrejection', (event) => {
this.addLog(
LogLevel.Error,
'promise',
`Unhandled Promise Rejection: ${event.reason}`,
[event.reason]
);
});
}
/**
* 格式化消息
*/
private formatMessage(args: unknown[]): string {
return args.map(arg => {
if (typeof arg === 'string') return arg;
if (arg instanceof Error) return arg.message;
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
}).join(' ');
}
/**
* 添加日志
*/
private addLog(level: LogLevel, source: string, message: string, args: unknown[]): void {
const entry: LogEntry = {
id: this.nextId++,
timestamp: new Date(),
level,
source,
message,
args
};
this.logs.push(entry);
if (this.logs.length > this.maxLogs) {
this.logs.shift();
}
this.notifyListeners(entry);
}
/**
* 通知监听器
*/
private notifyListeners(entry: LogEntry): void {
for (const listener of this.listeners) {
try {
listener(entry);
} catch (error) {
this.originalConsole.error('Error in log listener:', error);
}
}
}
/**
* 获取所有日志
*/
public getLogs(): LogEntry[] {
return [...this.logs];
}
/**
* 清空日志
*/
public clear(): void {
this.logs = [];
this.notifyListeners({
id: -1,
timestamp: new Date(),
level: LogLevel.Info,
source: 'system',
message: 'Logs cleared',
args: []
});
}
/**
* 订阅日志更新
*/
public subscribe(listener: LogListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/**
* 设置最大日志数量
*/
public setMaxLogs(max: number): void {
this.maxLogs = max;
while (this.logs.length > this.maxLogs) {
this.logs.shift();
}
}
public dispose(): void {
console.log = this.originalConsole.log;
console.debug = this.originalConsole.debug;
console.info = this.originalConsole.info;
console.warn = this.originalConsole.warn;
console.error = this.originalConsole.error;
this.listeners.clear();
this.logs = [];
}
}

View File

@@ -17,5 +17,6 @@ export * from './Services/PropertyMetadata';
export * from './Services/ProjectService';
export * from './Services/ComponentDiscoveryService';
export * from './Services/ComponentLoaderService';
export * from './Services/LogService';
export * from './Types/UITypes';