远程读取日志

This commit is contained in:
YHH
2025-10-16 17:10:22 +08:00
parent 1ec7892338
commit 43bdd7e43b
16 changed files with 966 additions and 80 deletions

View File

@@ -38,6 +38,13 @@ export class DebugManager implements IService, IUpdatable {
private lastSendTime: number = 0; private lastSendTime: number = 0;
private sendInterval: number; private sendInterval: number;
private isRunning: boolean = false; private isRunning: boolean = false;
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( constructor(
@Inject(SceneManager) sceneManager: SceneManager, @Inject(SceneManager) sceneManager: SceneManager,
@@ -68,6 +75,9 @@ export class DebugManager implements IService, IUpdatable {
const debugFrameRate = this.config.debugFrameRate || 30; const debugFrameRate = this.config.debugFrameRate || 30;
this.sendInterval = 1000 / debugFrameRate; this.sendInterval = 1000 / debugFrameRate;
// 拦截 console 日志
this.interceptConsole();
this.start(); this.start();
} }
@@ -91,6 +101,118 @@ export class DebugManager implements IService, IUpdatable {
this.webSocketManager.disconnect(); this.webSocketManager.disconnect();
} }
/**
* 拦截 console 日志并转发到编辑器
*/
private interceptConsole(): void {
console.log = (...args: unknown[]) => {
this.sendLog('info', this.formatLogMessage(args));
this.originalConsole.log(...args);
};
console.debug = (...args: unknown[]) => {
this.sendLog('debug', this.formatLogMessage(args));
this.originalConsole.debug(...args);
};
console.info = (...args: unknown[]) => {
this.sendLog('info', this.formatLogMessage(args));
this.originalConsole.info(...args);
};
console.warn = (...args: unknown[]) => {
this.sendLog('warn', this.formatLogMessage(args));
this.originalConsole.warn(...args);
};
console.error = (...args: unknown[]) => {
this.sendLog('error', this.formatLogMessage(args));
this.originalConsole.error(...args);
};
}
/**
* 格式化日志消息
*/
private formatLogMessage(args: unknown[]): string {
return args.map(arg => {
if (typeof arg === 'string') return arg;
if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
if (arg === null) return 'null';
if (arg === undefined) return 'undefined';
if (typeof arg === 'object') {
try {
return this.safeStringify(arg, 6);
} catch {
return Object.prototype.toString.call(arg);
}
}
return String(arg);
}).join(' ');
}
/**
* 安全的 JSON 序列化,支持循环引用和深度限制
*/
private safeStringify(obj: any, maxDepth: number = 6): string {
const seen = new WeakSet();
const stringify = (value: any, depth: number): any => {
if (value === null) return null;
if (value === undefined) return undefined;
if (typeof value !== 'object') return value;
if (depth >= maxDepth) {
return '[Max Depth Reached]';
}
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
if (Array.isArray(value)) {
const result = value.map(item => stringify(item, depth + 1));
seen.delete(value);
return result;
}
const result: any = {};
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
result[key] = stringify(value[key], depth + 1);
}
}
seen.delete(value);
return result;
};
return JSON.stringify(stringify(obj, 0));
}
/**
* 发送日志到编辑器
*/
private sendLog(level: string, message: string): void {
if (!this.webSocketManager.getConnectionStatus()) {
return;
}
try {
this.webSocketManager.send({
type: 'log',
data: {
level,
message,
timestamp: new Date().toISOString()
}
});
} catch (error) {
// 静默失败,避免递归日志
}
}
/** /**
* 更新配置 * 更新配置
*/ */
@@ -829,5 +951,12 @@ export class DebugManager implements IService, IUpdatable {
*/ */
public dispose(): void { public dispose(): void {
this.stop(); this.stop();
// 恢复原始 console 方法
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;
} }
} }

View File

