远程读取日志

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

@@ -1,7 +1,8 @@
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 { Trash2, AlertCircle, Info, AlertTriangle, XCircle, Bug, Search, Maximize2, ChevronRight, ChevronDown } from 'lucide-react';
import { JsonViewer } from './JsonViewer';
import '../styles/ConsolePanel.css';
interface ConsolePanelProps {
@@ -19,6 +20,8 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
LogLevel.Fatal
]));
const [autoScroll, setAutoScroll] = useState(true);
const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
const [jsonViewerData, setJsonViewerData] = useState<any>(null);
const logContainerRef = useRef<HTMLDivElement>(null);
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 = {
[LogLevel.Debug]: logs.filter(l => l.level === LogLevel.Debug).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>
</div>
) : (
filteredLogs.map(log => (
<div key={log.id} className={`log-entry ${getLevelClass(log.level)}`}>
<div className="log-entry-icon">
{getLevelIcon(log.level)}
filteredLogs.map(log => {
const isExpanded = expandedLogs.has(log.id);
const shouldShowExpander = log.message.length > 200;
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 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>
{jsonViewerData && (
<JsonViewer
data={jsonViewerData}
onClose={() => setJsonViewerData(null)}
/>
)}
{!autoScroll && (
<button
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 { 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 { SettingsService } from '../services/SettingsService';
import { Core } from '@esengine/ecs-framework';
@@ -11,6 +11,7 @@ export function ProfilerDockPanel() {
const [isConnected, setIsConnected] = useState(false);
const [isServerRunning, setIsServerRunning] = useState(false);
const [port, setPort] = useState('8080');
const [isPaused, setIsPaused] = useState(false);
useEffect(() => {
const settings = SettingsService.getInstance();
@@ -42,7 +43,9 @@ export function ProfilerDockPanel() {
// 订阅数据更新
const unsubscribe = profilerService.subscribe((data: ProfilerData) => {
setProfilerData(data);
if (!isPaused) {
setProfilerData(data);
}
});
// 定期检查连接状态
@@ -58,7 +61,7 @@ export function ProfilerDockPanel() {
unsubscribe();
clearInterval(interval);
};
}, []);
}, [isPaused]);
const fps = profilerData?.fps || 0;
const totalFrameTime = profilerData?.totalFrameTime || 0;
@@ -74,19 +77,32 @@ export function ProfilerDockPanel() {
}
};
const handleTogglePause = () => {
setIsPaused(!isPaused);
};
return (
<div className="profiler-dock-panel">
<div className="profiler-dock-header">
<h3>Performance Monitor</h3>
<div className="profiler-dock-header-actions">
{isConnected && (
<button
className="profiler-dock-details-btn"
onClick={handleOpenDetails}
title="Open detailed profiler"
>
<Maximize2 size={14} />
</button>
<>
<button
className="profiler-dock-pause-btn"
onClick={handleTogglePause}
title={isPaused ? 'Resume data updates' : 'Pause data updates'}
>
{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">
{isConnected ? (

View File

@@ -56,8 +56,24 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
setIsRemoteConnected(connected);
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([]);
}
});

View File

@@ -4,11 +4,13 @@ import '../styles/StartupPage.css';
interface StartupPageProps {
onOpenProject: () => void;
onCreateProject: () => void;
onOpenRecentProject?: (projectPath: string) => void;
onProfilerMode?: () => void;
recentProjects?: 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 translations = {
@@ -17,18 +19,22 @@ export function StartupPage({ onOpenProject, onCreateProject, recentProjects = [
subtitle: 'Professional Game Development Tool',
openProject: 'Open Project',
createProject: 'Create New Project',
profilerMode: 'Profiler Mode',
recentProjects: 'Recent Projects',
noRecentProjects: 'No recent projects',
version: 'Version 1.0.0'
version: 'Version 1.0.0',
comingSoon: 'Coming Soon'
},
zh: {
title: 'ECS 框架编辑器',
subtitle: '专业游戏开发工具',
openProject: '打开项目',
createProject: '创建新项目',
profilerMode: '性能分析模式',
recentProjects: '最近的项目',
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-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">
<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>
<span>{t.openProject}</span>
<span>{t.profilerMode}</span>
</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">
<path d="M12 5V19M5 12H19" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span>{t.createProject}</span>
<span className="badge-coming-soon">{t.comingSoon}</span>
</button>
</div>
@@ -70,6 +77,8 @@ export function StartupPage({ onOpenProject, onCreateProject, recentProjects = [
className={`recent-item ${hoveredProject === project ? 'hovered' : ''}`}
onMouseEnter={() => setHoveredProject(project)}
onMouseLeave={() => setHoveredProject(null)}
onClick={() => onOpenRecentProject?.(project)}
style={{ cursor: onOpenRecentProject ? 'pointer' : 'default' }}
>
<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"/>