diff --git a/packages/editor-app/src/App.tsx b/packages/editor-app/src/App.tsx index 44b17052..bd771323 100644 --- a/packages/editor-app/src/App.tsx +++ b/packages/editor-app/src/App.tsx @@ -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(null); const [entityStore, setEntityStore] = useState(null); const [messageHub, setMessageHub] = useState(null); + const [logService, setLogService] = useState(null); const { t, locale, changeLocale } = useLocale(); const [status, setStatus] = useState(t('header.status.initializing')); const [panels, setPanels] = useState([]); @@ -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: ( -
-

- {locale === 'zh' ? '控制台' : 'Console'} -

-

- {locale === 'zh' ? '控制台输出将显示在这里...' : 'Console output will appear here...'} -

-
- ), + content: , closable: false } ]); } - }, [projectLoaded, entityStore, messageHub, locale, currentProjectPath, t]); + }, [projectLoaded, entityStore, messageHub, logService, locale, currentProjectPath, t]); const handlePanelMove = (panelId: string, newPosition: any) => { setPanels(prevPanels => diff --git a/packages/editor-app/src/components/ConsolePanel.tsx b/packages/editor-app/src/components/ConsolePanel.tsx new file mode 100644 index 00000000..87bae871 --- /dev/null +++ b/packages/editor-app/src/components/ConsolePanel.tsx @@ -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([]); + const [filter, setFilter] = useState(''); + const [levelFilter, setLevelFilter] = useState>(new Set([ + LogLevel.Debug, + LogLevel.Info, + LogLevel.Warn, + LogLevel.Error, + LogLevel.Fatal + ])); + const [autoScroll, setAutoScroll] = useState(true); + const logContainerRef = useRef(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 ; + 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 => { + 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 ( +
+
+
+ +
+ + setFilter(e.target.value)} + /> +
+
+
+ + + + +
+
+
+ {filteredLogs.length === 0 ? ( +
+ +

No logs to display

+
+ ) : ( + filteredLogs.map(log => ( +
+
+ {getLevelIcon(log.level)} +
+
+ {formatTime(log.timestamp)} +
+
+ [{log.source}] +
+
+ {log.message} +
+
+ )) + )} +
+ {!autoScroll && ( + + )} +
+ ); +} diff --git a/packages/editor-app/src/styles/ConsolePanel.css b/packages/editor-app/src/styles/ConsolePanel.css new file mode 100644 index 00000000..5c4b622c --- /dev/null +++ b/packages/editor-app/src/styles/ConsolePanel.css @@ -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; + } +} diff --git a/packages/editor-core/src/Services/LogService.ts b/packages/editor-core/src/Services/LogService.ts new file mode 100644 index 00000000..ae9602a8 --- /dev/null +++ b/packages/editor-core/src/Services/LogService.ts @@ -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 = 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 = []; + } +} diff --git a/packages/editor-core/src/index.ts b/packages/editor-core/src/index.ts index 59c10972..14610f76 100644 --- a/packages/editor-core/src/index.ts +++ b/packages/editor-core/src/index.ts @@ -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';