@@ -17,6 +17,7 @@ import { Viewport } from './components/Viewport';
import { MenuBar } from './components/MenuBar'; import { MenuBar } from './components/MenuBar';
import { DockContainer, DockablePanel } from './components/DockContainer'; import { DockContainer, DockablePanel } from './components/DockContainer';
import { TauriAPI } from './api/tauri'; import { TauriAPI } from './api/tauri';
import { SettingsService } from './services/SettingsService';
import { useLocale } from './hooks/useLocale'; import { useLocale } from './hooks/useLocale';
import { en, zh } from './locales'; import { en, zh } from './locales';
import { Loader2, Globe } from 'lucide-react'; import { Loader2, Globe } from 'lucide-react';
@@ -51,6 +52,7 @@ function App() {
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0); const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0);
const [isRemoteConnected, setIsRemoteConnected] = useState(false); const [isRemoteConnected, setIsRemoteConnected] = useState(false);
const [isProfilerMode, setIsProfilerMode] = useState(false);
useEffect(() => { useEffect(() => {
// 禁用默认右键菜单 // 禁用默认右键菜单
@@ -144,6 +146,12 @@ function App() {
const logService = new LogService(); const logService = new LogService();
const settingsRegistry = new SettingsRegistry(); const settingsRegistry = new SettingsRegistry();
// 监听远程日志事件
window.addEventListener('profiler:remote-log', ((event: CustomEvent) => {
const { level, message, timestamp } = event.detail;
logService.addRemoteLog(level, message, timestamp);
}) as EventListener);
Core.services.registerInstance(UIRegistry, uiRegistry); Core.services.registerInstance(UIRegistry, uiRegistry);
Core.services.registerInstance(MessageHub, messageHub); Core.services.registerInstance(MessageHub, messageHub);
Core.services.registerInstance(SerializerRegistry, serializerRegistry); Core.services.registerInstance(SerializerRegistry, serializerRegistry);
@@ -196,11 +204,8 @@ function App() {
initializeEditor(); initializeEditor();
}, []); }, []);
const handleOpenProject = async () => { const handleOpenRecentProject = async (projectPath: string) => {
try { try {
const projectPath = await TauriAPI.openProjectDialog();
if (!projectPath) return;
setIsLoading(true); setIsLoading(true);
setLoadingMessage(locale === 'zh' ? '正在打开项目...' : 'Opening project...'); setLoadingMessage(locale === 'zh' ? '正在打开项目...' : 'Opening project...');
@@ -249,6 +254,9 @@ function App() {
setStatus(t('header.status.projectOpened')); setStatus(t('header.status.projectOpened'));
} }
const settings = SettingsService.getInstance();
settings.addRecentProject(projectPath);
setCurrentProjectPath(projectPath); setCurrentProjectPath(projectPath);
setProjectLoaded(true); setProjectLoaded(true);
setIsLoading(false); setIsLoading(false);
@@ -259,10 +267,27 @@ function App() {
} }
}; };
const handleOpenProject = async () => {
try {
const projectPath = await TauriAPI.openProjectDialog();
if (!projectPath) return;
await handleOpenRecentProject(projectPath);
} catch (error) {
console.error('Failed to open project dialog:', error);
}
};
const handleCreateProject = async () => { const handleCreateProject = async () => {
console.log('Create project not implemented yet'); console.log('Create project not implemented yet');
}; };
const handleProfilerMode = async () => {
setIsProfilerMode(true);
setProjectLoaded(true);
setStatus(t('header.status.profilerMode') || 'Profiler Mode - Waiting for connection...');
};
const handleNewScene = () => { const handleNewScene = () => {
console.log('New scene not implemented yet'); console.log('New scene not implemented yet');
}; };
@@ -282,6 +307,7 @@ function App() {
const handleCloseProject = () => { const handleCloseProject = () => {
setProjectLoaded(false); setProjectLoaded(false);
setCurrentProjectPath(null); setCurrentProjectPath(null);
setIsProfilerMode(false);
setStatus(t('header.status.ready')); setStatus(t('header.status.ready'));
}; };
@@ -304,43 +330,71 @@ function App() {
useEffect(() => { useEffect(() => {
if (projectLoaded && entityStore && messageHub && logService && uiRegistry && pluginManager) { if (projectLoaded && entityStore && messageHub && logService && uiRegistry && pluginManager) {
const corePanels: DockablePanel[] = [ let corePanels: DockablePanel[];
{
id: 'scene-hierarchy', if (isProfilerMode) {
title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy', corePanels = [
position: 'left', {
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} />, id: 'scene-hierarchy',
closable: false title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
}, position: 'left',
{ content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} />,
id: 'inspector', closable: false
title: locale === 'zh' ? '检视器' : 'Inspector', },
position: 'right', {
content: <EntityInspector entityStore={entityStore} messageHub={messageHub} />, id: 'inspector',
closable: false title: locale === 'zh' ? '检视器' : 'Inspector',
}, position: 'right',
{ content: <EntityInspector entityStore={entityStore} messageHub={messageHub} />,
id: 'viewport', closable: false
title: locale === 'zh' ? '视口' : 'Viewport', },
position: 'center', {
content: <Viewport locale={locale} />, id: 'console',
closable: false title: locale === 'zh' ? '控制台' : 'Console',
}, position: 'bottom',
{ content: <ConsolePanel logService={logService} />,
id: 'assets', closable: false
title: locale === 'zh' ? '资产' : 'Assets', }
position: 'bottom', ];
content: <AssetBrowser projectPath={currentProjectPath} locale={locale} />, } else {
closable: false corePanels = [
}, {
{ id: 'scene-hierarchy',
id: 'console', title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
title: locale === 'zh' ? '控制台' : 'Console', position: 'left',
position: 'bottom', content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} />,
content: <ConsolePanel logService={logService} />, closable: false
closable: false },
} {
]; id: 'inspector',
title: locale === 'zh' ? '检视器' : 'Inspector',
position: 'right',
content: <EntityInspector entityStore={entityStore} messageHub={messageHub} />,
closable: false
},
{
id: 'viewport',
title: locale === 'zh' ? '视口' : 'Viewport',
position: 'center',
content: <Viewport locale={locale} />,
closable: false
},
{
id: 'assets',
title: locale === 'zh' ? '资产' : 'Assets',
position: 'bottom',
content: <AssetBrowser projectPath={currentProjectPath} locale={locale} />,
closable: false
},
{
id: 'console',
title: locale === 'zh' ? '控制台' : 'Console',
position: 'bottom',
content: <ConsolePanel logService={logService} />,
closable: false
}
];
}
const enabledPlugins = pluginManager.getAllPluginMetadata() const enabledPlugins = pluginManager.getAllPluginMetadata()
.filter(p => p.enabled) .filter(p => p.enabled)
@@ -374,7 +428,7 @@ function App() {
console.log('[App] Loading plugin panels:', pluginPanels); console.log('[App] Loading plugin panels:', pluginPanels);
setPanels([...corePanels, ...pluginPanels]); setPanels([...corePanels, ...pluginPanels]);
} }
}, [projectLoaded, entityStore, messageHub, logService, uiRegistry, pluginManager, locale, currentProjectPath, t, pluginUpdateTrigger]); }, [projectLoaded, entityStore, messageHub, logService, uiRegistry, pluginManager, locale, currentProjectPath, t, pluginUpdateTrigger, isProfilerMode]);
const handlePanelMove = (panelId: string, newPosition: any) => { const handlePanelMove = (panelId: string, newPosition: any) => {
setPanels(prevPanels => setPanels(prevPanels =>
@@ -394,12 +448,17 @@ function App() {
} }
if (!projectLoaded) { if (!projectLoaded) {
const settings = SettingsService.getInstance();
const recentProjects = settings.getRecentProjects();
return ( return (
<> <>
<StartupPage <StartupPage
onOpenProject={handleOpenProject} onOpenProject={handleOpenProject}
onCreateProject={handleCreateProject} onCreateProject={handleCreateProject}
recentProjects={[]} onOpenRecentProject={handleOpenRecentProject}
onProfilerMode={handleProfilerMode}
recentProjects={recentProjects}
locale={locale} locale={locale}
/> />
{isLoading && ( {isLoading && (

View File

@@ -1,7 +1,8 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { LogService, LogEntry } from '@esengine/editor-core'; import { LogService, LogEntry } from '@esengine/editor-core';
import { LogLevel } from '@esengine/ecs-framework'; import { LogLevel } from '@esengine/ecs-framework';
import { Trash2, AlertCircle, Info, AlertTriangle, XCircle, Bug, Search, Filter } from 'lucide-react'; import { Trash2, AlertCircle, Info, AlertTriangle, XCircle, Bug, Search, Maximize2, ChevronRight, ChevronDown } from 'lucide-react';
import { JsonViewer } from './JsonViewer';
import '../styles/ConsolePanel.css'; import '../styles/ConsolePanel.css';
interface ConsolePanelProps { interface ConsolePanelProps {
@@ -19,6 +20,8 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
LogLevel.Fatal LogLevel.Fatal
])); ]));
const [autoScroll, setAutoScroll] = useState(true); const [autoScroll, setAutoScroll] = useState(true);
const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
const [jsonViewerData, setJsonViewerData] = useState<any>(null);
const logContainerRef = useRef<HTMLDivElement>(null); const logContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -110,6 +113,110 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
}); });
}; };
const toggleLogExpand = (logId: number) => {
const newExpanded = new Set(expandedLogs);
if (newExpanded.has(logId)) {
newExpanded.delete(logId);
} else {
newExpanded.add(logId);
}
setExpandedLogs(newExpanded);
};
const extractJSON = (message: string): { prefix: string; json: string; suffix: string } | 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;
for (let endIndex = message.length; endIndex > startIndex; endIndex--) {
const possibleJson = message.substring(startIndex, endIndex);
try {
JSON.parse(possibleJson);
return {
prefix: message.substring(0, startIndex).trim(),
json: possibleJson,
suffix: message.substring(endIndex).trim()
};
} catch {
continue;
}
}
return null;
};
const tryParseJSON = (message: string): { isJSON: boolean; parsed?: any; jsonStr?: string } => {
try {
const parsed = JSON.parse(message);
return { isJSON: true, parsed, jsonStr: message };
} catch {
const extracted = extractJSON(message);
if (extracted) {
try {
const parsed = JSON.parse(extracted.json);
return { isJSON: true, parsed, jsonStr: extracted.json };
} catch {
return { isJSON: false };
}
}
return { isJSON: false };
}
};
const openJsonViewer = (jsonStr: string) => {
try {
const parsed = JSON.parse(jsonStr);
setJsonViewerData(parsed);
} catch {
console.error('Failed to parse JSON:', jsonStr);
}
};
const formatMessage = (message: string, isExpanded: boolean): JSX.Element => {
const MAX_PREVIEW_LENGTH = 200;
const { isJSON, jsonStr } = tryParseJSON(message);
const extracted = extractJSON(message);
const shouldTruncate = message.length > MAX_PREVIEW_LENGTH && !isExpanded;
return (
<div className="log-message-container">
<div className="log-message-text">
{shouldTruncate ? (
<>
{extracted && extracted.prefix && <span>{extracted.prefix} </span>}
<span className="log-message-preview">
{message.substring(0, MAX_PREVIEW_LENGTH)}...
</span>
</>
) : (
<span>{message}</span>
)}
</div>
{isJSON && jsonStr && (
<button
className="log-open-json-btn"
onClick={(e) => {
e.stopPropagation();
openJsonViewer(jsonStr);
}}
title="Open in JSON Viewer"
>
<Maximize2 size={12} />
</button>
)}
</div>
);
};
const levelCounts = { const levelCounts = {
[LogLevel.Debug]: logs.filter(l => l.level === LogLevel.Debug).length, [LogLevel.Debug]: logs.filter(l => l.level === LogLevel.Debug).length,
[LogLevel.Info]: logs.filter(l => l.level === LogLevel.Info).length, [LogLevel.Info]: logs.filter(l => l.level === LogLevel.Info).length,
@@ -184,24 +291,46 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
<p>No logs to display</p> <p>No logs to display</p>
</div> </div>
) : ( ) : (
filteredLogs.map(log => ( filteredLogs.map(log => {
<div key={log.id} className={`log-entry ${getLevelClass(log.level)}`}> const isExpanded = expandedLogs.has(log.id);
<div className="log-entry-icon"> const shouldShowExpander = log.message.length > 200;
{getLevelIcon(log.level)}
return (
<div
key={log.id}
className={`log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''} ${isExpanded ? 'log-entry-expanded' : ''}`}
>
{shouldShowExpander && (
<div
className="log-entry-expander"
onClick={() => toggleLogExpand(log.id)}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</div>
)}
<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 === 'remote' ? 'source-remote' : ''}`}>
[{log.source === 'remote' ? '🌐 Remote' : log.source}]
</div>
<div className="log-entry-message">
{formatMessage(log.message, isExpanded)}
</div>
</div> </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> </div>
{jsonViewerData && (
<JsonViewer
data={jsonViewerData}
onClose={() => setJsonViewerData(null)}
/>
)}
{!autoScroll && ( {!autoScroll && (
<button <button
className="console-scroll-to-bottom" className="console-scroll-to-bottom"

View File

@@ -0,0 +1,139 @@
import { useState } from 'react';
import { X, ChevronRight, ChevronDown, Copy, Check } from 'lucide-react';
import '../styles/JsonViewer.css';
interface JsonViewerProps {
data: any;
onClose: () => void;
}
export function JsonViewer({ data, onClose }: JsonViewerProps) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(JSON.stringify(data, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="json-viewer-overlay" onClick={onClose}>
<div className="json-viewer-modal" onClick={(e) => e.stopPropagation()}>
<div className="json-viewer-header">
<h3>JSON Viewer</h3>
<div className="json-viewer-actions">
<button
className="json-viewer-btn"
onClick={handleCopy}
title="Copy JSON"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</button>
<button
className="json-viewer-btn"
onClick={onClose}
title="Close"
>
<X size={16} />
</button>
</div>
</div>
<div className="json-viewer-content">
<JsonTree data={data} name="root" />
</div>
</div>
</div>
);
}
interface JsonTreeProps {
data: any;
name: string;
level?: number;
}
function JsonTree({ data, name, level = 0 }: JsonTreeProps) {
const [expanded, setExpanded] = useState(level < 2);
const getValueType = (value: any): string => {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
return typeof value;
};
const getValueColor = (type: string): string => {
switch (type) {
case 'string': return 'json-string';
case 'number': return 'json-number';
case 'boolean': return 'json-boolean';
case 'null': return 'json-null';
case 'array': return 'json-array';
case 'object': return 'json-object';
default: return '';
}
};
const renderValue = (value: any): JSX.Element => {
const type = getValueType(value);
const colorClass = getValueColor(type);
if (type === 'object' || type === 'array') {
const isArray = Array.isArray(value);
const keys = Object.keys(value);
const preview = isArray
? `Array(${value.length})`
: `Object {${keys.length} ${keys.length === 1 ? 'key' : 'keys'}}`;
return (
<div className="json-tree-node">
<div
className="json-tree-header"
onClick={() => setExpanded(!expanded)}
>
<span className="json-tree-expander">
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<span className="json-tree-key">{name}:</span>
<span className={`json-tree-preview ${colorClass}`}>
{preview}
</span>
</div>
{expanded && (
<div className="json-tree-children">
{isArray ? (
value.map((item: any, index: number) => (
<JsonTree
key={index}
data={item}
name={`[${index}]`}
level={level + 1}
/>
))
) : (
Object.entries(value).map(([key, val]) => (
<JsonTree
key={key}
data={val}
name={key}
level={level + 1}
/>
))
)}
</div>
)}
</div>
);
}
return (
<div className="json-tree-leaf">
<span className="json-tree-key">{name}:</span>
<span className={`json-tree-value ${colorClass}`}>
{type === 'string' ? `"${value}"` : String(value)}
</span>
</div>
);
};
return renderValue(data);
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Activity, Cpu, Layers, Package, Wifi, WifiOff, Maximize2 } from 'lucide-react'; import { Activity, Cpu, Layers, Package, Wifi, WifiOff, Maximize2, Pause, Play } from 'lucide-react';
import { ProfilerService, ProfilerData } from '../services/ProfilerService'; import { ProfilerService, ProfilerData } from '../services/ProfilerService';
import { SettingsService } from '../services/SettingsService'; import { SettingsService } from '../services/SettingsService';
import { Core } from '@esengine/ecs-framework'; import { Core } from '@esengine/ecs-framework';
@@ -11,6 +11,7 @@ export function ProfilerDockPanel() {
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [isServerRunning, setIsServerRunning] = useState(false); const [isServerRunning, setIsServerRunning] = useState(false);
const [port, setPort] = useState('8080'); const [port, setPort] = useState('8080');
const [isPaused, setIsPaused] = useState(false);
useEffect(() => { useEffect(() => {
const settings = SettingsService.getInstance(); const settings = SettingsService.getInstance();
@@ -42,7 +43,9 @@ export function ProfilerDockPanel() {
// 订阅数据更新 // 订阅数据更新
const unsubscribe = profilerService.subscribe((data: ProfilerData) => { const unsubscribe = profilerService.subscribe((data: ProfilerData) => {
setProfilerData(data); if (!isPaused) {
setProfilerData(data);
}
}); });
// 定期检查连接状态 // 定期检查连接状态
@@ -58,7 +61,7 @@ export function ProfilerDockPanel() {
unsubscribe(); unsubscribe();
clearInterval(interval); clearInterval(interval);
}; };
}, []); }, [isPaused]);
const fps = profilerData?.fps || 0; const fps = profilerData?.fps || 0;
const totalFrameTime = profilerData?.totalFrameTime || 0; const totalFrameTime = profilerData?.totalFrameTime || 0;
@@ -74,19 +77,32 @@ export function ProfilerDockPanel() {
} }
}; };
const handleTogglePause = () => {
setIsPaused(!isPaused);
};
return ( return (
<div className="profiler-dock-panel"> <div className="profiler-dock-panel">
<div className="profiler-dock-header"> <div className="profiler-dock-header">
<h3>Performance Monitor</h3> <h3>Performance Monitor</h3>
<div className="profiler-dock-header-actions"> <div className="profiler-dock-header-actions">
{isConnected && ( {isConnected && (
<button <>
className="profiler-dock-details-btn" <button
onClick={handleOpenDetails} className="profiler-dock-pause-btn"
title="Open detailed profiler" onClick={handleTogglePause}
> title={isPaused ? 'Resume data updates' : 'Pause data updates'}
<Maximize2 size={14} /> >
</button> {isPaused ? <Play size={14} /> : <Pause size={14} />}
</button>
<button
className="profiler-dock-details-btn"
onClick={handleOpenDetails}
title="Open detailed profiler"
>
<Maximize2 size={14} />
</button>
</>
)} )}
<div className="profiler-dock-status"> <div className="profiler-dock-status">
{isConnected ? ( {isConnected ? (

View File

@@ -56,8 +56,24 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
setIsRemoteConnected(connected); setIsRemoteConnected(connected);
if (connected && data.entities && data.entities.length > 0) { if (connected && data.entities && data.entities.length > 0) {
setRemoteEntities(data.entities); // 只在实体列表发生实质性变化时才更新
} else { setRemoteEntities(prev => {
if (prev.length !== data.entities!.length) {
return data.entities!;
}
// 检查实体ID和名称是否变化
const hasChanged = data.entities!.some((entity, index) => {
const prevEntity = prev[index];
return !prevEntity ||
prevEntity.id !== entity.id ||
prevEntity.name !== entity.name ||
prevEntity.componentCount !== entity.componentCount;
});
return hasChanged ? data.entities! : prev;
});
} else if (!connected) {
setRemoteEntities([]); setRemoteEntities([]);
} }
}); });

View File

@@ -4,11 +4,13 @@ import '../styles/StartupPage.css';
interface StartupPageProps { interface StartupPageProps {
onOpenProject: () => void; onOpenProject: () => void;
onCreateProject: () => void; onCreateProject: () => void;
onOpenRecentProject?: (projectPath: string) => void;
onProfilerMode?: () => void;
recentProjects?: string[]; recentProjects?: string[];
locale: string; locale: string;
} }
export function StartupPage({ onOpenProject, onCreateProject, recentProjects = [], locale }: StartupPageProps) { export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onProfilerMode, recentProjects = [], locale }: StartupPageProps) {
const [hoveredProject, setHoveredProject] = useState<string | null>(null); const [hoveredProject, setHoveredProject] = useState<string | null>(null);
const translations = { const translations = {
@@ -17,18 +19,22 @@ export function StartupPage({ onOpenProject, onCreateProject, recentProjects = [
subtitle: 'Professional Game Development Tool', subtitle: 'Professional Game Development Tool',
openProject: 'Open Project', openProject: 'Open Project',
createProject: 'Create New Project', createProject: 'Create New Project',
profilerMode: 'Profiler Mode',
recentProjects: 'Recent Projects', recentProjects: 'Recent Projects',
noRecentProjects: 'No recent projects', noRecentProjects: 'No recent projects',
version: 'Version 1.0.0' version: 'Version 1.0.0',
comingSoon: 'Coming Soon'
}, },
zh: { zh: {
title: 'ECS 框架编辑器', title: 'ECS 框架编辑器',
subtitle: '专业游戏开发工具', subtitle: '专业游戏开发工具',
openProject: '打开项目', openProject: '打开项目',
createProject: '创建新项目', createProject: '创建新项目',
profilerMode: '性能分析模式',
recentProjects: '最近的项目', recentProjects: '最近的项目',
noRecentProjects: '没有最近的项目', noRecentProjects: '没有最近的项目',
version: '版本 1.0.0' version: '版本 1.0.0',
comingSoon: '即将推出'
} }
}; };
@@ -43,18 +49,19 @@ export function StartupPage({ onOpenProject, onCreateProject, recentProjects = [
<div className="startup-content"> <div className="startup-content">
<div className="startup-actions"> <div className="startup-actions">
<button className="startup-action-btn primary" onClick={onOpenProject}> <button className="startup-action-btn primary" onClick={onProfilerMode}>
<svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> <path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg> </svg>
<span>{t.openProject}</span> <span>{t.profilerMode}</span>
</button> </button>
<button className="startup-action-btn" onClick={onCreateProject}> <button className="startup-action-btn disabled" disabled title={t.comingSoon}>
<svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M12 5V19M5 12H19" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> <path d="M12 5V19M5 12H19" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg> </svg>
<span>{t.createProject}</span> <span>{t.createProject}</span>
<span className="badge-coming-soon">{t.comingSoon}</span>
</button> </button>
</div> </div>
@@ -70,6 +77,8 @@ export function StartupPage({ onOpenProject, onCreateProject, recentProjects = [
className={`recent-item ${hoveredProject === project ? 'hovered' : ''}`} className={`recent-item ${hoveredProject === project ? 'hovered' : ''}`}
onMouseEnter={() => setHoveredProject(project)} onMouseEnter={() => setHoveredProject(project)}
onMouseLeave={() => setHoveredProject(null)} onMouseLeave={() => setHoveredProject(null)}
onClick={() => onOpenRecentProject?.(project)}
style={{ cursor: onOpenRecentProject ? 'pointer' : 'default' }}
> >
<svg className="recent-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <svg className="recent-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" strokeWidth="2"/> <path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" strokeWidth="2"/>

View File

@@ -15,7 +15,8 @@ export const en: Translations = {
ready: 'Editor Ready', ready: 'Editor Ready',
failed: 'Initialization Failed', failed: 'Initialization Failed',
projectOpened: 'Project Opened', projectOpened: 'Project Opened',
remoteConnected: 'Remote Game Connected' remoteConnected: 'Remote Game Connected',
profilerMode: 'Profiler Mode - Waiting for connection...'
} }
}, },
hierarchy: { hierarchy: {

View File

@@ -15,7 +15,8 @@ export const zh: Translations = {
ready: '编辑器就绪', ready: '编辑器就绪',
failed: '初始化失败', failed: '初始化失败',
projectOpened: '项目已打开', projectOpened: '项目已打开',
remoteConnected: '远程游戏已连接' remoteConnected: '远程游戏已连接',
profilerMode: '性能分析模式 - 等待连接...'
} }
}, },
hierarchy: { hierarchy: {

View File

@@ -1,5 +1,6 @@
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { SettingsService } from './SettingsService'; import { SettingsService } from './SettingsService';
import { LogLevel } from '@esengine/ecs-framework';
export interface SystemPerformanceData { export interface SystemPerformanceData {
name: string; name: string;
@@ -201,6 +202,8 @@ export class ProfilerService {
this.handleRawEntityListResponse(message.data); this.handleRawEntityListResponse(message.data);
} else if (message.type === 'get_entity_details_response' && message.data) { } else if (message.type === 'get_entity_details_response' && message.data) {
this.handleEntityDetailsResponse(message.data); this.handleEntityDetailsResponse(message.data);
} else if (message.type === 'log' && message.data) {
this.handleRemoteLog(message.data);
} }
} catch (error) { } catch (error) {
console.error('[ProfilerService] Failed to parse message:', error); console.error('[ProfilerService] Failed to parse message:', error);
@@ -343,6 +346,39 @@ export class ProfilerService {
})); }));
} }
private handleRemoteLog(data: any): void {
if (!data) {
return;
}
const levelMap: Record<string, LogLevel> = {
'debug': LogLevel.Debug,
'info': LogLevel.Info,
'warn': LogLevel.Warn,
'error': LogLevel.Error,
'fatal': LogLevel.Fatal
};
const level = levelMap[data.level?.toLowerCase() || 'info'] || LogLevel.Info;
let message = data.message || '';
if (typeof message === 'object') {
try {
message = JSON.stringify(message, null, 2);
} catch {
message = String(message);
}
}
window.dispatchEvent(new CustomEvent('profiler:remote-log', {
detail: {
level,
message,
timestamp: data.timestamp ? new Date(data.timestamp) : new Date()
}
}));
}
private createEmptyData(): ProfilerData { private createEmptyData(): ProfilerData {
return { return {
totalFrameTime: 0, totalFrameTime: 0,

View File

@@ -64,4 +64,25 @@ export class SettingsService {
public getAll(): Record<string, any> { public getAll(): Record<string, any> {
return Object.fromEntries(this.settings); return Object.fromEntries(this.settings);
} }
public getRecentProjects(): string[] {
return this.get<string[]>('recentProjects', []);
}
public addRecentProject(projectPath: string): void {
const recentProjects = this.getRecentProjects();
const filtered = recentProjects.filter(p => p !== projectPath);
const updated = [projectPath, ...filtered].slice(0, 10);
this.set('recentProjects', updated);
}
public removeRecentProject(projectPath: string): void {
const recentProjects = this.getRecentProjects();
const filtered = recentProjects.filter(p => p !== projectPath);
this.set('recentProjects', filtered);
}
public clearRecentProjects(): void {
this.set('recentProjects', []);
}
} }

View File

@@ -209,6 +209,41 @@
opacity: 0.7; opacity: 0.7;
} }
.log-entry-source.source-remote {
color: #4a9eff;
opacity: 1;
font-weight: 600;
}
.log-entry-remote {
border-left: 2px solid #4a9eff;
background: rgba(74, 158, 255, 0.05);
}
.log-entry-expander {
display: flex;
align-items: center;
padding-top: 2px;
cursor: pointer;
color: var(--color-text-secondary);
flex-shrink: 0;
transition: color var(--transition-fast);
}
.log-entry-expander:hover {
color: var(--color-text-primary);
}
.log-entry-expanded {
flex-direction: column;
align-items: flex-start;
}
.log-entry-expanded .log-entry-message {
padding-left: 22px;
width: 100%;
}
.log-entry-message { .log-entry-message {
flex: 1; flex: 1;
color: var(--color-text-primary); color: var(--color-text-primary);
@@ -216,6 +251,72 @@
padding-top: 2px; padding-top: 2px;
} }
.log-message-container {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.log-message-preview {
opacity: 0.9;
flex: 1;
}
.log-open-json-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
background: var(--color-primary);
border: none;
border-radius: var(--radius-sm);
color: white;
cursor: pointer;
opacity: 0.7;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.log-open-json-btn:hover {
opacity: 1;
transform: scale(1.1);
}
.log-message-json {
margin: 4px 0 0 0;
padding: 8px;
background: rgba(0, 0, 0, 0.3);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-default);
font-family: var(--font-family-mono);
font-size: 11px;
line-height: 1.5;
overflow: auto;
white-space: pre;
color: #a0e7a0;
max-height: 400px;
}
.log-message-json::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.log-message-json::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.log-message-json::-webkit-scrollbar-thumb {
background: rgba(160, 231, 160, 0.3);
border-radius: 4px;
}
.log-message-json::-webkit-scrollbar-thumb:hover {
background: rgba(160, 231, 160, 0.5);
}
.log-entry-debug { .log-entry-debug {
color: var(--color-text-tertiary); color: var(--color-text-tertiary);
} }

View File

@@ -0,0 +1,177 @@
.json-viewer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
backdrop-filter: blur(4px);
}
.json-viewer-modal {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
box-shadow: var(--shadow-xl);
width: 90%;
max-width: 1200px;
height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.json-viewer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border-default);
background: var(--color-bg-base);
}
.json-viewer-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
}
.json-viewer-actions {
display: flex;
gap: 8px;
}
.json-viewer-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);
}
.json-viewer-btn:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
.json-viewer-content {
flex: 1;
overflow: auto;
padding: 16px;
font-family: var(--font-family-mono);
font-size: 12px;
line-height: 1.6;
}
.json-tree-node {
margin-bottom: 4px;
}
.json-tree-header {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition-fast);
user-select: none;
}
.json-tree-header:hover {
background: var(--color-bg-hover);
}
.json-tree-expander {
display: flex;
align-items: center;
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.json-tree-key {
color: #9cdcfe;
font-weight: 500;
margin-right: 4px;
}
.json-tree-preview {
color: var(--color-text-tertiary);
font-style: italic;
}
.json-tree-children {
margin-left: 20px;
padding-left: 12px;
border-left: 1px solid var(--color-border-subtle);
}
.json-tree-leaf {
display: flex;
align-items: baseline;
gap: 6px;
padding: 2px 8px;
border-radius: var(--radius-sm);
transition: background var(--transition-fast);
}
.json-tree-leaf:hover {
background: var(--color-bg-hover);
}
.json-tree-value {
font-family: var(--font-family-mono);
}
.json-string {
color: #ce9178;
}
.json-number {
color: #b5cea8;
}
.json-boolean {
color: #569cd6;
}
.json-null {
color: #569cd6;
font-style: italic;
}
.json-array {
color: #4ec9b0;
}
.json-object {
color: #4ec9b0;
}
.json-viewer-content::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.json-viewer-content::-webkit-scrollbar-track {
background: var(--color-bg-base);
}
.json-viewer-content::-webkit-scrollbar-thumb {
background: var(--color-border-default);
border-radius: 5px;
}
.json-viewer-content::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary);
}

View File

@@ -28,6 +28,7 @@
gap: 8px; gap: 8px;
} }
.profiler-dock-pause-btn,
.profiler-dock-details-btn { .profiler-dock-details-btn {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -43,12 +44,18 @@
transition: all var(--transition-fast); transition: all var(--transition-fast);
} }
.profiler-dock-pause-btn:hover,
.profiler-dock-details-btn:hover { .profiler-dock-details-btn:hover {
background: var(--color-bg-hover); background: var(--color-bg-hover);
border-color: var(--color-border-strong); border-color: var(--color-border-strong);
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.profiler-dock-pause-btn:active,
.profiler-dock-details-btn:active {
transform: scale(0.95);
}
.profiler-dock-status { .profiler-dock-status {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -42,6 +42,7 @@
} }
.startup-action-btn { .startup-action-btn {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
@@ -72,6 +73,28 @@
border-color: #1177bb; border-color: #1177bb;
} }
.startup-action-btn.disabled {
background-color: #252526;
border-color: #3e3e42;
color: #6e6e6e;
cursor: not-allowed;
opacity: 0.5;
}
.startup-action-btn.disabled:hover {
background-color: #252526;
border-color: #3e3e42;
}
.badge-coming-soon {
margin-left: auto;
font-size: 10px;
padding: 2px 6px;
background-color: #3e3e42;
border-radius: 2px;
color: #858585;
}
.btn-icon { .btn-icon {
width: 20px; width: 20px;
height: 20px; height: 20px;

View File

@@ -121,6 +121,28 @@ export class LogService implements IService {
this.notifyListeners(entry); this.notifyListeners(entry);
} }
/**
* 添加远程日志(从远程游戏接收)
*/
public addRemoteLog(level: LogLevel, message: string, timestamp?: Date): void {
const entry: LogEntry = {
id: this.nextId++,
timestamp: timestamp || new Date(),
level,
source: 'remote',
message,
args: []
};
this.logs.push(entry);
if (this.logs.length > this.maxLogs) {
this.logs.shift();
}
this.notifyListeners(entry);
}
/** /**
* 通知监听器 * 通知监听器
*/ */