refactor: reorganize package structure and decouple framework packages (#338)
* refactor: reorganize package structure and decouple framework packages ## Package Structure Reorganization - Reorganized 55 packages into categorized subdirectories: - packages/framework/ - Generic framework (Laya/Cocos compatible) - packages/engine/ - ESEngine core modules - packages/rendering/ - Rendering modules (WASM dependent) - packages/physics/ - Physics modules - packages/streaming/ - World streaming - packages/network-ext/ - Network extensions - packages/editor/ - Editor framework and plugins - packages/rust/ - Rust WASM engine - packages/tools/ - Build tools and SDK ## Framework Package Decoupling - Decoupled behavior-tree and blueprint packages from ESEngine dependencies - Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent) - ESEngine-specific code moved to esengine/ subpath exports - Framework packages now usable with Cocos/Laya without ESEngine ## CI Configuration - Updated CI to only type-check and lint framework packages - Added type-check:framework and lint:framework scripts ## Breaking Changes - Package import paths changed due to directory reorganization - ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine') * fix: update es-engine file path after directory reorganization * docs: update README to focus on framework over engine * ci: only build framework packages, remove Rust/WASM dependencies * fix: remove esengine subpath from behavior-tree and blueprint builds ESEngine integration code will only be available in full engine builds. Framework packages are now purely engine-agnostic. * fix: move network-protocols to framework, build both in CI * fix: update workflow paths from packages/core to packages/framework/core * fix: exclude esengine folder from type-check in behavior-tree and blueprint * fix: update network tsconfig references to new paths * fix: add test:ci:framework to only test framework packages in CI * fix: only build core and math npm packages in CI * fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
import type { IFileAPI } from '@esengine/editor-core';
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
|
||||
/**
|
||||
* Tauri 文件 API 适配器
|
||||
*
|
||||
* 实现 IFileAPI 接口,连接 editor-core 和 Tauri 后端
|
||||
*/
|
||||
export class TauriFileAPI implements IFileAPI {
|
||||
public async openSceneDialog(): Promise<string | null> {
|
||||
return await TauriAPI.openSceneDialog();
|
||||
}
|
||||
|
||||
public async saveSceneDialog(defaultName?: string, scenesDir?: string): Promise<string | null> {
|
||||
return await TauriAPI.saveSceneDialog(defaultName, scenesDir);
|
||||
}
|
||||
|
||||
public async readFileContent(path: string): Promise<string> {
|
||||
return await TauriAPI.readFileContent(path);
|
||||
}
|
||||
|
||||
public async saveProject(path: string, data: string): Promise<void> {
|
||||
return await TauriAPI.saveProject(path, data);
|
||||
}
|
||||
|
||||
public async exportBinary(data: Uint8Array, path: string): Promise<void> {
|
||||
return await TauriAPI.exportBinary(data, path);
|
||||
}
|
||||
|
||||
public async createDirectory(path: string): Promise<void> {
|
||||
return await TauriAPI.createDirectory(path);
|
||||
}
|
||||
|
||||
public async writeFileContent(path: string, content: string): Promise<void> {
|
||||
return await TauriAPI.writeFileContent(path, content);
|
||||
}
|
||||
|
||||
public async pathExists(path: string): Promise<boolean> {
|
||||
return await TauriAPI.pathExists(path);
|
||||
}
|
||||
|
||||
public async getFileMtime(path: string): Promise<number> {
|
||||
return await TauriAPI.getFileMtime(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
|
||||
|
||||
/**
|
||||
* 文件过滤器定义
|
||||
*/
|
||||
interface FileFilter {
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tauri IPC 通信层
|
||||
*/
|
||||
export class TauriAPI {
|
||||
static async openFolderDialog(title?: string): Promise<string | null> {
|
||||
return await invoke<string | null>('open_folder_dialog', { title });
|
||||
}
|
||||
|
||||
static async openFileDialog(
|
||||
title?: string,
|
||||
filters?: FileFilter[],
|
||||
multiple?: boolean
|
||||
): Promise<string[] | null> {
|
||||
return await invoke<string[] | null>('open_file_dialog', {
|
||||
title,
|
||||
filters,
|
||||
multiple
|
||||
});
|
||||
}
|
||||
|
||||
static async saveFileDialog(
|
||||
title?: string,
|
||||
defaultName?: string,
|
||||
filters?: FileFilter[],
|
||||
defaultPath?: string
|
||||
): Promise<string | null> {
|
||||
return await invoke<string | null>('save_file_dialog', {
|
||||
title,
|
||||
defaultName,
|
||||
defaultPath,
|
||||
filters
|
||||
});
|
||||
}
|
||||
|
||||
static async openProjectDialog(): Promise<string | null> {
|
||||
return await this.openFolderDialog('Select Project Directory');
|
||||
}
|
||||
|
||||
static async openProject(path: string): Promise<string> {
|
||||
return await invoke<string>('open_project', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存项目
|
||||
*/
|
||||
static async saveProject(path: string, data: string): Promise<void> {
|
||||
return await invoke<void>('save_project', { path, data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出二进制数据
|
||||
*/
|
||||
static async exportBinary(data: Uint8Array, outputPath: string): Promise<void> {
|
||||
return await invoke<void>('export_binary', {
|
||||
data: Array.from(data),
|
||||
outputPath
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描目录查找匹配模式的文件
|
||||
*/
|
||||
static async scanDirectory(path: string, pattern: string): Promise<string[]> {
|
||||
return await invoke<string[]>('scan_directory', { path, pattern });
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容(文本)
|
||||
*/
|
||||
static async readFileContent(path: string): Promise<string> {
|
||||
return await invoke<string>('read_file_content', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容(二进制)
|
||||
* Read file content as binary ArrayBuffer
|
||||
*/
|
||||
static async readFileBinary(path: string): Promise<ArrayBuffer> {
|
||||
// Use Tauri read_file_as_base64 command which returns base64 encoded data
|
||||
// 使用 Tauri 的 read_file_as_base64 命令,返回 base64 编码的数据
|
||||
const base64: string = await invoke<string>('read_file_as_base64', { filePath: path });
|
||||
// Decode base64 to ArrayBuffer
|
||||
// 将 base64 解码为 ArrayBuffer
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出目录内容
|
||||
*/
|
||||
static async listDirectory(path: string): Promise<DirectoryEntry[]> {
|
||||
return await invoke<DirectoryEntry[]>('list_directory', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置项目基础路径,用于 Custom Protocol
|
||||
*/
|
||||
static async setProjectBasePath(path: string): Promise<void> {
|
||||
return await invoke<void>('set_project_base_path', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换开发者工具(仅在debug模式下可用)
|
||||
*/
|
||||
static async toggleDevtools(): Promise<void> {
|
||||
return await invoke<void>('toggle_devtools');
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开保存场景对话框
|
||||
* Open save scene dialog
|
||||
*
|
||||
* @param defaultName 默认文件名(可选)| Default file name (optional)
|
||||
* @param scenesDir 场景目录路径(可选)| Scenes directory path (optional)
|
||||
* @returns 用户选择的文件路径,取消则返回 null | Selected file path or null
|
||||
*/
|
||||
static async saveSceneDialog(defaultName?: string, scenesDir?: string): Promise<string | null> {
|
||||
return await this.saveFileDialog(
|
||||
'Save ECS Scene',
|
||||
defaultName,
|
||||
[{ name: 'ECS Scene Files', extensions: ['ecs'] }],
|
||||
scenesDir
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开场景文件选择对话框
|
||||
* @returns 用户选择的文件路径,取消则返回 null
|
||||
*/
|
||||
static async openSceneDialog(): Promise<string | null> {
|
||||
const result = await this.openFileDialog(
|
||||
'Open ECS Scene',
|
||||
[{ name: 'ECS Scene Files', extensions: ['ecs'] }],
|
||||
false
|
||||
);
|
||||
return result && result[0] ? result[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建目录
|
||||
* @param path 目录路径
|
||||
*/
|
||||
static async createDirectory(path: string): Promise<void> {
|
||||
return await invoke<void>('create_directory', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入文件内容
|
||||
* @param path 文件路径
|
||||
* @param content 文件内容
|
||||
*/
|
||||
static async writeFileContent(path: string, content: string): Promise<void> {
|
||||
return await invoke<void>('write_file_content', { path, content });
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查路径是否存在
|
||||
* @param path 文件或目录路径
|
||||
* @returns 路径是否存在
|
||||
*/
|
||||
static async pathExists(path: string): Promise<boolean> {
|
||||
return await invoke<boolean>('path_exists', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用系统默认程序打开文件
|
||||
* @param path 文件路径
|
||||
*/
|
||||
static async openFileWithSystemApp(path: string): Promise<void> {
|
||||
await invoke('open_file_with_default_app', { filePath: path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 在文件管理器中显示文件
|
||||
* @param path 文件路径
|
||||
*/
|
||||
static async showInFolder(path: string): Promise<void> {
|
||||
await invoke('show_in_folder', { filePath: path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定编辑器打开项目
|
||||
* Open project with specified editor
|
||||
*
|
||||
* @param projectPath 项目文件夹路径 | Project folder path
|
||||
* @param editorCommand 编辑器命令(如 "code", "cursor")| Editor command
|
||||
* @param filePath 可选的要打开的文件路径 | Optional file path to open
|
||||
*/
|
||||
static async openWithEditor(
|
||||
projectPath: string,
|
||||
editorCommand: string,
|
||||
filePath?: string
|
||||
): Promise<void> {
|
||||
await invoke('open_with_editor', {
|
||||
projectPath,
|
||||
editorCommand,
|
||||
filePath: filePath || null
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开行为树文件选择对话框
|
||||
* @returns 用户选择的文件路径,取消则返回 null
|
||||
*/
|
||||
static async openBehaviorTreeDialog(): Promise<string | null> {
|
||||
const result = await this.openFileDialog(
|
||||
'Select Behavior Tree',
|
||||
[{ name: 'Behavior Tree Files', extensions: ['btree'] }],
|
||||
false
|
||||
);
|
||||
return result && result[0] ? result[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描项目中的所有行为树文件
|
||||
* @param projectPath 项目路径
|
||||
* @returns 行为树资产ID列表(相对于 .ecs/behaviors 的路 径,不含扩展名)
|
||||
*/
|
||||
static async scanBehaviorTrees(projectPath: string): Promise<string[]> {
|
||||
return await invoke<string[]>('scan_behavior_trees', { projectPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件或文件夹
|
||||
* @param oldPath 原路径
|
||||
* @param newPath 新路径
|
||||
*/
|
||||
static async renameFileOrFolder(oldPath: string, newPath: string): Promise<void> {
|
||||
return await invoke<void>('rename_file_or_folder', { oldPath, newPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* @param path 文件路径
|
||||
*/
|
||||
static async deleteFile(path: string): Promise<void> {
|
||||
return await invoke<void>('delete_file', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件夹
|
||||
* @param path 文件夹路径
|
||||
*/
|
||||
static async deleteFolder(path: string): Promise<void> {
|
||||
return await invoke<void>('delete_folder', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件
|
||||
* @param path 文件路径
|
||||
*/
|
||||
static async createFile(path: string): Promise<void> {
|
||||
return await invoke<void>('create_file', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件并转换为base64
|
||||
* @param path 文件路径
|
||||
* @returns base64编码的文件内容
|
||||
*/
|
||||
static async readFileAsBase64(path: string): Promise<string> {
|
||||
return await invoke<string>('read_file_as_base64', { filePath: path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制文件
|
||||
* @param src 源文件路径
|
||||
* @param dst 目标文件路径
|
||||
*/
|
||||
static async copyFile(src: string, dst: string): Promise<void> {
|
||||
return await invoke<void>('copy_file', { src, dst });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件修改时间
|
||||
* Get file modification time
|
||||
*
|
||||
* @param path 文件路径 | File path
|
||||
* @returns 文件修改时间(毫秒时间戳)| File modification time (milliseconds timestamp)
|
||||
*/
|
||||
static async getFileMtime(path: string): Promise<number> {
|
||||
return await invoke<number>('get_file_mtime', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入二进制文件
|
||||
* @param filePath 文件路径
|
||||
* @param content 二进制数据
|
||||
*/
|
||||
static async writeBinaryFile(filePath: string, content: Uint8Array): Promise<void> {
|
||||
return await invoke<void>('write_binary_file', {
|
||||
filePath,
|
||||
content: Array.from(content)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取临时目录路径
|
||||
* @returns 临时目录路径
|
||||
*/
|
||||
static async getTempDir(): Promise<string> {
|
||||
return await invoke<string>('get_temp_dir');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取应用资源目录
|
||||
* @returns 资源目录路径
|
||||
*/
|
||||
static async getAppResourceDir(): Promise<string> {
|
||||
return await invoke<string>('get_app_resource_dir');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前工作目录
|
||||
* @returns 当前工作目录路径
|
||||
*/
|
||||
static async getCurrentDir(): Promise<string> {
|
||||
return await invoke<string>('get_current_dir');
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动本地HTTP服务器
|
||||
* @param rootPath 服务器根目录
|
||||
* @param port 端口号
|
||||
* @returns 服务器URL
|
||||
*/
|
||||
static async startLocalServer(rootPath: string, port: number): Promise<string> {
|
||||
return await invoke<string>('start_local_server', { rootPath, port });
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止本地HTTP服务器
|
||||
*/
|
||||
static async stopLocalServer(): Promise<void> {
|
||||
return await invoke<void>('stop_local_server');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本机局域网IP地址
|
||||
* @returns 局域网IP地址
|
||||
*/
|
||||
static async getLocalIp(): Promise<string> {
|
||||
return await invoke<string>('get_local_ip');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码
|
||||
* @param text 要编码的文本
|
||||
* @returns base64编码的PNG图片
|
||||
*/
|
||||
static async generateQRCode(text: string): Promise<string> {
|
||||
return await invoke<string>('generate_qrcode', { text });
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新项目的 tsconfig.json,添加引擎类型路径
|
||||
* Update project tsconfig.json with engine type paths
|
||||
*
|
||||
* This updates the tsconfig to point directly to engine's .d.ts files
|
||||
* instead of copying them to the project.
|
||||
* 这会更新 tsconfig 直接指向引擎的 .d.ts 文件,而不是复制到项目。
|
||||
*
|
||||
* @param projectPath 项目路径 | Project path
|
||||
*/
|
||||
static async updateProjectTsconfig(projectPath: string): Promise<void> {
|
||||
return await invoke<void>('update_project_tsconfig', { projectPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* 将本地文件路径转换为 Tauri 可访问的 asset URL
|
||||
* @param filePath 本地文件路径
|
||||
* @param protocol 协议类型 (默认: 'asset')
|
||||
* @returns 转换后的 URL,可用于 img src、audio src 等
|
||||
* @example
|
||||
* const url = TauriAPI.convertFileSrc('C:\\Users\\...\\image.png');
|
||||
* // 返回: 'https://asset.localhost/C:/Users/.../image.png'
|
||||
*/
|
||||
static convertFileSrc(filePath: string, protocol?: string): string {
|
||||
return convertFileSrc(filePath, protocol);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测开发环境
|
||||
* Check development environment
|
||||
*
|
||||
* Checks if all required tools (esbuild, etc.) are available.
|
||||
* 检查所有必需的工具是否可用。
|
||||
*
|
||||
* @returns 环境检测结果 | Environment check result
|
||||
*/
|
||||
static async checkEnvironment(): Promise<EnvironmentCheckResult> {
|
||||
return await invoke<EnvironmentCheckResult>('check_environment');
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装 esbuild(全局)
|
||||
* Install esbuild globally via npm
|
||||
*
|
||||
* This command installs esbuild globally using `npm install -g esbuild`.
|
||||
* 使用 `npm install -g esbuild` 全局安装 esbuild。
|
||||
*
|
||||
* @returns Promise that resolves when installation completes
|
||||
*/
|
||||
static async installEsbuild(): Promise<void> {
|
||||
return await invoke<void>('install_esbuild');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具可用性状态
|
||||
* Tool availability status
|
||||
*/
|
||||
export interface ToolStatus {
|
||||
/** 工具是否可用 | Whether the tool is available */
|
||||
available: boolean;
|
||||
/** 工具版本 | Tool version */
|
||||
version?: string;
|
||||
/** 工具路径 | Tool path */
|
||||
path?: string;
|
||||
/** 工具来源: "bundled", "local", "global" | Tool source */
|
||||
source?: string;
|
||||
/** 错误信息 | Error message */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 环境检测结果
|
||||
* Environment check result
|
||||
*/
|
||||
export interface EnvironmentCheckResult {
|
||||
/** 所有必需工具是否可用 | Whether all required tools are available */
|
||||
ready: boolean;
|
||||
/** esbuild 可用性状态 | esbuild availability status */
|
||||
esbuild: ToolStatus;
|
||||
}
|
||||
|
||||
export interface DirectoryEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
size?: number;
|
||||
modified?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目信息
|
||||
*/
|
||||
export interface ProjectInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器配置
|
||||
*/
|
||||
export interface EditorConfig {
|
||||
theme: string;
|
||||
autoSave: boolean;
|
||||
recentProjects: string[];
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { create } from 'zustand';
|
||||
import type { ConfirmDialogData } from '../../services/TauriDialogService';
|
||||
|
||||
interface ErrorDialogData {
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部修改对话框数据
|
||||
* External modification dialog data
|
||||
*/
|
||||
export interface ExternalModificationDialogData {
|
||||
sceneName: string;
|
||||
onReload: () => void;
|
||||
onOverwrite: () => void;
|
||||
}
|
||||
|
||||
interface DialogState {
|
||||
showProfiler: boolean;
|
||||
showAdvancedProfiler: boolean;
|
||||
showPortManager: boolean;
|
||||
showSettings: boolean;
|
||||
showAbout: boolean;
|
||||
showPluginGenerator: boolean;
|
||||
showBuildSettings: boolean;
|
||||
showRenderDebug: boolean;
|
||||
errorDialog: ErrorDialogData | null;
|
||||
confirmDialog: ConfirmDialogData | null;
|
||||
externalModificationDialog: ExternalModificationDialogData | null;
|
||||
|
||||
setShowProfiler: (show: boolean) => void;
|
||||
setShowAdvancedProfiler: (show: boolean) => void;
|
||||
setShowPortManager: (show: boolean) => void;
|
||||
setShowSettings: (show: boolean) => void;
|
||||
setShowAbout: (show: boolean) => void;
|
||||
setShowPluginGenerator: (show: boolean) => void;
|
||||
setShowBuildSettings: (show: boolean) => void;
|
||||
setShowRenderDebug: (show: boolean) => void;
|
||||
setErrorDialog: (data: ErrorDialogData | null) => void;
|
||||
setConfirmDialog: (data: ConfirmDialogData | null) => void;
|
||||
setExternalModificationDialog: (data: ExternalModificationDialogData | null) => void;
|
||||
closeAllDialogs: () => void;
|
||||
}
|
||||
|
||||
export const useDialogStore = create<DialogState>((set) => ({
|
||||
showProfiler: false,
|
||||
showAdvancedProfiler: false,
|
||||
showPortManager: false,
|
||||
showSettings: false,
|
||||
showAbout: false,
|
||||
showPluginGenerator: false,
|
||||
showBuildSettings: false,
|
||||
showRenderDebug: false,
|
||||
errorDialog: null,
|
||||
confirmDialog: null,
|
||||
externalModificationDialog: null,
|
||||
|
||||
setShowProfiler: (show) => set({ showProfiler: show }),
|
||||
setShowAdvancedProfiler: (show) => set({ showAdvancedProfiler: show }),
|
||||
setShowPortManager: (show) => set({ showPortManager: show }),
|
||||
setShowSettings: (show) => set({ showSettings: show }),
|
||||
setShowAbout: (show) => set({ showAbout: show }),
|
||||
setShowPluginGenerator: (show) => set({ showPluginGenerator: show }),
|
||||
setShowBuildSettings: (show) => set({ showBuildSettings: show }),
|
||||
setShowRenderDebug: (show) => set({ showRenderDebug: show }),
|
||||
setErrorDialog: (data) => set({ errorDialog: data }),
|
||||
setConfirmDialog: (data) => set({ confirmDialog: data }),
|
||||
setExternalModificationDialog: (data) => set({ externalModificationDialog: data }),
|
||||
|
||||
closeAllDialogs: () => set({
|
||||
showProfiler: false,
|
||||
showAdvancedProfiler: false,
|
||||
showPortManager: false,
|
||||
showSettings: false,
|
||||
showAbout: false,
|
||||
showPluginGenerator: false,
|
||||
showBuildSettings: false,
|
||||
showRenderDebug: false,
|
||||
errorDialog: null,
|
||||
confirmDialog: null,
|
||||
externalModificationDialog: null
|
||||
})
|
||||
}));
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 插件安装器
|
||||
* Plugin Installer
|
||||
*
|
||||
* 现在所有插件都使用统一的 IPlugin 接口,无需适配器。
|
||||
* Now all plugins use the unified IPlugin interface, no adapter needed.
|
||||
*/
|
||||
|
||||
import type { PluginManager } from '@esengine/editor-core';
|
||||
|
||||
// 内置插件
|
||||
import { GizmoPlugin } from '../../plugins/builtin/GizmoPlugin';
|
||||
import { SceneInspectorPlugin } from '../../plugins/builtin/SceneInspectorPlugin';
|
||||
import { ProfilerPlugin } from '../../plugins/builtin/ProfilerPlugin';
|
||||
import { EditorAppearancePlugin } from '../../plugins/builtin/EditorAppearancePlugin';
|
||||
import { ProjectSettingsPlugin } from '../../plugins/builtin/ProjectSettingsPlugin';
|
||||
import { AssetMetaPlugin } from '../../plugins/builtin/AssetMetaPlugin';
|
||||
// Note: PluginConfigPlugin removed - module management is now unified in ProjectSettingsPlugin
|
||||
|
||||
// 统一模块插件(从编辑器包导入完整插件,包含 runtime + editor)
|
||||
import { BehaviorTreePlugin } from '@esengine/behavior-tree-editor';
|
||||
import { ParticlePlugin } from '@esengine/particle-editor';
|
||||
import { Physics2DPlugin } from '@esengine/physics-rapier2d-editor';
|
||||
import { TilemapPlugin } from '@esengine/tilemap-editor';
|
||||
import { FGUIPlugin } from '@esengine/fairygui-editor';
|
||||
import { BlueprintPlugin } from '@esengine/blueprint-editor';
|
||||
import { MaterialPlugin } from '@esengine/material-editor';
|
||||
import { SpritePlugin } from '@esengine/sprite-editor';
|
||||
import { ShaderEditorPlugin } from '@esengine/shader-editor';
|
||||
import { Mesh3DPlugin } from '@esengine/mesh-3d-editor';
|
||||
|
||||
// 纯运行时插件 | Runtime-only plugins
|
||||
import { CameraPlugin } from '@esengine/camera';
|
||||
|
||||
export class PluginInstaller {
|
||||
/**
|
||||
* 安装所有内置插件
|
||||
*/
|
||||
async installBuiltinPlugins(pluginManager: PluginManager): Promise<void> {
|
||||
// 内置编辑器插件
|
||||
const builtinPlugins = [
|
||||
{ name: 'GizmoPlugin', plugin: GizmoPlugin },
|
||||
{ name: 'SceneInspectorPlugin', plugin: SceneInspectorPlugin },
|
||||
{ name: 'ProfilerPlugin', plugin: ProfilerPlugin },
|
||||
{ name: 'EditorAppearancePlugin', plugin: EditorAppearancePlugin },
|
||||
{ name: 'ProjectSettingsPlugin', plugin: ProjectSettingsPlugin },
|
||||
{ name: 'AssetMetaPlugin', plugin: AssetMetaPlugin },
|
||||
];
|
||||
|
||||
for (const { name, plugin } of builtinPlugins) {
|
||||
if (!plugin || !plugin.manifest) {
|
||||
console.error(`[PluginInstaller] ${name} is invalid: missing manifest`, plugin);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
pluginManager.register(plugin);
|
||||
} catch (error) {
|
||||
console.error(`[PluginInstaller] Failed to register ${name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 统一模块插件(runtime + editor)
|
||||
const modulePlugins = [
|
||||
{ name: 'CameraPlugin', plugin: CameraPlugin },
|
||||
{ name: 'SpritePlugin', plugin: SpritePlugin },
|
||||
{ name: 'TilemapPlugin', plugin: TilemapPlugin },
|
||||
{ name: 'FGUIPlugin', plugin: FGUIPlugin },
|
||||
{ name: 'BehaviorTreePlugin', plugin: BehaviorTreePlugin },
|
||||
{ name: 'ParticlePlugin', plugin: ParticlePlugin },
|
||||
{ name: 'Physics2DPlugin', plugin: Physics2DPlugin },
|
||||
{ name: 'BlueprintPlugin', plugin: BlueprintPlugin },
|
||||
{ name: 'MaterialPlugin', plugin: MaterialPlugin },
|
||||
{ name: 'ShaderEditorPlugin', plugin: ShaderEditorPlugin },
|
||||
{ name: 'Mesh3DPlugin', plugin: Mesh3DPlugin },
|
||||
];
|
||||
|
||||
for (const { name, plugin } of modulePlugins) {
|
||||
if (!plugin || !plugin.manifest) {
|
||||
console.error(`[PluginInstaller] ${name} is invalid: missing manifest`, plugin);
|
||||
continue;
|
||||
}
|
||||
// 详细日志,检查 editorModule 是否存在
|
||||
console.log(`[PluginInstaller] ${name}: manifest.id=${plugin.manifest.id}, hasRuntimeModule=${!!plugin.runtimeModule}, hasEditorModule=${!!plugin.editorModule}`);
|
||||
try {
|
||||
pluginManager.register(plugin);
|
||||
} catch (error) {
|
||||
console.error(`[PluginInstaller] Failed to register ${name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
import { Core, GlobalComponentRegistry, PrefabSerializer } from '@esengine/ecs-framework';
|
||||
import type { ComponentType } from '@esengine/ecs-framework';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
UIRegistry,
|
||||
MessageHub,
|
||||
IMessageHub,
|
||||
SerializerRegistry,
|
||||
EntityStoreService,
|
||||
EditorComponentRegistry,
|
||||
ProjectService,
|
||||
ComponentDiscoveryService,
|
||||
PropertyMetadataService,
|
||||
LogService,
|
||||
SettingsRegistry,
|
||||
SceneManagerService,
|
||||
SceneTemplateRegistry,
|
||||
FileActionRegistry,
|
||||
IFileActionRegistry,
|
||||
EntityCreationRegistry,
|
||||
PluginManager,
|
||||
IPluginManager,
|
||||
InspectorRegistry,
|
||||
IInspectorRegistry,
|
||||
PropertyRendererRegistry,
|
||||
FieldEditorRegistry,
|
||||
ComponentActionRegistry,
|
||||
ComponentInspectorRegistry,
|
||||
IDialogService,
|
||||
IFileSystemService,
|
||||
CompilerRegistry,
|
||||
ICompilerRegistry,
|
||||
IViewportService_ID,
|
||||
IPreviewSceneService,
|
||||
IEditorViewportServiceIdentifier,
|
||||
PreviewSceneService,
|
||||
EditorViewportService,
|
||||
BuildService,
|
||||
WebBuildPipeline,
|
||||
WeChatBuildPipeline,
|
||||
moduleRegistry,
|
||||
UserCodeService,
|
||||
UserCodeTarget,
|
||||
type HotReloadEvent
|
||||
} from '@esengine/editor-core';
|
||||
import { ViewportService } from '../../services/ViewportService';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
import { AudioSourceComponent } from '@esengine/audio';
|
||||
import { FGUIComponent } from '@esengine/fairygui';
|
||||
import { BehaviorTreeRuntimeComponent } from '@esengine/behavior-tree';
|
||||
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
|
||||
import { DIContainer } from '../../core/di/DIContainer';
|
||||
import { TypedEventBus } from '../../core/events/TypedEventBus';
|
||||
import { CommandRegistry } from '../../core/commands/CommandRegistry';
|
||||
import { PanelRegistry } from '../../core/commands/PanelRegistry';
|
||||
import type { EditorEventMap } from '../../core/events/EditorEventMap';
|
||||
import { TauriFileSystemService } from '../../services/TauriFileSystemService';
|
||||
import { TauriDialogService } from '../../services/TauriDialogService';
|
||||
import { NotificationService } from '../../services/NotificationService';
|
||||
import {
|
||||
StringRenderer,
|
||||
NumberRenderer,
|
||||
BooleanRenderer,
|
||||
NullRenderer,
|
||||
Vector2Renderer,
|
||||
Vector3Renderer,
|
||||
ColorRenderer,
|
||||
ComponentRenderer,
|
||||
ArrayRenderer,
|
||||
FallbackRenderer
|
||||
} from '../../infrastructure/property-renderers';
|
||||
import {
|
||||
AssetFieldEditor,
|
||||
Vector2FieldEditor,
|
||||
Vector3FieldEditor,
|
||||
Vector4FieldEditor,
|
||||
ColorFieldEditor,
|
||||
AnimationClipsFieldEditor,
|
||||
EntityRefFieldEditor
|
||||
} from '../../infrastructure/field-editors';
|
||||
import { TransformComponentInspector } from '../../components/inspectors/component-inspectors/TransformComponentInspector';
|
||||
import { buildFileSystem } from '../../services/BuildFileSystemService';
|
||||
import { TauriModuleFileSystem } from '../../services/TauriModuleFileSystem';
|
||||
import { PluginSDKRegistry } from '../../services/PluginSDKRegistry';
|
||||
|
||||
export interface EditorServices {
|
||||
uiRegistry: UIRegistry;
|
||||
messageHub: MessageHub;
|
||||
serializerRegistry: SerializerRegistry;
|
||||
entityStore: EntityStoreService;
|
||||
componentRegistry: EditorComponentRegistry;
|
||||
projectService: ProjectService;
|
||||
componentDiscovery: ComponentDiscoveryService;
|
||||
propertyMetadata: PropertyMetadataService;
|
||||
logService: LogService;
|
||||
settingsRegistry: SettingsRegistry;
|
||||
sceneManager: SceneManagerService;
|
||||
fileActionRegistry: FileActionRegistry;
|
||||
pluginManager: PluginManager;
|
||||
diContainer: DIContainer;
|
||||
eventBus: TypedEventBus<EditorEventMap>;
|
||||
commandRegistry: CommandRegistry;
|
||||
panelRegistry: PanelRegistry;
|
||||
fileSystem: TauriFileSystemService;
|
||||
dialog: TauriDialogService;
|
||||
notification: NotificationService;
|
||||
inspectorRegistry: InspectorRegistry;
|
||||
propertyRendererRegistry: PropertyRendererRegistry;
|
||||
fieldEditorRegistry: FieldEditorRegistry;
|
||||
buildService: BuildService;
|
||||
userCodeService: UserCodeService;
|
||||
}
|
||||
|
||||
export class ServiceRegistry {
|
||||
registerAllServices(coreInstance: Core): EditorServices {
|
||||
const fileAPI = new TauriFileAPI();
|
||||
|
||||
const uiRegistry = new UIRegistry();
|
||||
const messageHub = new MessageHub();
|
||||
const serializerRegistry = new SerializerRegistry();
|
||||
const entityStore = new EntityStoreService(messageHub);
|
||||
const componentRegistry = new EditorComponentRegistry();
|
||||
|
||||
// 注册标准组件到编辑器和核心注册表
|
||||
// Register to both editor registry (for UI) and core registry (for serialization)
|
||||
const standardComponents = [
|
||||
{ name: 'TransformComponent', type: TransformComponent, editorName: 'Transform', category: 'components.category.core', description: 'components.transform.description', icon: 'Move3d' },
|
||||
{ name: 'SpriteComponent', type: SpriteComponent, editorName: 'Sprite', category: 'components.category.rendering', description: 'components.sprite.description', icon: 'Image' },
|
||||
{ name: 'SpriteAnimatorComponent', type: SpriteAnimatorComponent, editorName: 'SpriteAnimator', category: 'components.category.rendering', description: 'components.spriteAnimator.description', icon: 'Film' },
|
||||
{ name: 'FGUIComponent', type: FGUIComponent, editorName: 'FGUI', category: 'components.category.ui', description: 'FairyGUI UI component', icon: 'Layout' },
|
||||
{ name: 'CameraComponent', type: CameraComponent, editorName: 'Camera', category: 'components.category.rendering', description: 'components.camera.description', icon: 'Camera' },
|
||||
{ name: 'AudioSourceComponent', type: AudioSourceComponent, editorName: 'AudioSource', category: 'components.category.audio', description: 'components.audioSource.description', icon: 'Volume2' },
|
||||
{ name: 'BehaviorTreeRuntimeComponent', type: BehaviorTreeRuntimeComponent, editorName: 'BehaviorTreeRuntime', category: 'components.category.ai', description: 'components.behaviorTreeRuntime.description', icon: 'GitBranch' }
|
||||
];
|
||||
|
||||
for (const comp of standardComponents) {
|
||||
// Register to editor registry for UI
|
||||
// 组件已通过 @ECSComponent 装饰器自动注册到 GlobalComponentRegistry
|
||||
// Components are auto-registered to GlobalComponentRegistry via @ECSComponent decorator
|
||||
componentRegistry.register({
|
||||
name: comp.editorName,
|
||||
type: comp.type,
|
||||
category: comp.category,
|
||||
description: comp.description,
|
||||
icon: comp.icon
|
||||
});
|
||||
}
|
||||
|
||||
// Enable hot reload for editor environment
|
||||
// 在编辑器环境中启用热更新
|
||||
GlobalComponentRegistry.enableHotReload();
|
||||
|
||||
const projectService = new ProjectService(messageHub, fileAPI);
|
||||
const componentDiscovery = new ComponentDiscoveryService(messageHub);
|
||||
const propertyMetadata = new PropertyMetadataService();
|
||||
const logService = new LogService();
|
||||
const settingsRegistry = new SettingsRegistry();
|
||||
const sceneManager = new SceneManagerService(messageHub, fileAPI, projectService, entityStore);
|
||||
const fileActionRegistry = new FileActionRegistry();
|
||||
const entityCreationRegistry = new EntityCreationRegistry();
|
||||
const componentActionRegistry = new ComponentActionRegistry();
|
||||
const componentInspectorRegistry = new ComponentInspectorRegistry();
|
||||
|
||||
Core.services.registerInstance(UIRegistry, uiRegistry);
|
||||
Core.services.registerInstance(MessageHub, messageHub);
|
||||
Core.services.registerInstance(IMessageHub, messageHub); // Symbol 注册用于跨包插件访问
|
||||
Core.services.registerInstance(SerializerRegistry, serializerRegistry);
|
||||
Core.services.registerInstance(EntityStoreService, entityStore);
|
||||
Core.services.registerInstance(EditorComponentRegistry, componentRegistry);
|
||||
Core.services.registerInstance(ProjectService, projectService);
|
||||
Core.services.registerInstance(ComponentDiscoveryService, componentDiscovery);
|
||||
Core.services.registerInstance(PropertyMetadataService, propertyMetadata);
|
||||
Core.services.registerInstance(LogService, logService);
|
||||
Core.services.registerInstance(SettingsRegistry, settingsRegistry);
|
||||
Core.services.registerInstance(SceneManagerService, sceneManager);
|
||||
Core.services.registerInstance(FileActionRegistry, fileActionRegistry);
|
||||
Core.services.registerInstance(IFileActionRegistry, fileActionRegistry); // Symbol 注册用于跨包插件访问
|
||||
|
||||
// 注册预制体文件处理器 | Register prefab file handler
|
||||
fileActionRegistry.registerActionHandler({
|
||||
extensions: ['prefab'],
|
||||
onDoubleClick: (filePath: string) => {
|
||||
// 发布事件,由编辑器面板处理预制体选择/预览
|
||||
// Publish event for editor panels to handle prefab selection/preview
|
||||
messageHub.publish('prefab:selected', { path: filePath });
|
||||
}
|
||||
});
|
||||
|
||||
Core.services.registerInstance(EntityCreationRegistry, entityCreationRegistry);
|
||||
Core.services.registerInstance(ComponentActionRegistry, componentActionRegistry);
|
||||
Core.services.registerInstance(ComponentInspectorRegistry, componentInspectorRegistry);
|
||||
|
||||
const pluginManager = new PluginManager();
|
||||
Core.services.registerInstance(IPluginManager, pluginManager);
|
||||
|
||||
const diContainer = new DIContainer();
|
||||
const eventBus = new TypedEventBus<EditorEventMap>();
|
||||
const commandRegistry = new CommandRegistry();
|
||||
const panelRegistry = new PanelRegistry();
|
||||
|
||||
const fileSystem = new TauriFileSystemService();
|
||||
const dialog = new TauriDialogService();
|
||||
const notification = new NotificationService();
|
||||
Core.services.registerInstance(NotificationService, notification);
|
||||
Core.services.registerInstance(IDialogService, dialog);
|
||||
Core.services.registerInstance(IFileSystemService, fileSystem);
|
||||
|
||||
// Register viewport service for editor panels
|
||||
// 注册视口服务供编辑器面板使用
|
||||
const viewportService = ViewportService.getInstance();
|
||||
Core.services.registerInstance(IViewportService_ID, viewportService);
|
||||
|
||||
// Register preview scene service for isolated preview scenes
|
||||
// 注册预览场景服务,用于隔离的预览场景
|
||||
const previewSceneService = PreviewSceneService.getInstance();
|
||||
Core.services.registerInstance(IPreviewSceneService, previewSceneService);
|
||||
|
||||
// Register editor viewport service for coordinating viewports with overlays
|
||||
// 注册编辑器视口服务,协调带有覆盖层的视口
|
||||
const editorViewportService = EditorViewportService.getInstance();
|
||||
editorViewportService.setViewportService(viewportService);
|
||||
Core.services.registerInstance(IEditorViewportServiceIdentifier, editorViewportService);
|
||||
|
||||
const inspectorRegistry = new InspectorRegistry();
|
||||
Core.services.registerInstance(InspectorRegistry, inspectorRegistry);
|
||||
Core.services.registerInstance(IInspectorRegistry, inspectorRegistry); // Symbol 注册用于跨包插件访问
|
||||
|
||||
const propertyRendererRegistry = new PropertyRendererRegistry();
|
||||
Core.services.registerInstance(PropertyRendererRegistry, propertyRendererRegistry);
|
||||
|
||||
propertyRendererRegistry.register(new StringRenderer());
|
||||
propertyRendererRegistry.register(new NumberRenderer());
|
||||
propertyRendererRegistry.register(new BooleanRenderer());
|
||||
propertyRendererRegistry.register(new NullRenderer());
|
||||
propertyRendererRegistry.register(new Vector2Renderer());
|
||||
propertyRendererRegistry.register(new Vector3Renderer());
|
||||
propertyRendererRegistry.register(new ColorRenderer());
|
||||
propertyRendererRegistry.register(new ComponentRenderer());
|
||||
propertyRendererRegistry.register(new ArrayRenderer());
|
||||
propertyRendererRegistry.register(new FallbackRenderer());
|
||||
|
||||
const fieldEditorRegistry = new FieldEditorRegistry();
|
||||
Core.services.registerInstance(FieldEditorRegistry, fieldEditorRegistry);
|
||||
|
||||
fieldEditorRegistry.register(new AssetFieldEditor());
|
||||
fieldEditorRegistry.register(new Vector2FieldEditor());
|
||||
fieldEditorRegistry.register(new Vector3FieldEditor());
|
||||
fieldEditorRegistry.register(new Vector4FieldEditor());
|
||||
fieldEditorRegistry.register(new ColorFieldEditor());
|
||||
fieldEditorRegistry.register(new AnimationClipsFieldEditor());
|
||||
fieldEditorRegistry.register(new EntityRefFieldEditor());
|
||||
|
||||
// 注册组件检查器
|
||||
// Register component inspectors
|
||||
componentInspectorRegistry.register(new TransformComponentInspector());
|
||||
|
||||
// 注册构建服务
|
||||
// Register build service
|
||||
const buildService = new BuildService();
|
||||
|
||||
// Register Web build pipeline with file system service
|
||||
// 注册 Web 构建管线并注入文件系统服务
|
||||
const webPipeline = new WebBuildPipeline();
|
||||
webPipeline.setFileSystem(buildFileSystem);
|
||||
|
||||
// Get engine modules path from Tauri backend
|
||||
// 从 Tauri 后端获取引擎模块路径
|
||||
invoke<string>('get_engine_modules_base_path').then(enginePath => {
|
||||
console.log('[ServiceRegistry] Engine modules path:', enginePath);
|
||||
webPipeline.setEngineModulesPath(enginePath);
|
||||
}).catch(err => {
|
||||
console.warn('[ServiceRegistry] Failed to get engine modules path:', err);
|
||||
});
|
||||
|
||||
buildService.register(webPipeline);
|
||||
|
||||
// Register WeChat build pipeline
|
||||
// 注册微信构建管线
|
||||
const wechatPipeline = new WeChatBuildPipeline();
|
||||
wechatPipeline.setFileSystem(buildFileSystem);
|
||||
buildService.register(wechatPipeline);
|
||||
|
||||
Core.services.registerInstance(BuildService, buildService);
|
||||
|
||||
// Initialize ModuleRegistry with Tauri file system
|
||||
// 使用 Tauri 文件系统初始化 ModuleRegistry
|
||||
// Engine modules are read via Tauri commands from local file system
|
||||
// 引擎模块通过 Tauri 命令从本地文件系统读取
|
||||
const tauriModuleFs = new TauriModuleFileSystem();
|
||||
moduleRegistry.initialize(tauriModuleFs, '/engine').catch(err => {
|
||||
console.warn('[ServiceRegistry] Failed to initialize ModuleRegistry:', err);
|
||||
});
|
||||
|
||||
// Initialize UserCodeService for user script compilation and loading
|
||||
// 初始化 UserCodeService 用于用户脚本编译和加载
|
||||
const userCodeService = new UserCodeService(fileSystem);
|
||||
Core.services.registerInstance(UserCodeService, userCodeService);
|
||||
|
||||
// Helper function to compile and load user scripts
|
||||
// 辅助函数:编译和加载用户脚本
|
||||
let currentProjectPath: string | null = null;
|
||||
|
||||
const compileAndLoadUserScripts = async (projectPath: string) => {
|
||||
// Ensure PluginSDKRegistry is initialized before loading user code
|
||||
// 确保在加载用户代码之前 PluginSDKRegistry 已初始化
|
||||
PluginSDKRegistry.initialize();
|
||||
|
||||
try {
|
||||
// 1. 编译运行时脚本 | Compile runtime scripts
|
||||
const runtimeResult = await userCodeService.compile({
|
||||
projectPath: projectPath,
|
||||
target: UserCodeTarget.Runtime
|
||||
});
|
||||
|
||||
if (runtimeResult.success && runtimeResult.outputPath) {
|
||||
const module = await userCodeService.load(runtimeResult.outputPath, UserCodeTarget.Runtime);
|
||||
userCodeService.registerComponents(module, componentRegistry);
|
||||
messageHub.publish('usercode:reloaded', {
|
||||
projectPath,
|
||||
exports: Object.keys(module.exports)
|
||||
});
|
||||
} else if (runtimeResult.errors.length > 0) {
|
||||
console.warn('[UserCodeService] Runtime compilation errors:', runtimeResult.errors);
|
||||
}
|
||||
|
||||
// 2. 编译编辑器脚本 | Compile editor scripts
|
||||
const editorResult = await userCodeService.compile({
|
||||
projectPath: projectPath,
|
||||
target: UserCodeTarget.Editor
|
||||
});
|
||||
|
||||
if (editorResult.success && editorResult.outputPath) {
|
||||
const editorModule = await userCodeService.load(editorResult.outputPath, UserCodeTarget.Editor);
|
||||
userCodeService.registerEditorExtensions(editorModule, componentInspectorRegistry);
|
||||
messageHub.publish('usercode:editor-reloaded', {
|
||||
projectPath,
|
||||
exports: Object.keys(editorModule.exports)
|
||||
});
|
||||
} else if (editorResult.errors.length > 0) {
|
||||
// 编辑器脚本编译错误只记录,不影响运行时
|
||||
console.warn('[UserCodeService] Editor compilation errors:', editorResult.errors);
|
||||
}
|
||||
|
||||
// 编译完成,发出就绪信号 | Compilation done, signal ready
|
||||
userCodeService.signalReady();
|
||||
} catch (error) {
|
||||
console.error('[UserCodeService] Failed to compile/load:', error);
|
||||
// 即使编译失败也要发出就绪信号,避免阻塞场景加载
|
||||
// Signal ready even on failure to avoid blocking scene loading
|
||||
userCodeService.signalReady();
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to project:opened to compile and load user scripts
|
||||
// 订阅 project:opened 以编译和加载用户脚本
|
||||
messageHub.subscribe('project:opened', async (data: { path: string; type: string; name: string }) => {
|
||||
currentProjectPath = data.path;
|
||||
await compileAndLoadUserScripts(data.path);
|
||||
|
||||
// Start watching for file changes (external editor support)
|
||||
// 开始监视文件变更(支持外部编辑器)
|
||||
userCodeService.watch(data.path, async (event) => {
|
||||
console.log('[UserCodeService] Hot reload event:', event.changedFiles);
|
||||
|
||||
if (event.newModule) {
|
||||
// 1. 注册新的/更新的组件到注册表
|
||||
userCodeService.registerComponents(event.newModule, componentRegistry);
|
||||
|
||||
// 2. 热更新:更新现有实例的原型链
|
||||
const updatedCount = userCodeService.hotReloadInstances(event.newModule);
|
||||
console.log(`[UserCodeService] Hot reloaded ${updatedCount} component instances`);
|
||||
|
||||
// 3. 如果正在预览,热更新用户系统
|
||||
const scene = Core.scene;
|
||||
if (scene && !scene.isEditorMode) {
|
||||
userCodeService.hotReloadSystems(event.newModule, scene);
|
||||
}
|
||||
|
||||
// 4. 通知用户代码已重新加载
|
||||
messageHub.publish('usercode:reloaded', {
|
||||
projectPath: data.path,
|
||||
exports: Object.keys(event.newModule.exports),
|
||||
updatedInstances: updatedCount
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
console.warn('[UserCodeService] Failed to start file watcher:', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to project:closed to stop watching and cleanup
|
||||
// 订阅 project:closed 以停止监视和清理
|
||||
messageHub.subscribe('project:closed', async () => {
|
||||
currentProjectPath = null;
|
||||
await userCodeService.stopWatch();
|
||||
userCodeService.unregisterEditorExtensions(componentInspectorRegistry);
|
||||
});
|
||||
|
||||
// Subscribe to script file changes (create/delete) from editor operations
|
||||
// 订阅编辑器操作的脚本文件变更(创建/删除)
|
||||
// Note: file:modified is handled by the Rust file watcher for external editor support
|
||||
// 注意:file:modified 由 Rust 文件监视器处理以支持外部编辑器
|
||||
messageHub.subscribe('file:created', async (data: { path: string }) => {
|
||||
if (currentProjectPath && this.isScriptFile(data.path)) {
|
||||
await compileAndLoadUserScripts(currentProjectPath);
|
||||
}
|
||||
});
|
||||
|
||||
messageHub.subscribe('file:deleted', async (data: { path: string }) => {
|
||||
if (currentProjectPath && this.isScriptFile(data.path)) {
|
||||
await compileAndLoadUserScripts(currentProjectPath);
|
||||
}
|
||||
});
|
||||
|
||||
// 预览开始时注册用户系统
|
||||
// Register user systems when preview starts
|
||||
messageHub.subscribe('preview:start', () => {
|
||||
const runtimeModule = userCodeService.getModule(UserCodeTarget.Runtime);
|
||||
if (runtimeModule) {
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
userCodeService.registerSystems(runtimeModule, scene);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 预览停止时移除用户系统
|
||||
// Unregister user systems when preview stops
|
||||
messageHub.subscribe('preview:stop', () => {
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
userCodeService.unregisterSystems(scene);
|
||||
}
|
||||
});
|
||||
|
||||
// 注册默认场景模板 - 创建默认相机
|
||||
// Register default scene template - creates default camera
|
||||
this.registerDefaultSceneTemplate();
|
||||
|
||||
return {
|
||||
uiRegistry,
|
||||
messageHub,
|
||||
serializerRegistry,
|
||||
entityStore,
|
||||
componentRegistry,
|
||||
projectService,
|
||||
componentDiscovery,
|
||||
propertyMetadata,
|
||||
logService,
|
||||
settingsRegistry,
|
||||
sceneManager,
|
||||
fileActionRegistry,
|
||||
pluginManager,
|
||||
diContainer,
|
||||
eventBus,
|
||||
commandRegistry,
|
||||
panelRegistry,
|
||||
fileSystem,
|
||||
dialog,
|
||||
notification,
|
||||
inspectorRegistry,
|
||||
propertyRendererRegistry,
|
||||
fieldEditorRegistry,
|
||||
buildService,
|
||||
userCodeService
|
||||
};
|
||||
}
|
||||
|
||||
setupRemoteLogListener(logService: LogService): void {
|
||||
window.addEventListener('profiler:remote-log', ((event: CustomEvent) => {
|
||||
const { level, message, timestamp, clientId } = event.detail;
|
||||
logService.addRemoteLog(level, message, timestamp, clientId);
|
||||
}) as EventListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file path is a TypeScript script file (not in editor folder)
|
||||
* 检查文件路径是否为 TypeScript 脚本文件(不在 editor 文件夹中)
|
||||
*/
|
||||
private isScriptFile(filePath: string): boolean {
|
||||
// Must be .ts file | 必须是 .ts 文件
|
||||
if (!filePath.endsWith('.ts')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize path separators | 规范化路径分隔符
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
|
||||
// Must be in scripts folder | 必须在 scripts 文件夹中
|
||||
if (!normalizedPath.includes('/scripts/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude editor scripts | 排除编辑器脚本
|
||||
if (normalizedPath.includes('/scripts/editor/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude .esengine folder | 排除 .esengine 文件夹
|
||||
if (normalizedPath.includes('/.esengine/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册默认场景模板
|
||||
* Register default scene template with default entities
|
||||
*/
|
||||
private registerDefaultSceneTemplate(): void {
|
||||
// 注册默认相机创建器
|
||||
// Register default camera creator
|
||||
SceneTemplateRegistry.registerDefaultEntity((scene) => {
|
||||
// 检查是否已存在相机
|
||||
// Check if camera already exists
|
||||
const existingCameras = scene.entities.findEntitiesWithComponent(CameraComponent);
|
||||
if (existingCameras.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 创建默认相机实体
|
||||
// Create default camera entity
|
||||
const cameraEntity = scene.createEntity('Main Camera');
|
||||
cameraEntity.addComponent(new TransformComponent());
|
||||
const camera = new CameraComponent();
|
||||
camera.orthographicSize = 1;
|
||||
cameraEntity.addComponent(camera);
|
||||
|
||||
return cameraEntity;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './ServiceRegistry';
|
||||
export * from './DialogManager';
|
||||
export * from './PluginInstaller';
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ICommand } from './ICommand';
|
||||
|
||||
/**
|
||||
* 命令基类
|
||||
* 提供默认实现,具体命令继承此类
|
||||
*/
|
||||
export abstract class BaseCommand implements ICommand {
|
||||
abstract execute(): void;
|
||||
abstract undo(): void;
|
||||
abstract getDescription(): string;
|
||||
|
||||
/**
|
||||
* 默认不支持合并
|
||||
*/
|
||||
canMergeWith(_other: ICommand): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认抛出错误
|
||||
*/
|
||||
mergeWith(_other: ICommand): ICommand {
|
||||
throw new Error(`${this.constructor.name} 不支持合并操作`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { ICommand } from './ICommand';
|
||||
|
||||
/**
|
||||
* 命令历史记录配置
|
||||
*/
|
||||
export interface CommandManagerConfig {
|
||||
/**
|
||||
* 最大历史记录数量
|
||||
*/
|
||||
maxHistorySize?: number;
|
||||
|
||||
/**
|
||||
* 是否自动合并相似命令
|
||||
*/
|
||||
autoMerge?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令管理器
|
||||
* 管理命令的执行、撤销、重做以及历史记录
|
||||
*/
|
||||
export class CommandManager {
|
||||
private undoStack: ICommand[] = [];
|
||||
private redoStack: ICommand[] = [];
|
||||
private readonly config: Required<CommandManagerConfig>;
|
||||
private isExecuting = false;
|
||||
|
||||
constructor(config: CommandManagerConfig = {}) {
|
||||
this.config = {
|
||||
maxHistorySize: config.maxHistorySize ?? 100,
|
||||
autoMerge: config.autoMerge ?? true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行命令
|
||||
*/
|
||||
execute(command: ICommand): void {
|
||||
if (this.isExecuting) {
|
||||
throw new Error('不能在命令执行过程中执行新命令');
|
||||
}
|
||||
|
||||
this.isExecuting = true;
|
||||
|
||||
try {
|
||||
command.execute();
|
||||
|
||||
if (this.config.autoMerge && this.undoStack.length > 0) {
|
||||
const lastCommand = this.undoStack[this.undoStack.length - 1];
|
||||
if (lastCommand && lastCommand.canMergeWith(command)) {
|
||||
const mergedCommand = lastCommand.mergeWith(command);
|
||||
this.undoStack[this.undoStack.length - 1] = mergedCommand;
|
||||
this.redoStack = [];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.undoStack.push(command);
|
||||
this.redoStack = [];
|
||||
|
||||
if (this.undoStack.length > this.config.maxHistorySize) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销上一个命令
|
||||
*/
|
||||
undo(): void {
|
||||
if (this.isExecuting) {
|
||||
throw new Error('不能在命令执行过程中撤销');
|
||||
}
|
||||
|
||||
const command = this.undoStack.pop();
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isExecuting = true;
|
||||
|
||||
try {
|
||||
command.undo();
|
||||
this.redoStack.push(command);
|
||||
} catch (error) {
|
||||
this.undoStack.push(command);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重做上一个被撤销的命令
|
||||
*/
|
||||
redo(): void {
|
||||
if (this.isExecuting) {
|
||||
throw new Error('不能在命令执行过程中重做');
|
||||
}
|
||||
|
||||
const command = this.redoStack.pop();
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isExecuting = true;
|
||||
|
||||
try {
|
||||
command.execute();
|
||||
this.undoStack.push(command);
|
||||
} catch (error) {
|
||||
this.redoStack.push(command);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以撤销
|
||||
*/
|
||||
canUndo(): boolean {
|
||||
return this.undoStack.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以重做
|
||||
*/
|
||||
canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取撤销栈的描述列表
|
||||
*/
|
||||
getUndoHistory(): string[] {
|
||||
return this.undoStack.map((cmd) => cmd.getDescription());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取重做栈的描述列表
|
||||
*/
|
||||
getRedoHistory(): string[] {
|
||||
return this.redoStack.map((cmd) => cmd.getDescription());
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有历史记录
|
||||
*/
|
||||
clear(): void {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量执行命令(作为单一操作,可以一次撤销)
|
||||
*/
|
||||
executeBatch(commands: ICommand[]): void {
|
||||
if (commands.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batchCommand = new BatchCommand(commands);
|
||||
this.execute(batchCommand);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将命令推入撤销栈但不执行
|
||||
* Push command to undo stack without executing
|
||||
*
|
||||
* 用于已经执行过的操作(如拖动变换),只需要记录到历史
|
||||
* Used for operations that have already been performed (like drag transforms),
|
||||
* only need to record to history
|
||||
*/
|
||||
pushWithoutExecute(command: ICommand): void {
|
||||
if (this.config.autoMerge && this.undoStack.length > 0) {
|
||||
const lastCommand = this.undoStack[this.undoStack.length - 1];
|
||||
if (lastCommand && lastCommand.canMergeWith(command)) {
|
||||
const mergedCommand = lastCommand.mergeWith(command);
|
||||
this.undoStack[this.undoStack.length - 1] = mergedCommand;
|
||||
this.redoStack = [];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.undoStack.push(command);
|
||||
this.redoStack = [];
|
||||
|
||||
if (this.undoStack.length > this.config.maxHistorySize) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量命令
|
||||
* 将多个命令组合为一个命令
|
||||
*/
|
||||
class BatchCommand implements ICommand {
|
||||
constructor(private readonly commands: ICommand[]) {}
|
||||
|
||||
execute(): void {
|
||||
for (const command of this.commands) {
|
||||
command.execute();
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
for (let i = this.commands.length - 1; i >= 0; i--) {
|
||||
const command = this.commands[i];
|
||||
if (command) {
|
||||
command.undo();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `批量操作 (${this.commands.length} 个命令)`;
|
||||
}
|
||||
|
||||
canMergeWith(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
mergeWith(): ICommand {
|
||||
throw new Error('批量命令不支持合并');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 命令接口
|
||||
* 实现命令模式,支持撤销/重做功能
|
||||
*/
|
||||
export interface ICommand {
|
||||
/**
|
||||
* 执行命令
|
||||
*/
|
||||
execute(): void;
|
||||
|
||||
/**
|
||||
* 撤销命令
|
||||
*/
|
||||
undo(): void;
|
||||
|
||||
/**
|
||||
* 获取命令描述(用于显示历史记录)
|
||||
*/
|
||||
getDescription(): string;
|
||||
|
||||
/**
|
||||
* 检查命令是否可以合并
|
||||
* 用于优化撤销/重做历史,例如连续的移动操作可以合并为一个
|
||||
*/
|
||||
canMergeWith(other: ICommand): boolean;
|
||||
|
||||
/**
|
||||
* 与另一个命令合并
|
||||
*/
|
||||
mergeWith(other: ICommand): ICommand;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Entity, Component, getComponentDependencies, getComponentTypeName } from '@esengine/ecs-framework';
|
||||
import { MessageHub, EditorComponentRegistry } from '@esengine/editor-core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 添加组件命令
|
||||
*
|
||||
* 自动添加缺失的依赖组件(通过 @ECSComponent requires 选项声明)
|
||||
* Automatically adds missing dependency components (declared via @ECSComponent requires option)
|
||||
*/
|
||||
export class AddComponentCommand extends BaseCommand {
|
||||
private component: Component | null = null;
|
||||
/** 自动添加的依赖组件(用于撤销时一并移除) | Auto-added dependencies (for undo removal) */
|
||||
private autoAddedDependencies: Component[] = [];
|
||||
|
||||
constructor(
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity,
|
||||
private ComponentClass: new () => Component,
|
||||
private initialData?: Record<string, unknown>
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// 先添加缺失的依赖组件 | Add missing dependencies first
|
||||
this.addMissingDependencies();
|
||||
|
||||
this.component = new this.ComponentClass();
|
||||
|
||||
// 应用初始数据 | Apply initial data
|
||||
if (this.initialData) {
|
||||
for (const [key, value] of Object.entries(this.initialData)) {
|
||||
(this.component as any)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
this.entity.addComponent(this.component);
|
||||
|
||||
this.messageHub.publish('component:added', {
|
||||
entity: this.entity,
|
||||
component: this.component
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加缺失的依赖组件
|
||||
* Add missing dependency components
|
||||
*/
|
||||
private addMissingDependencies(): void {
|
||||
const dependencies = getComponentDependencies(this.ComponentClass);
|
||||
|
||||
if (!dependencies || dependencies.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentRegistry = Core.services.tryResolve(EditorComponentRegistry) as EditorComponentRegistry | null;
|
||||
if (!componentRegistry) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const depName of dependencies) {
|
||||
// 检查实体是否已有该依赖组件 | Check if entity already has this dependency
|
||||
const depInfo = componentRegistry.getComponent(depName);
|
||||
|
||||
if (!depInfo?.type) {
|
||||
console.warn(`Dependency component not found in registry: ${depName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const DepClass = depInfo.type;
|
||||
|
||||
// 使用名称检查而非类引用,因为打包可能导致同一个类有多个副本
|
||||
// Use name-based check instead of class reference, as bundling may create multiple copies of the same class
|
||||
const foundByName = this.entity.components.find(c => c.constructor.name === DepClass.name);
|
||||
|
||||
if (foundByName) {
|
||||
// 组件已存在(通过名称匹配),跳过添加
|
||||
// Component already exists (matched by name), skip adding
|
||||
continue;
|
||||
}
|
||||
|
||||
// 自动添加依赖组件 | Auto-add dependency component
|
||||
const depComponent = new DepClass();
|
||||
this.entity.addComponent(depComponent);
|
||||
this.autoAddedDependencies.push(depComponent);
|
||||
|
||||
this.messageHub.publish('component:added', {
|
||||
entity: this.entity,
|
||||
component: depComponent,
|
||||
isAutoDependency: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.component) return;
|
||||
|
||||
// 先移除主组件 | Remove main component first
|
||||
this.entity.removeComponent(this.component);
|
||||
|
||||
this.messageHub.publish('component:removed', {
|
||||
entity: this.entity,
|
||||
componentType: getComponentTypeName(this.ComponentClass)
|
||||
});
|
||||
|
||||
// 移除自动添加的依赖组件(逆序) | Remove auto-added dependencies (reverse order)
|
||||
for (let i = this.autoAddedDependencies.length - 1; i >= 0; i--) {
|
||||
const dep = this.autoAddedDependencies[i];
|
||||
if (dep) {
|
||||
this.entity.removeComponent(dep);
|
||||
this.messageHub.publish('component:removed', {
|
||||
entity: this.entity,
|
||||
componentType: dep.constructor.name,
|
||||
isAutoDependency: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.component = null;
|
||||
this.autoAddedDependencies = [];
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const mainName = getComponentTypeName(this.ComponentClass);
|
||||
if (this.autoAddedDependencies.length > 0) {
|
||||
const depNames = this.autoAddedDependencies.map(d => d.constructor.name).join(', ');
|
||||
return `添加组件: ${mainName} (+ 依赖: ${depNames})`;
|
||||
}
|
||||
return `添加组件: ${mainName}`;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { Entity, Component } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 移除组件命令
|
||||
*/
|
||||
export class RemoveComponentCommand extends BaseCommand {
|
||||
private componentData: Record<string, unknown> = {};
|
||||
private ComponentClass: new () => Component;
|
||||
|
||||
constructor(
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity,
|
||||
private component: Component
|
||||
) {
|
||||
super();
|
||||
this.ComponentClass = component.constructor as new () => Component;
|
||||
|
||||
// 保存组件数据用于撤销
|
||||
for (const key of Object.keys(component)) {
|
||||
if (key !== 'entity' && key !== 'id') {
|
||||
this.componentData[key] = (component as any)[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.entity.removeComponent(this.component);
|
||||
|
||||
this.messageHub.publish('component:removed', {
|
||||
entity: this.entity,
|
||||
componentType: this.ComponentClass.name
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const newComponent = new this.ComponentClass();
|
||||
|
||||
// 恢复数据
|
||||
for (const [key, value] of Object.entries(this.componentData)) {
|
||||
(newComponent as any)[key] = value;
|
||||
}
|
||||
|
||||
this.entity.addComponent(newComponent);
|
||||
this.component = newComponent;
|
||||
|
||||
this.messageHub.publish('component:added', {
|
||||
entity: this.entity,
|
||||
component: newComponent
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `移除组件: ${this.ComponentClass.name}`;
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { Entity, Component } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
import { ICommand } from '../ICommand';
|
||||
|
||||
/**
|
||||
* 更新组件属性命令
|
||||
*/
|
||||
export class UpdateComponentCommand extends BaseCommand {
|
||||
private oldValue: unknown;
|
||||
|
||||
constructor(
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity,
|
||||
private component: Component,
|
||||
private propertyName: string,
|
||||
private newValue: unknown
|
||||
) {
|
||||
super();
|
||||
this.oldValue = (component as any)[propertyName];
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
(this.component as any)[this.propertyName] = this.newValue;
|
||||
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entity: this.entity,
|
||||
component: this.component,
|
||||
propertyName: this.propertyName,
|
||||
value: this.newValue
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
(this.component as any)[this.propertyName] = this.oldValue;
|
||||
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entity: this.entity,
|
||||
component: this.component,
|
||||
propertyName: this.propertyName,
|
||||
value: this.oldValue
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `更新 ${this.component.constructor.name}.${this.propertyName}`;
|
||||
}
|
||||
|
||||
canMergeWith(other: ICommand): boolean {
|
||||
if (!(other instanceof UpdateComponentCommand)) return false;
|
||||
|
||||
return (
|
||||
this.entity === other.entity &&
|
||||
this.component === other.component &&
|
||||
this.propertyName === other.propertyName
|
||||
);
|
||||
}
|
||||
|
||||
mergeWith(other: ICommand): ICommand {
|
||||
if (!(other instanceof UpdateComponentCommand)) {
|
||||
throw new Error('无法合并不同类型的命令');
|
||||
}
|
||||
|
||||
// 保留原始值,使用新命令的新值
|
||||
const merged = new UpdateComponentCommand(
|
||||
this.messageHub,
|
||||
this.entity,
|
||||
this.component,
|
||||
this.propertyName,
|
||||
other.newValue
|
||||
);
|
||||
merged.oldValue = this.oldValue;
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { AddComponentCommand } from './AddComponentCommand';
|
||||
export { RemoveComponentCommand } from './RemoveComponentCommand';
|
||||
export { UpdateComponentCommand } from './UpdateComponentCommand';
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent, SpriteAnimatorComponent } from '@esengine/sprite';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 创建带动画组件的Sprite实体命令
|
||||
*/
|
||||
export class CreateAnimatedSpriteEntityCommand extends BaseCommand {
|
||||
private entity: Entity | null = null;
|
||||
private entityId: number | null = null;
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private entityName: string,
|
||||
private parentEntity?: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化');
|
||||
}
|
||||
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 添加 Transform、Sprite、Animator 和 Hierarchy 组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
this.entity.addComponent(new SpriteComponent());
|
||||
this.entity.addComponent(new SpriteAnimatorComponent());
|
||||
this.entity.addComponent(new HierarchyComponent());
|
||||
|
||||
if (this.parentEntity) {
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
hierarchySystem?.setParent(this.entity, this.parentEntity);
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(this.entity, this.parentEntity);
|
||||
this.entityStore.selectEntity(this.entity);
|
||||
|
||||
this.messageHub.publish('entity:added', { entity: this.entity });
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.entity) return;
|
||||
|
||||
this.entityStore.removeEntity(this.entity);
|
||||
this.entity.destroy();
|
||||
|
||||
this.messageHub.publish('entity:removed', { entityId: this.entityId });
|
||||
|
||||
this.entity = null;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `创建动画Sprite实体: ${this.entityName}`;
|
||||
}
|
||||
|
||||
getCreatedEntity(): Entity | null {
|
||||
return this.entity;
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { CameraComponent } from '@esengine/camera';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 创建带Camera组件的实体命令
|
||||
*/
|
||||
export class CreateCameraEntityCommand extends BaseCommand {
|
||||
private entity: Entity | null = null;
|
||||
private entityId: number | null = null;
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private entityName: string,
|
||||
private parentEntity?: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化');
|
||||
}
|
||||
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 添加 Transform、Camera 和 Hierarchy 组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
this.entity.addComponent(new CameraComponent());
|
||||
this.entity.addComponent(new HierarchyComponent());
|
||||
|
||||
if (this.parentEntity) {
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
hierarchySystem?.setParent(this.entity, this.parentEntity);
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(this.entity, this.parentEntity);
|
||||
this.entityStore.selectEntity(this.entity);
|
||||
|
||||
this.messageHub.publish('entity:added', { entity: this.entity });
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.entity) return;
|
||||
|
||||
this.entityStore.removeEntity(this.entity);
|
||||
this.entity.destroy();
|
||||
|
||||
this.messageHub.publish('entity:removed', { entityId: this.entityId });
|
||||
|
||||
this.entity = null;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `创建Camera实体: ${this.entityName}`;
|
||||
}
|
||||
|
||||
getCreatedEntity(): Entity | null {
|
||||
return this.entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 创建实体命令
|
||||
*/
|
||||
export class CreateEntityCommand extends BaseCommand {
|
||||
private entity: Entity | null = null;
|
||||
private entityId: number | null = null;
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private entityName: string,
|
||||
private parentEntity?: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化');
|
||||
}
|
||||
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 自动添加 Transform 组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
|
||||
// 添加 HierarchyComponent 支持层级结构
|
||||
this.entity.addComponent(new HierarchyComponent());
|
||||
|
||||
if (this.parentEntity) {
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
hierarchySystem?.setParent(this.entity, this.parentEntity);
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(this.entity, this.parentEntity);
|
||||
this.entityStore.selectEntity(this.entity);
|
||||
|
||||
this.messageHub.publish('entity:added', { entity: this.entity });
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.entity) return;
|
||||
|
||||
this.entityStore.removeEntity(this.entity);
|
||||
this.entity.destroy();
|
||||
|
||||
this.messageHub.publish('entity:removed', { entityId: this.entityId });
|
||||
|
||||
this.entity = null;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `创建实体: ${this.entityName}`;
|
||||
}
|
||||
|
||||
getCreatedEntity(): Entity | null {
|
||||
return this.entity;
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { SpriteComponent } from '@esengine/sprite';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 创建带Sprite组件的实体命令
|
||||
*/
|
||||
export class CreateSpriteEntityCommand extends BaseCommand {
|
||||
private entity: Entity | null = null;
|
||||
private entityId: number | null = null;
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private entityName: string,
|
||||
private parentEntity?: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化');
|
||||
}
|
||||
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 添加 Transform、Sprite 和 Hierarchy 组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
this.entity.addComponent(new SpriteComponent());
|
||||
this.entity.addComponent(new HierarchyComponent());
|
||||
|
||||
if (this.parentEntity) {
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
hierarchySystem?.setParent(this.entity, this.parentEntity);
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(this.entity, this.parentEntity);
|
||||
this.entityStore.selectEntity(this.entity);
|
||||
|
||||
this.messageHub.publish('entity:added', { entity: this.entity });
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.entity) return;
|
||||
|
||||
this.entityStore.removeEntity(this.entity);
|
||||
this.entity.destroy();
|
||||
|
||||
this.messageHub.publish('entity:removed', { entityId: this.entityId });
|
||||
|
||||
this.entity = null;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `创建Sprite实体: ${this.entityName}`;
|
||||
}
|
||||
|
||||
getCreatedEntity(): Entity | null {
|
||||
return this.entity;
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { TilemapComponent } from '@esengine/tilemap';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* Tilemap创建选项
|
||||
*/
|
||||
export interface TilemapCreationOptions {
|
||||
/** 地图宽度(瓦片数),默认10 */
|
||||
width?: number;
|
||||
/** 地图高度(瓦片数),默认10 */
|
||||
height?: number;
|
||||
/** 瓦片宽度(像素),默认32 */
|
||||
tileWidth?: number;
|
||||
/** 瓦片高度(像素),默认32 */
|
||||
tileHeight?: number;
|
||||
/** 渲染层级,默认0 */
|
||||
sortingOrder?: number;
|
||||
/** 初始Tileset源路径 */
|
||||
tilesetSource?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带Tilemap组件的实体命令
|
||||
*/
|
||||
export class CreateTilemapEntityCommand extends BaseCommand {
|
||||
private entity: Entity | null = null;
|
||||
private entityId: number | null = null;
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private entityName: string,
|
||||
private parentEntity?: Entity,
|
||||
private options: TilemapCreationOptions = {}
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化');
|
||||
}
|
||||
|
||||
this.entity = scene.createEntity(this.entityName);
|
||||
this.entityId = this.entity.id;
|
||||
|
||||
// 添加 Transform 和 Hierarchy 组件
|
||||
this.entity.addComponent(new TransformComponent());
|
||||
this.entity.addComponent(new HierarchyComponent());
|
||||
|
||||
// 创建并配置Tilemap组件
|
||||
const tilemapComponent = new TilemapComponent();
|
||||
|
||||
// 应用配置选项
|
||||
const {
|
||||
width = 10,
|
||||
height = 10,
|
||||
tileWidth = 32,
|
||||
tileHeight = 32,
|
||||
sortingOrder = 0,
|
||||
tilesetSource
|
||||
} = this.options;
|
||||
|
||||
tilemapComponent.tileWidth = tileWidth;
|
||||
tilemapComponent.tileHeight = tileHeight;
|
||||
tilemapComponent.sortingOrder = sortingOrder;
|
||||
|
||||
// 初始化空白地图
|
||||
tilemapComponent.initializeEmpty(width, height);
|
||||
|
||||
// 添加初始 Tileset
|
||||
if (tilesetSource) {
|
||||
tilemapComponent.addTileset(tilesetSource);
|
||||
}
|
||||
|
||||
this.entity.addComponent(tilemapComponent);
|
||||
|
||||
if (this.parentEntity) {
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
hierarchySystem?.setParent(this.entity, this.parentEntity);
|
||||
}
|
||||
|
||||
this.entityStore.addEntity(this.entity, this.parentEntity);
|
||||
this.entityStore.selectEntity(this.entity);
|
||||
|
||||
this.messageHub.publish('entity:added', { entity: this.entity });
|
||||
this.messageHub.publish('tilemap:created', {
|
||||
entity: this.entity,
|
||||
component: tilemapComponent
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.entity) return;
|
||||
|
||||
this.entityStore.removeEntity(this.entity);
|
||||
this.entity.destroy();
|
||||
|
||||
this.messageHub.publish('entity:removed', { entityId: this.entityId });
|
||||
|
||||
this.entity = null;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `创建Tilemap实体: ${this.entityName}`;
|
||||
}
|
||||
|
||||
getCreatedEntity(): Entity | null {
|
||||
return this.entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Core, Entity, Component, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 删除实体命令
|
||||
*/
|
||||
export class DeleteEntityCommand extends BaseCommand {
|
||||
private entityId: number;
|
||||
private entityName: string;
|
||||
private parentEntityId: number | null;
|
||||
private components: Component[] = [];
|
||||
private childEntityIds: number[] = [];
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity
|
||||
) {
|
||||
super();
|
||||
this.entityId = entity.id;
|
||||
this.entityName = entity.name;
|
||||
|
||||
// 通过 HierarchyComponent 获取父实体 ID
|
||||
const hierarchy = entity.getComponent(HierarchyComponent);
|
||||
this.parentEntityId = hierarchy?.parentId ?? null;
|
||||
|
||||
// 保存组件状态用于撤销
|
||||
this.components = [...entity.components];
|
||||
|
||||
// 保存子实体 ID
|
||||
this.childEntityIds = hierarchy?.childIds ? [...hierarchy.childIds] : [];
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
// 先移除子实体
|
||||
for (const childId of this.childEntityIds) {
|
||||
const child = scene.findEntityById(childId);
|
||||
if (child) {
|
||||
this.entityStore.removeEntity(child);
|
||||
}
|
||||
}
|
||||
|
||||
this.entityStore.removeEntity(this.entity);
|
||||
this.entity.destroy();
|
||||
|
||||
this.messageHub.publish('entity:removed', { entityId: this.entityId });
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化');
|
||||
}
|
||||
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
|
||||
// 重新创建实体
|
||||
const newEntity = scene.createEntity(this.entityName);
|
||||
|
||||
// 设置父实体
|
||||
if (this.parentEntityId !== null && hierarchySystem) {
|
||||
const parentEntity = scene.findEntityById(this.parentEntityId);
|
||||
if (parentEntity) {
|
||||
hierarchySystem.setParent(newEntity, parentEntity);
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复组件
|
||||
for (const component of this.components) {
|
||||
// 创建组件副本
|
||||
const ComponentClass = component.constructor as new () => Component;
|
||||
const newComponent = new ComponentClass();
|
||||
|
||||
// 复制属性
|
||||
for (const key of Object.keys(component)) {
|
||||
if (key !== 'entity' && key !== 'id') {
|
||||
(newComponent as any)[key] = (component as any)[key];
|
||||
}
|
||||
}
|
||||
|
||||
newEntity.addComponent(newComponent);
|
||||
}
|
||||
|
||||
// 恢复子实体
|
||||
for (const childId of this.childEntityIds) {
|
||||
const child = scene.findEntityById(childId);
|
||||
if (child && hierarchySystem) {
|
||||
hierarchySystem.setParent(child, newEntity);
|
||||
this.entityStore.addEntity(child, newEntity);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取父实体
|
||||
const parentEntity = this.parentEntityId !== null
|
||||
? scene.findEntityById(this.parentEntityId) ?? undefined
|
||||
: undefined;
|
||||
|
||||
this.entityStore.addEntity(newEntity, parentEntity);
|
||||
this.entityStore.selectEntity(newEntity);
|
||||
|
||||
// 更新引用
|
||||
this.entity = newEntity;
|
||||
|
||||
this.messageHub.publish('entity:added', { entity: newEntity });
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `删除实体: ${this.entityName}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Core, Entity, HierarchySystem, HierarchyComponent } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 拖放位置类型
|
||||
*/
|
||||
export enum DropPosition {
|
||||
/** 在目标之前 */
|
||||
BEFORE = 'before',
|
||||
/** 在目标内部(作为子级) */
|
||||
INSIDE = 'inside',
|
||||
/** 在目标之后 */
|
||||
AFTER = 'after'
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新设置实体父级命令
|
||||
*
|
||||
* 支持拖拽重排功能,可以将实体移动到:
|
||||
* - 另一个实体之前 (BEFORE)
|
||||
* - 另一个实体内部作为子级 (INSIDE)
|
||||
* - 另一个实体之后 (AFTER)
|
||||
*/
|
||||
export class ReparentEntityCommand extends BaseCommand {
|
||||
private oldParentId: number | null;
|
||||
private oldSiblingIndex: number;
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity,
|
||||
private targetEntity: Entity,
|
||||
private dropPosition: DropPosition
|
||||
) {
|
||||
super();
|
||||
|
||||
// 保存原始状态用于撤销
|
||||
const hierarchy = entity.getComponent(HierarchyComponent);
|
||||
this.oldParentId = hierarchy?.parentId ?? null;
|
||||
|
||||
// 获取在兄弟列表中的原始索引
|
||||
this.oldSiblingIndex = this.getSiblingIndex(entity);
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
console.warn('[ReparentEntityCommand] No scene available');
|
||||
return;
|
||||
}
|
||||
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
if (!hierarchySystem) {
|
||||
console.warn('[ReparentEntityCommand] No HierarchySystem found');
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保目标实体有 HierarchyComponent
|
||||
if (!this.targetEntity.getComponent(HierarchyComponent)) {
|
||||
this.targetEntity.addComponent(new HierarchyComponent());
|
||||
}
|
||||
|
||||
console.log(`[ReparentEntityCommand] Moving ${this.entity.name} to ${this.targetEntity.name} (${this.dropPosition})`);
|
||||
|
||||
switch (this.dropPosition) {
|
||||
case DropPosition.INSIDE:
|
||||
// 移动到目标实体内部作为最后一个子级
|
||||
hierarchySystem.setParent(this.entity, this.targetEntity);
|
||||
break;
|
||||
|
||||
case DropPosition.BEFORE:
|
||||
case DropPosition.AFTER:
|
||||
// 移动到目标实体的同级
|
||||
this.moveToSibling(hierarchySystem);
|
||||
break;
|
||||
}
|
||||
|
||||
this.entityStore.syncFromScene();
|
||||
this.messageHub.publish('entity:reparented', {
|
||||
entityId: this.entity.id,
|
||||
targetId: this.targetEntity.id,
|
||||
position: this.dropPosition
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
if (!hierarchySystem) return;
|
||||
|
||||
// 恢复到原始父级
|
||||
const oldParent = this.oldParentId !== null
|
||||
? scene.findEntityById(this.oldParentId)
|
||||
: null;
|
||||
|
||||
if (oldParent) {
|
||||
// 恢复到原始父级的指定位置
|
||||
hierarchySystem.insertChildAt(oldParent, this.entity, this.oldSiblingIndex);
|
||||
} else {
|
||||
// 恢复到根级
|
||||
hierarchySystem.setParent(this.entity, null);
|
||||
}
|
||||
|
||||
this.entityStore.syncFromScene();
|
||||
this.messageHub.publish('entity:reparented', {
|
||||
entityId: this.entity.id,
|
||||
targetId: null,
|
||||
position: 'undo'
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const positionText = this.dropPosition === DropPosition.INSIDE
|
||||
? '移入'
|
||||
: this.dropPosition === DropPosition.BEFORE ? '移动到前面' : '移动到后面';
|
||||
return `${positionText}: ${this.entity.name} -> ${this.targetEntity.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动到目标的同级位置
|
||||
*/
|
||||
private moveToSibling(hierarchySystem: HierarchySystem): void {
|
||||
const targetHierarchy = this.targetEntity.getComponent(HierarchyComponent);
|
||||
const targetParentId = targetHierarchy?.parentId ?? null;
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
// 获取目标的父实体
|
||||
const targetParent = targetParentId !== null
|
||||
? scene.findEntityById(targetParentId)
|
||||
: null;
|
||||
|
||||
// 获取目标在兄弟列表中的索引
|
||||
let targetIndex = this.getSiblingIndex(this.targetEntity);
|
||||
|
||||
// 根据放置位置调整索引
|
||||
if (this.dropPosition === DropPosition.AFTER) {
|
||||
targetIndex++;
|
||||
}
|
||||
|
||||
// 如果移动到同一父级下,需要考虑原位置对索引的影响
|
||||
const entityHierarchy = this.entity.getComponent(HierarchyComponent);
|
||||
const entityParentId = entityHierarchy?.parentId ?? null;
|
||||
|
||||
const bSameParent = entityParentId === targetParentId;
|
||||
if (bSameParent) {
|
||||
const currentIndex = this.getSiblingIndex(this.entity);
|
||||
if (currentIndex < targetIndex) {
|
||||
targetIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[ReparentEntityCommand] moveToSibling: targetParent=${targetParent?.name ?? 'ROOT'}, targetIndex=${targetIndex}`);
|
||||
|
||||
if (targetParent) {
|
||||
// 有父级,插入到父级的指定位置
|
||||
hierarchySystem.insertChildAt(targetParent, this.entity, targetIndex);
|
||||
} else {
|
||||
// 目标在根级
|
||||
// 先确保实体移动到根级
|
||||
if (entityParentId !== null) {
|
||||
hierarchySystem.setParent(this.entity, null);
|
||||
}
|
||||
// 然后调整根级顺序
|
||||
this.entityStore.reorderEntity(this.entity.id, targetIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实体在兄弟列表中的索引
|
||||
*/
|
||||
private getSiblingIndex(entity: Entity): number {
|
||||
const scene = Core.scene;
|
||||
if (!scene) return 0;
|
||||
|
||||
const hierarchy = entity.getComponent(HierarchyComponent);
|
||||
const parentId = hierarchy?.parentId;
|
||||
|
||||
if (parentId === null || parentId === undefined) {
|
||||
// 根级实体,从 EntityStoreService 获取
|
||||
return this.entityStore.getRootEntityIds().indexOf(entity.id);
|
||||
}
|
||||
|
||||
const parent = scene.findEntityById(parentId);
|
||||
if (!parent) return 0;
|
||||
|
||||
const parentHierarchy = parent.getComponent(HierarchyComponent);
|
||||
return parentHierarchy?.childIds.indexOf(entity.id) ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { CreateEntityCommand } from './CreateEntityCommand';
|
||||
export { CreateSpriteEntityCommand } from './CreateSpriteEntityCommand';
|
||||
export { CreateAnimatedSpriteEntityCommand } from './CreateAnimatedSpriteEntityCommand';
|
||||
export { CreateCameraEntityCommand } from './CreateCameraEntityCommand';
|
||||
export { CreateTilemapEntityCommand } from './CreateTilemapEntityCommand';
|
||||
export { DeleteEntityCommand } from './DeleteEntityCommand';
|
||||
export { ReparentEntityCommand, DropPosition } from './ReparentEntityCommand';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export type { ICommand } from './ICommand';
|
||||
export { BaseCommand } from './BaseCommand';
|
||||
export { CommandManager } from './CommandManager';
|
||||
export { TransformCommand, type TransformState, type TransformOperationType } from './transform/TransformCommand';
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 应用预制体命令
|
||||
* Apply prefab command
|
||||
*
|
||||
* 将预制体实例的修改应用到源预制体文件。
|
||||
* Applies modifications from a prefab instance to the source prefab file.
|
||||
*/
|
||||
|
||||
import { Entity, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import type { MessageHub, PrefabService } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 应用预制体命令
|
||||
* Apply prefab command
|
||||
*/
|
||||
export class ApplyPrefabCommand extends BaseCommand {
|
||||
private previousModifiedProperties: string[] = [];
|
||||
private previousOriginalValues: Record<string, unknown> = {};
|
||||
private success: boolean = false;
|
||||
|
||||
constructor(
|
||||
private prefabService: PrefabService,
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// 保存当前状态用于撤销 | Save current state for undo
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
if (comp) {
|
||||
this.previousModifiedProperties = [...comp.modifiedProperties];
|
||||
this.previousOriginalValues = { ...comp.originalValues };
|
||||
}
|
||||
|
||||
// 执行应用操作 | Execute apply operation
|
||||
this.success = await this.prefabService.applyToPrefab(this.entity);
|
||||
|
||||
if (!this.success) {
|
||||
throw new Error('Failed to apply changes to prefab');
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// 恢复修改状态 | Restore modification state
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
if (comp) {
|
||||
comp.modifiedProperties = this.previousModifiedProperties;
|
||||
comp.originalValues = this.previousOriginalValues;
|
||||
}
|
||||
|
||||
// 发布事件通知 UI 更新 | Publish event to notify UI update
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entityId: this.entity.id
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
const prefabName = comp?.sourcePrefabPath?.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab';
|
||||
return `应用修改到预制体: ${prefabName}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 断开预制体链接命令
|
||||
* Break prefab link command
|
||||
*
|
||||
* 断开实体与源预制体的关联,使其成为普通实体。
|
||||
* Breaks the link between an entity and its source prefab, making it a regular entity.
|
||||
*/
|
||||
|
||||
import { Entity, PrefabInstanceComponent, Core } from '@esengine/ecs-framework';
|
||||
import type { MessageHub, PrefabService } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 保存的预制体实例组件状态
|
||||
* Saved prefab instance component state
|
||||
*/
|
||||
interface PrefabInstanceState {
|
||||
entityId: number;
|
||||
sourcePrefabGuid: string;
|
||||
sourcePrefabPath: string;
|
||||
isRoot: boolean;
|
||||
rootInstanceEntityId: number | null;
|
||||
modifiedProperties: string[];
|
||||
originalValues: Record<string, unknown>;
|
||||
instantiatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开预制体链接命令
|
||||
* Break prefab link command
|
||||
*/
|
||||
export class BreakPrefabLinkCommand extends BaseCommand {
|
||||
private removedStates: PrefabInstanceState[] = [];
|
||||
|
||||
constructor(
|
||||
private prefabService: PrefabService,
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// 保存所有将被移除的组件状态 | Save all component states that will be removed
|
||||
this.removedStates = [];
|
||||
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
if (!comp) {
|
||||
throw new Error('Entity is not a prefab instance');
|
||||
}
|
||||
|
||||
// 保存根实体的状态 | Save root entity state
|
||||
this.saveComponentState(this.entity);
|
||||
|
||||
// 如果是根节点,也保存所有子实体的状态
|
||||
// If it's root, also save all children's state
|
||||
if (comp.isRoot) {
|
||||
const scene = Core.scene;
|
||||
if (scene) {
|
||||
scene.entities.forEach((e) => {
|
||||
if (e.id === this.entity.id) return;
|
||||
const childComp = e.getComponent(PrefabInstanceComponent);
|
||||
if (childComp && childComp.rootInstanceEntityId === this.entity.id) {
|
||||
this.saveComponentState(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 执行断开链接操作 | Execute break link operation
|
||||
this.prefabService.breakPrefabLink(this.entity);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// 恢复所有被移除的组件 | Restore all removed components
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
for (const state of this.removedStates) {
|
||||
const entity = scene.findEntityById(state.entityId);
|
||||
if (!entity) continue;
|
||||
|
||||
// 创建并恢复组件 | Create and restore component
|
||||
const comp = new PrefabInstanceComponent(
|
||||
state.sourcePrefabGuid,
|
||||
state.sourcePrefabPath,
|
||||
state.isRoot
|
||||
);
|
||||
comp.rootInstanceEntityId = state.rootInstanceEntityId;
|
||||
comp.modifiedProperties = state.modifiedProperties;
|
||||
comp.originalValues = state.originalValues;
|
||||
comp.instantiatedAt = state.instantiatedAt;
|
||||
|
||||
entity.addComponent(comp);
|
||||
}
|
||||
|
||||
// 发布事件通知 UI 更新 | Publish event to notify UI update
|
||||
this.messageHub.publish('prefab:link:restored', {
|
||||
entityId: this.entity.id
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const state = this.removedStates.find(s => s.entityId === this.entity.id);
|
||||
const prefabName = state?.sourcePrefabPath?.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab';
|
||||
return `断开预制体链接: ${prefabName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存实体的预制体实例组件状态
|
||||
* Save entity's prefab instance component state
|
||||
*/
|
||||
private saveComponentState(entity: Entity): void {
|
||||
const comp = entity.getComponent(PrefabInstanceComponent);
|
||||
if (!comp) return;
|
||||
|
||||
this.removedStates.push({
|
||||
entityId: entity.id,
|
||||
sourcePrefabGuid: comp.sourcePrefabGuid,
|
||||
sourcePrefabPath: comp.sourcePrefabPath,
|
||||
isRoot: comp.isRoot,
|
||||
rootInstanceEntityId: comp.rootInstanceEntityId,
|
||||
modifiedProperties: [...comp.modifiedProperties],
|
||||
originalValues: { ...comp.originalValues },
|
||||
instantiatedAt: comp.instantiatedAt
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 创建预制体命令
|
||||
* Create prefab command
|
||||
*
|
||||
* 从选中的实体创建预制体资产并保存到文件系统。
|
||||
* Creates a prefab asset from the selected entity and saves it to the file system.
|
||||
*/
|
||||
|
||||
import { Core, Entity, HierarchySystem, PrefabSerializer } from '@esengine/ecs-framework';
|
||||
import type { PrefabData } from '@esengine/ecs-framework';
|
||||
import type { MessageHub, IFileAPI, ProjectService, AssetRegistryService } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 创建预制体命令选项
|
||||
* Create prefab command options
|
||||
*/
|
||||
export interface CreatePrefabOptions {
|
||||
/** 预制体名称 | Prefab name */
|
||||
name: string;
|
||||
/** 保存路径(不包含文件名) | Save path (without filename) */
|
||||
savePath?: string;
|
||||
/** 预制体描述 | Prefab description */
|
||||
description?: string;
|
||||
/** 预制体标签 | Prefab tags */
|
||||
tags?: string[];
|
||||
/** 是否包含子实体 | Whether to include child entities */
|
||||
includeChildren?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建预制体命令
|
||||
* Create prefab command
|
||||
*/
|
||||
export class CreatePrefabCommand extends BaseCommand {
|
||||
private savedFilePath: string | null = null;
|
||||
private savedGuid: string | null = null;
|
||||
|
||||
constructor(
|
||||
private messageHub: MessageHub,
|
||||
private fileAPI: IFileAPI,
|
||||
private projectService: ProjectService | undefined,
|
||||
private assetRegistry: AssetRegistryService | null,
|
||||
private sourceEntity: Entity,
|
||||
private options: CreatePrefabOptions
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化 | Scene not initialized');
|
||||
}
|
||||
|
||||
// 获取层级系统 | Get hierarchy system
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
|
||||
// 创建预制体数据 | Create prefab data
|
||||
const prefabData = PrefabSerializer.createPrefab(
|
||||
this.sourceEntity,
|
||||
{
|
||||
name: this.options.name,
|
||||
description: this.options.description,
|
||||
tags: this.options.tags,
|
||||
includeChildren: this.options.includeChildren ?? true
|
||||
},
|
||||
hierarchySystem ?? undefined
|
||||
);
|
||||
|
||||
// 序列化为 JSON | Serialize to JSON
|
||||
const prefabJson = PrefabSerializer.serialize(prefabData, true);
|
||||
|
||||
// 确定保存路径 | Determine save path
|
||||
let savePath = this.options.savePath;
|
||||
if (!savePath && this.projectService?.isProjectOpen()) {
|
||||
// 默认保存到项目的 prefabs 目录 | Default save to project's prefabs directory
|
||||
const currentProject = this.projectService.getCurrentProject();
|
||||
if (currentProject) {
|
||||
const projectRoot = currentProject.path;
|
||||
const sep = projectRoot.includes('\\') ? '\\' : '/';
|
||||
savePath = `${projectRoot}${sep}assets${sep}prefabs`;
|
||||
// 确保目录存在 | Ensure directory exists
|
||||
await this.fileAPI.createDirectory(savePath);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建完整文件路径 | Build complete file path
|
||||
let fullPath: string | null = null;
|
||||
if (savePath) {
|
||||
const sep = savePath.includes('\\') ? '\\' : '/';
|
||||
fullPath = `${savePath}${sep}${this.options.name}.prefab`;
|
||||
} else {
|
||||
// 打开保存对话框 | Open save dialog
|
||||
fullPath = await this.fileAPI.saveSceneDialog(`${this.options.name}.prefab`);
|
||||
}
|
||||
|
||||
if (!fullPath) {
|
||||
throw new Error('保存被取消 | Save cancelled');
|
||||
}
|
||||
|
||||
// 确保扩展名正确 | Ensure correct extension
|
||||
if (!fullPath.endsWith('.prefab')) {
|
||||
fullPath += '.prefab';
|
||||
}
|
||||
|
||||
// 保存文件 | Save file
|
||||
await this.fileAPI.writeFileContent(fullPath, prefabJson);
|
||||
this.savedFilePath = fullPath;
|
||||
|
||||
// 注册资产以生成 .meta 文件 | Register asset to generate .meta file
|
||||
if (this.assetRegistry) {
|
||||
const guid = await this.assetRegistry.registerAsset(fullPath);
|
||||
this.savedGuid = guid;
|
||||
console.log(`[CreatePrefabCommand] Registered prefab asset with GUID: ${guid}`);
|
||||
}
|
||||
|
||||
// 发布事件 | Publish event
|
||||
await this.messageHub.publish('prefab:created', {
|
||||
path: fullPath,
|
||||
guid: this.savedGuid,
|
||||
name: this.options.name,
|
||||
sourceEntityId: this.sourceEntity.id,
|
||||
sourceEntityName: this.sourceEntity.name
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// 预制体创建是一个文件系统操作,撤销意味着删除文件
|
||||
// Prefab creation is a file system operation, undo means deleting the file
|
||||
// 但为了安全,我们不自动删除文件,只是清除引用
|
||||
// But for safety, we don't auto-delete the file, just clear the reference
|
||||
this.savedFilePath = null;
|
||||
|
||||
// TODO: 如果需要完整撤销,可以实现文件删除
|
||||
// TODO: If full undo is needed, implement file deletion
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `创建预制体: ${this.options.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取保存的文件路径
|
||||
* Get saved file path
|
||||
*/
|
||||
getSavedFilePath(): string | null {
|
||||
return this.savedFilePath;
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 实例化预制体命令
|
||||
* Instantiate prefab command
|
||||
*
|
||||
* 从预制体资产创建实体实例。
|
||||
* Creates an entity instance from a prefab asset.
|
||||
*/
|
||||
|
||||
import { Core, Entity, HierarchySystem, PrefabSerializer, GlobalComponentRegistry } from '@esengine/ecs-framework';
|
||||
import type { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 实例化预制体命令选项
|
||||
* Instantiate prefab command options
|
||||
*/
|
||||
export interface InstantiatePrefabOptions {
|
||||
/** 父实体 | Parent entity */
|
||||
parent?: Entity;
|
||||
/** 实例名称(可选,默认使用预制体名称) | Instance name (optional, defaults to prefab name) */
|
||||
name?: string;
|
||||
/** 位置覆盖 | Position override */
|
||||
position?: { x: number; y: number };
|
||||
/** 是否追踪为预制体实例 | Whether to track as prefab instance */
|
||||
trackInstance?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实例化预制体命令
|
||||
* Instantiate prefab command
|
||||
*/
|
||||
export class InstantiatePrefabCommand extends BaseCommand {
|
||||
private createdEntity: Entity | null = null;
|
||||
private createdEntityIds: number[] = [];
|
||||
|
||||
constructor(
|
||||
private entityStore: EntityStoreService,
|
||||
private messageHub: MessageHub,
|
||||
private prefabData: PrefabData,
|
||||
private options: InstantiatePrefabOptions = {}
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const scene = Core.scene;
|
||||
if (!scene) {
|
||||
throw new Error('场景未初始化 | Scene not initialized');
|
||||
}
|
||||
|
||||
// 获取组件注册表 | Get component registry
|
||||
// GlobalComponentRegistry.getAllComponentNames() returns Map<string, Function>
|
||||
// We need to cast it to Map<string, ComponentType>
|
||||
const componentRegistry = GlobalComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
|
||||
|
||||
// 实例化预制体 | Instantiate prefab
|
||||
this.createdEntity = PrefabSerializer.instantiate(
|
||||
this.prefabData,
|
||||
scene,
|
||||
componentRegistry,
|
||||
{
|
||||
parentId: this.options.parent?.id,
|
||||
name: this.options.name,
|
||||
position: this.options.position,
|
||||
trackInstance: this.options.trackInstance ?? true
|
||||
}
|
||||
);
|
||||
|
||||
// 收集所有创建的实体 ID(用于撤销) | Collect all created entity IDs (for undo)
|
||||
this.collectEntityIds(this.createdEntity);
|
||||
|
||||
// 更新 EntityStore | Update EntityStore
|
||||
this.entityStore.syncFromScene();
|
||||
|
||||
// 选中创建的实体 | Select created entity
|
||||
this.entityStore.selectEntity(this.createdEntity);
|
||||
|
||||
// 发布事件 | Publish event
|
||||
this.messageHub.publish('entity:added', { entity: this.createdEntity });
|
||||
this.messageHub.publish('prefab:instantiated', {
|
||||
entity: this.createdEntity,
|
||||
prefabName: this.prefabData.metadata.name,
|
||||
prefabGuid: this.prefabData.metadata.guid
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.createdEntity) return;
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
// 移除所有创建的实体 | Remove all created entities
|
||||
for (const entityId of this.createdEntityIds) {
|
||||
const entity = scene.findEntityById(entityId);
|
||||
if (entity) {
|
||||
scene.entities.remove(entity);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 EntityStore | Update EntityStore
|
||||
this.entityStore.syncFromScene();
|
||||
|
||||
// 发布事件 | Publish event
|
||||
this.messageHub.publish('entity:removed', { entityId: this.createdEntity.id });
|
||||
|
||||
this.createdEntity = null;
|
||||
this.createdEntityIds = [];
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const name = this.options.name || this.prefabData.metadata.name;
|
||||
return `实例化预制体: ${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取创建的根实体
|
||||
* Get created root entity
|
||||
*/
|
||||
getCreatedEntity(): Entity | null {
|
||||
return this.createdEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归收集实体 ID
|
||||
* Recursively collect entity IDs
|
||||
*/
|
||||
private collectEntityIds(entity: Entity): void {
|
||||
this.createdEntityIds.push(entity.id);
|
||||
|
||||
const scene = Core.scene;
|
||||
if (!scene) return;
|
||||
|
||||
const hierarchySystem = scene.getSystem(HierarchySystem);
|
||||
if (hierarchySystem) {
|
||||
const children = hierarchySystem.getChildren(entity);
|
||||
for (const child of children) {
|
||||
this.collectEntityIds(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 还原预制体实例命令
|
||||
* Revert prefab instance command
|
||||
*
|
||||
* 将预制体实例还原为源预制体的状态。
|
||||
* Reverts a prefab instance to the state of the source prefab.
|
||||
*/
|
||||
|
||||
import { Entity, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import type { MessageHub, PrefabService } from '@esengine/editor-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
|
||||
/**
|
||||
* 组件快照
|
||||
* Component snapshot
|
||||
*/
|
||||
interface ComponentSnapshot {
|
||||
typeName: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 还原预制体实例命令
|
||||
* Revert prefab instance command
|
||||
*/
|
||||
export class RevertPrefabCommand extends BaseCommand {
|
||||
private previousSnapshots: ComponentSnapshot[] = [];
|
||||
private previousModifiedProperties: string[] = [];
|
||||
private previousOriginalValues: Record<string, unknown> = {};
|
||||
private success: boolean = false;
|
||||
|
||||
constructor(
|
||||
private prefabService: PrefabService,
|
||||
private messageHub: MessageHub,
|
||||
private entity: Entity
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
// 保存当前状态用于撤销 | Save current state for undo
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
if (comp) {
|
||||
this.previousModifiedProperties = [...comp.modifiedProperties];
|
||||
this.previousOriginalValues = { ...comp.originalValues };
|
||||
|
||||
// 保存所有修改的属性当前值 | Save current values of all modified properties
|
||||
this.previousSnapshots = [];
|
||||
for (const key of comp.modifiedProperties) {
|
||||
const [componentType, ...pathParts] = key.split('.');
|
||||
const propertyPath = pathParts.join('.');
|
||||
|
||||
for (const compInstance of this.entity.components) {
|
||||
const typeName = (compInstance.constructor as any).__componentTypeName || compInstance.constructor.name;
|
||||
if (typeName === componentType) {
|
||||
const value = this.getNestedValue(compInstance, propertyPath);
|
||||
this.previousSnapshots.push({
|
||||
typeName: key,
|
||||
data: { value: this.deepClone(value) }
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 执行还原操作 | Execute revert operation
|
||||
this.success = await this.prefabService.revertInstance(this.entity);
|
||||
|
||||
if (!this.success) {
|
||||
throw new Error('Failed to revert prefab instance');
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// 恢复修改的属性值 | Restore modified property values
|
||||
for (const snapshot of this.previousSnapshots) {
|
||||
const [componentType, ...pathParts] = snapshot.typeName.split('.');
|
||||
const propertyPath = pathParts.join('.');
|
||||
|
||||
for (const compInstance of this.entity.components) {
|
||||
const typeName = (compInstance.constructor as any).__componentTypeName || compInstance.constructor.name;
|
||||
if (typeName === componentType) {
|
||||
this.setNestedValue(compInstance, propertyPath, snapshot.data.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复修改状态 | Restore modification state
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
if (comp) {
|
||||
comp.modifiedProperties = this.previousModifiedProperties;
|
||||
comp.originalValues = this.previousOriginalValues;
|
||||
}
|
||||
|
||||
// 发布事件通知 UI 更新 | Publish event to notify UI update
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entityId: this.entity.id
|
||||
});
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const comp = this.entity.getComponent(PrefabInstanceComponent);
|
||||
const prefabName = comp?.sourcePrefabPath?.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab';
|
||||
return `还原预制体实例: ${prefabName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取嵌套属性值
|
||||
* Get nested property value
|
||||
*/
|
||||
private getNestedValue(obj: any, path: string): unknown {
|
||||
const parts = path.split('.');
|
||||
let current = obj;
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = current[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置嵌套属性值
|
||||
* Set nested property value
|
||||
*/
|
||||
private setNestedValue(obj: any, path: string, value: unknown): void {
|
||||
const parts = path.split('.');
|
||||
let current = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const key = parts[i]!;
|
||||
if (current[key] === null || current[key] === undefined) {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
current[parts[parts.length - 1]!] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深拷贝值
|
||||
* Deep clone value
|
||||
*/
|
||||
private deepClone(value: unknown): unknown {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 预制体命令导出
|
||||
* Prefab commands export
|
||||
*/
|
||||
|
||||
export { CreatePrefabCommand } from './CreatePrefabCommand';
|
||||
export type { CreatePrefabOptions } from './CreatePrefabCommand';
|
||||
|
||||
export { InstantiatePrefabCommand } from './InstantiatePrefabCommand';
|
||||
export type { InstantiatePrefabOptions } from './InstantiatePrefabCommand';
|
||||
|
||||
export { ApplyPrefabCommand } from './ApplyPrefabCommand';
|
||||
export { RevertPrefabCommand } from './RevertPrefabCommand';
|
||||
export { BreakPrefabLinkCommand } from './BreakPrefabLinkCommand';
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Entity, Component } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { TransformComponent } from '@esengine/engine-core';
|
||||
import { BaseCommand } from '../BaseCommand';
|
||||
import { ICommand } from '../ICommand';
|
||||
|
||||
/**
|
||||
* Transform 状态快照
|
||||
* Transform state snapshot
|
||||
*/
|
||||
export interface TransformState {
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
positionZ?: number;
|
||||
rotationX?: number;
|
||||
rotationY?: number;
|
||||
rotationZ?: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
scaleZ?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 变换操作类型
|
||||
* Transform operation type
|
||||
*/
|
||||
export type TransformOperationType = 'move' | 'rotate' | 'scale';
|
||||
|
||||
/**
|
||||
* 变换命令
|
||||
* Transform command for undo/redo support
|
||||
*/
|
||||
export class TransformCommand extends BaseCommand {
|
||||
private readonly timestamp: number;
|
||||
|
||||
constructor(
|
||||
private readonly messageHub: MessageHub,
|
||||
private readonly entity: Entity,
|
||||
private readonly component: TransformComponent,
|
||||
private readonly operationType: TransformOperationType,
|
||||
private readonly oldState: TransformState,
|
||||
private newState: TransformState
|
||||
) {
|
||||
super();
|
||||
this.timestamp = Date.now();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.applyState(this.newState);
|
||||
this.notifyChange();
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this.applyState(this.oldState);
|
||||
this.notifyChange();
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const opNames: Record<TransformOperationType, string> = {
|
||||
move: '移动',
|
||||
rotate: '旋转',
|
||||
scale: '缩放'
|
||||
};
|
||||
return `${opNames[this.operationType]} ${this.entity.name || 'Entity'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以与另一个命令合并
|
||||
* 只有相同实体、相同操作类型、且在短时间内的命令可以合并
|
||||
*/
|
||||
canMergeWith(other: ICommand): boolean {
|
||||
if (!(other instanceof TransformCommand)) return false;
|
||||
|
||||
// 相同实体、相同组件、相同操作类型
|
||||
if (this.entity !== other.entity) return false;
|
||||
if (this.component !== other.component) return false;
|
||||
if (this.operationType !== other.operationType) return false;
|
||||
|
||||
// 时间间隔小于 500ms 才能合并(连续拖动)
|
||||
const timeDiff = other.timestamp - this.timestamp;
|
||||
return timeDiff < 500;
|
||||
}
|
||||
|
||||
mergeWith(other: ICommand): ICommand {
|
||||
if (!(other instanceof TransformCommand)) {
|
||||
throw new Error('无法合并不同类型的命令');
|
||||
}
|
||||
|
||||
// 保留原始 oldState,使用新命令的 newState
|
||||
return new TransformCommand(
|
||||
this.messageHub,
|
||||
this.entity,
|
||||
this.component,
|
||||
this.operationType,
|
||||
this.oldState,
|
||||
other.newState
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用变换状态
|
||||
* Apply transform state
|
||||
*/
|
||||
private applyState(state: TransformState): void {
|
||||
const transform = this.component;
|
||||
if (state.positionX !== undefined) transform.position.x = state.positionX;
|
||||
if (state.positionY !== undefined) transform.position.y = state.positionY;
|
||||
if (state.positionZ !== undefined) transform.position.z = state.positionZ;
|
||||
if (state.rotationX !== undefined) transform.rotation.x = state.rotationX;
|
||||
if (state.rotationY !== undefined) transform.rotation.y = state.rotationY;
|
||||
if (state.rotationZ !== undefined) transform.rotation.z = state.rotationZ;
|
||||
if (state.scaleX !== undefined) transform.scale.x = state.scaleX;
|
||||
if (state.scaleY !== undefined) transform.scale.y = state.scaleY;
|
||||
if (state.scaleZ !== undefined) transform.scale.z = state.scaleZ;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知属性变更
|
||||
* Notify property change
|
||||
*/
|
||||
private notifyChange(): void {
|
||||
const propertyName = this.operationType === 'move'
|
||||
? 'position'
|
||||
: this.operationType === 'rotate'
|
||||
? 'rotation'
|
||||
: 'scale';
|
||||
|
||||
this.messageHub.publish('component:property:changed', {
|
||||
entity: this.entity,
|
||||
component: this.component,
|
||||
propertyName,
|
||||
value: this.component[propertyName as keyof TransformComponent]
|
||||
});
|
||||
|
||||
// 通知 Inspector 刷新 | Notify Inspector to refresh
|
||||
this.messageHub.publish('entity:select', { entityId: this.entity.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 TransformComponent 捕获状态
|
||||
* Capture state from TransformComponent
|
||||
*/
|
||||
static captureTransformState(transform: TransformComponent): TransformState {
|
||||
return {
|
||||
positionX: transform.position.x,
|
||||
positionY: transform.position.y,
|
||||
positionZ: transform.position.z,
|
||||
rotationX: transform.rotation.x,
|
||||
rotationY: transform.rotation.y,
|
||||
rotationZ: transform.rotation.z,
|
||||
scaleX: transform.scale.x,
|
||||
scaleY: transform.scale.y,
|
||||
scaleZ: transform.scale.z
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './commands';
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, RefreshCw, Check, AlertCircle, Download, Loader2 } from 'lucide-react';
|
||||
import { checkForUpdates, installUpdate } from '../utils/updater';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { MiniParticleLogo } from './MiniParticleLogo';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import '../styles/AboutDialog.css';
|
||||
|
||||
interface AboutDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AboutDialog({ onClose }: AboutDialogProps) {
|
||||
const { t } = useLocale();
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'available' | 'latest' | 'error' | 'installing'>('idle');
|
||||
const [version, setVersion] = useState<string>('1.0.0');
|
||||
const [newVersion, setNewVersion] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch version on mount
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const currentVersion = await getVersion();
|
||||
setVersion(currentVersion);
|
||||
} catch (error) {
|
||||
console.error('Failed to get version:', error);
|
||||
}
|
||||
};
|
||||
fetchVersion();
|
||||
}, []);
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
setChecking(true);
|
||||
setUpdateStatus('checking');
|
||||
|
||||
try {
|
||||
const currentVersion = await getVersion();
|
||||
setVersion(currentVersion);
|
||||
|
||||
// 使用我们的 updater 工具检查更新(仅检查,不自动安装)
|
||||
const result = await checkForUpdates();
|
||||
|
||||
if (result.error) {
|
||||
setUpdateStatus('error');
|
||||
} else if (result.available) {
|
||||
setUpdateStatus('available');
|
||||
if (result.version) {
|
||||
setNewVersion(result.version);
|
||||
}
|
||||
} else {
|
||||
setUpdateStatus('latest');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Check update failed:', error);
|
||||
setUpdateStatus('error');
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallUpdate = async () => {
|
||||
setInstalling(true);
|
||||
setUpdateStatus('installing');
|
||||
|
||||
try {
|
||||
const success = await installUpdate();
|
||||
if (!success) {
|
||||
setUpdateStatus('error');
|
||||
setInstalling(false);
|
||||
}
|
||||
// 如果成功,应用会重启,不需要处理
|
||||
} catch (error) {
|
||||
console.error('Install update failed:', error);
|
||||
setUpdateStatus('error');
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (updateStatus) {
|
||||
case 'checking':
|
||||
return <RefreshCw size={16} className="animate-spin" />;
|
||||
case 'available':
|
||||
return <Download size={16} className="status-available" />;
|
||||
case 'installing':
|
||||
return <Loader2 size={16} className="animate-spin" />;
|
||||
case 'latest':
|
||||
return <Check size={16} className="status-latest" />;
|
||||
case 'error':
|
||||
return <AlertCircle size={16} className="status-error" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (updateStatus) {
|
||||
case 'checking':
|
||||
return t('about.checking');
|
||||
case 'available':
|
||||
return `${t('about.updateAvailable')} (v${newVersion})`;
|
||||
case 'installing':
|
||||
return t('about.installing');
|
||||
case 'latest':
|
||||
return t('about.latest');
|
||||
case 'error':
|
||||
return t('about.error');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenGithub = async () => {
|
||||
try {
|
||||
await open('https://github.com/esengine/esengine');
|
||||
} catch (error) {
|
||||
console.error('Failed to open GitHub link:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="about-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="about-header">
|
||||
<h2>{t('about.title')}</h2>
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="about-content">
|
||||
<div className="about-logo">
|
||||
<MiniParticleLogo text="ESEngine" width={360} height={60} fontSize={42} />
|
||||
</div>
|
||||
|
||||
<div className="about-info">
|
||||
<h3>ESEngine Editor</h3>
|
||||
<p className="about-version">
|
||||
{t('about.version')}: Editor {version}
|
||||
</p>
|
||||
<p className="about-description">
|
||||
{t('about.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="about-update">
|
||||
<button
|
||||
className="update-btn"
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={checking || installing}
|
||||
>
|
||||
{checking ? (
|
||||
<>
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
<span>{t('about.checking')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw size={16} />
|
||||
<span>{t('about.checkUpdate')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{updateStatus !== 'idle' && (
|
||||
<div className={`update-status status-${updateStatus}`}>
|
||||
{getStatusIcon()}
|
||||
<span>{getStatusText()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateStatus === 'available' && (
|
||||
<button
|
||||
className="update-btn install-btn"
|
||||
onClick={handleInstallUpdate}
|
||||
disabled={installing}
|
||||
>
|
||||
{installing ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span>{t('about.installing')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={16} />
|
||||
<span>{t('about.download')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="about-links">
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleOpenGithub();
|
||||
}}
|
||||
className="about-link"
|
||||
>
|
||||
{t('about.github')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="about-footer">
|
||||
<p>{t('about.copyright')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="about-actions">
|
||||
<button className="btn-primary" onClick={onClose}>
|
||||
{t('about.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Activity, Pause, Play, RefreshCw, Search, ChevronDown, ChevronUp,
|
||||
ChevronRight, ArrowRight, Cpu, BarChart3, Settings
|
||||
} from 'lucide-react';
|
||||
import '../styles/AdvancedProfiler.css';
|
||||
|
||||
/**
|
||||
* 高级性能数据接口(与 Core 的 IAdvancedProfilerData 对应)
|
||||
*/
|
||||
interface HotspotItem {
|
||||
name: string;
|
||||
category: string;
|
||||
inclusiveTime: number;
|
||||
inclusiveTimePercent: number;
|
||||
exclusiveTime: number;
|
||||
exclusiveTimePercent: number;
|
||||
callCount: number;
|
||||
avgCallTime: number;
|
||||
depth: number;
|
||||
children?: HotspotItem[];
|
||||
}
|
||||
|
||||
interface AdvancedProfilerData {
|
||||
currentFrame: {
|
||||
frameNumber: number;
|
||||
frameTime: number;
|
||||
fps: number;
|
||||
memory: {
|
||||
usedHeapSize: number;
|
||||
totalHeapSize: number;
|
||||
heapSizeLimit: number;
|
||||
utilizationPercent: number;
|
||||
gcCount: number;
|
||||
};
|
||||
};
|
||||
frameTimeHistory: Array<{
|
||||
frameNumber: number;
|
||||
time: number;
|
||||
duration: number;
|
||||
}>;
|
||||
categoryStats: Array<{
|
||||
category: string;
|
||||
totalTime: number;
|
||||
percentOfFrame: number;
|
||||
sampleCount: number;
|
||||
expanded?: boolean;
|
||||
items: Array<{
|
||||
name: string;
|
||||
inclusiveTime: number;
|
||||
exclusiveTime: number;
|
||||
callCount: number;
|
||||
percentOfCategory: number;
|
||||
percentOfFrame: number;
|
||||
}>;
|
||||
}>;
|
||||
hotspots: HotspotItem[];
|
||||
callGraph: {
|
||||
currentFunction: string | null;
|
||||
callers: Array<{
|
||||
name: string;
|
||||
callCount: number;
|
||||
totalTime: number;
|
||||
percentOfCurrent: number;
|
||||
}>;
|
||||
callees: Array<{
|
||||
name: string;
|
||||
callCount: number;
|
||||
totalTime: number;
|
||||
percentOfCurrent: number;
|
||||
}>;
|
||||
};
|
||||
longTasks: Array<{
|
||||
startTime: number;
|
||||
duration: number;
|
||||
attribution: string[];
|
||||
}>;
|
||||
memoryTrend: Array<{
|
||||
time: number;
|
||||
usedMB: number;
|
||||
totalMB: number;
|
||||
gcCount: number;
|
||||
}>;
|
||||
summary: {
|
||||
totalFrames: number;
|
||||
averageFrameTime: number;
|
||||
minFrameTime: number;
|
||||
maxFrameTime: number;
|
||||
p95FrameTime: number;
|
||||
p99FrameTime: number;
|
||||
currentMemoryMB: number;
|
||||
peakMemoryMB: number;
|
||||
gcCount: number;
|
||||
longTaskCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProfilerServiceInterface {
|
||||
subscribeAdvanced: (listener: (data: { advancedProfiler?: AdvancedProfilerData; performance?: unknown; systems?: unknown }) => void) => () => void;
|
||||
isConnected: () => boolean;
|
||||
requestAdvancedProfilerData?: () => void;
|
||||
setProfilerSelectedFunction?: (name: string | null) => void;
|
||||
}
|
||||
|
||||
interface AdvancedProfilerProps {
|
||||
profilerService: ProfilerServiceInterface | null;
|
||||
}
|
||||
|
||||
type SortColumn = 'name' | 'incTime' | 'incPercent' | 'excTime' | 'excPercent' | 'calls' | 'avgTime' | 'framePercent';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
'ECS': '#3b82f6',
|
||||
'Rendering': '#8b5cf6',
|
||||
'Physics': '#f59e0b',
|
||||
'Audio': '#ec4899',
|
||||
'Network': '#14b8a6',
|
||||
'Script': '#84cc16',
|
||||
'Memory': '#ef4444',
|
||||
'Animation': '#f97316',
|
||||
'AI': '#6366f1',
|
||||
'Input': '#06b6d4',
|
||||
'Loading': '#a855f7',
|
||||
'Custom': '#64748b'
|
||||
};
|
||||
|
||||
type DataMode = 'oneframe' | 'average' | 'maximum';
|
||||
|
||||
export function AdvancedProfiler({ profilerService }: AdvancedProfilerProps) {
|
||||
const [data, setData] = useState<AdvancedProfilerData | null>(null);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedFunction, setSelectedFunction] = useState<string | null>(null);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['ECS']));
|
||||
const [expandedHotspots, setExpandedHotspots] = useState<Set<string>>(new Set());
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('incTime');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const [viewMode, setViewMode] = useState<'hierarchical' | 'flat'>('hierarchical');
|
||||
const [dataMode, setDataMode] = useState<DataMode>('average');
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const frameHistoryRef = useRef<Array<{ time: number; duration: number }>>([]);
|
||||
const lastDataRef = useRef<AdvancedProfilerData | null>(null);
|
||||
// 用于计算平均值和最大值的历史数据
|
||||
const hotspotHistoryRef = useRef<Map<string, { times: number[]; maxTime: number }>>(new Map());
|
||||
|
||||
// 更新历史数据
|
||||
const updateHotspotHistory = useCallback((hotspots: HotspotItem[]) => {
|
||||
const updateItem = (item: HotspotItem) => {
|
||||
const history = hotspotHistoryRef.current.get(item.name) || { times: [], maxTime: 0 };
|
||||
history.times.push(item.inclusiveTime);
|
||||
// 保留最近 60 帧的数据
|
||||
if (history.times.length > 60) {
|
||||
history.times.shift();
|
||||
}
|
||||
history.maxTime = Math.max(history.maxTime, item.inclusiveTime);
|
||||
hotspotHistoryRef.current.set(item.name, history);
|
||||
|
||||
if (item.children) {
|
||||
item.children.forEach(updateItem);
|
||||
}
|
||||
};
|
||||
hotspots.forEach(updateItem);
|
||||
}, []);
|
||||
|
||||
// 根据数据模式处理 hotspots
|
||||
const processHotspotsWithDataMode = useCallback((hotspots: HotspotItem[], mode: DataMode): HotspotItem[] => {
|
||||
if (mode === 'oneframe') {
|
||||
return hotspots;
|
||||
}
|
||||
|
||||
const processItem = (item: HotspotItem): HotspotItem => {
|
||||
const history = hotspotHistoryRef.current.get(item.name);
|
||||
let processedTime = item.inclusiveTime;
|
||||
|
||||
if (history && history.times.length > 0) {
|
||||
if (mode === 'average') {
|
||||
processedTime = history.times.reduce((a, b) => a + b, 0) / history.times.length;
|
||||
} else if (mode === 'maximum') {
|
||||
processedTime = history.maxTime;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
inclusiveTime: processedTime,
|
||||
avgCallTime: item.callCount > 0 ? processedTime / item.callCount : 0,
|
||||
children: item.children ? item.children.map(processItem) : undefined
|
||||
};
|
||||
};
|
||||
|
||||
return hotspots.map(processItem);
|
||||
}, []);
|
||||
|
||||
// 订阅数据更新
|
||||
useEffect(() => {
|
||||
if (!profilerService) return;
|
||||
|
||||
const unsubscribe = profilerService.subscribeAdvanced((rawData: { advancedProfiler?: AdvancedProfilerData; performance?: unknown; systems?: unknown }) => {
|
||||
if (isPaused) return;
|
||||
|
||||
// 解析高级性能数据
|
||||
if (rawData.advancedProfiler) {
|
||||
// 更新历史数据
|
||||
updateHotspotHistory(rawData.advancedProfiler.hotspots);
|
||||
setData(rawData.advancedProfiler);
|
||||
lastDataRef.current = rawData.advancedProfiler;
|
||||
} else if (rawData.performance) {
|
||||
// 从传统数据构建
|
||||
const advancedData = buildFromLegacyData(rawData);
|
||||
updateHotspotHistory(advancedData.hotspots);
|
||||
setData(advancedData);
|
||||
lastDataRef.current = advancedData;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [profilerService, isPaused, updateHotspotHistory]);
|
||||
|
||||
// 当选中函数变化时,通知服务端
|
||||
useEffect(() => {
|
||||
if (profilerService?.setProfilerSelectedFunction) {
|
||||
profilerService.setProfilerSelectedFunction(selectedFunction);
|
||||
}
|
||||
}, [selectedFunction, profilerService]);
|
||||
|
||||
// 绘制帧时间图表
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !data) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// 更新帧历史
|
||||
if (data.currentFrame.frameTime > 0) {
|
||||
frameHistoryRef.current.push({
|
||||
time: Date.now(),
|
||||
duration: data.currentFrame.frameTime
|
||||
});
|
||||
if (frameHistoryRef.current.length > 300) {
|
||||
frameHistoryRef.current.shift();
|
||||
}
|
||||
}
|
||||
|
||||
drawFrameTimeGraph(ctx, canvas, frameHistoryRef.current);
|
||||
}, [data]);
|
||||
|
||||
const drawFrameTimeGraph = useCallback((
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
history: Array<{ time: number; duration: number }>
|
||||
) => {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
|
||||
// 清空画布
|
||||
ctx.fillStyle = '#1e1e1e';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
if (history.length < 2) return;
|
||||
|
||||
// 计算最大值
|
||||
const maxTime = Math.max(...history.map((h) => h.duration), 33.33);
|
||||
const targetLine = 16.67; // 60 FPS
|
||||
|
||||
// 绘制网格线
|
||||
ctx.strokeStyle = '#333';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([2, 2]);
|
||||
|
||||
// 16.67ms 线 (60 FPS)
|
||||
const targetY = height - (targetLine / maxTime) * height;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, targetY);
|
||||
ctx.lineTo(width, targetY);
|
||||
ctx.stroke();
|
||||
|
||||
// 33.33ms 线 (30 FPS)
|
||||
const halfY = height - (33.33 / maxTime) * height;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, halfY);
|
||||
ctx.lineTo(width, halfY);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// 绘制帧时间曲线
|
||||
const stepX = width / (history.length - 1);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#4ade80';
|
||||
ctx.lineWidth = 1.5;
|
||||
|
||||
history.forEach((frame, i) => {
|
||||
const x = i * stepX;
|
||||
const y = height - (frame.duration / maxTime) * height;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
// 如果超过阈值,改变颜色
|
||||
if (frame.duration > 33.33) {
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#ef4444';
|
||||
ctx.moveTo(x, y);
|
||||
} else if (frame.duration > 16.67) {
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#fbbf24';
|
||||
ctx.moveTo(x, y);
|
||||
}
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制填充区域
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = 'rgba(74, 222, 128, 0.1)';
|
||||
ctx.moveTo(0, height);
|
||||
history.forEach((frame, i) => {
|
||||
const x = i * stepX;
|
||||
const y = height - (frame.duration / maxTime) * height;
|
||||
ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.lineTo(width, height);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}, []);
|
||||
|
||||
const handleSort = (column: SortColumn) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection((d) => d === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) {
|
||||
next.delete(category);
|
||||
} else {
|
||||
next.add(category);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
frameHistoryRef.current = [];
|
||||
setData(null);
|
||||
};
|
||||
|
||||
const getFrameTimeClass = (frameTime: number): string => {
|
||||
if (frameTime > 33.33) return 'critical';
|
||||
if (frameTime > 16.67) return 'warning';
|
||||
return '';
|
||||
};
|
||||
|
||||
const formatTime = (ms: number): string => {
|
||||
if (ms < 0.01) return '< 0.01';
|
||||
return ms.toFixed(2);
|
||||
};
|
||||
|
||||
const formatPercent = (percent: number): string => {
|
||||
return percent.toFixed(1) + '%';
|
||||
};
|
||||
|
||||
// 展平层级数据用于显示
|
||||
const flattenHotspots = (items: HotspotItem[], result: HotspotItem[] = []): HotspotItem[] => {
|
||||
for (const item of items) {
|
||||
// 搜索过滤
|
||||
const matchesSearch = searchTerm === '' || item.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
if (viewMode === 'flat') {
|
||||
// 扁平模式:显示所有层级的项目
|
||||
if (matchesSearch) {
|
||||
result.push({ ...item, depth: 0 }); // 扁平模式下深度都是0
|
||||
}
|
||||
if (item.children) {
|
||||
flattenHotspots(item.children, result);
|
||||
}
|
||||
} else {
|
||||
// 层级模式:根据展开状态显示
|
||||
if (matchesSearch || (item.children && item.children.some(c => c.name.toLowerCase().includes(searchTerm.toLowerCase())))) {
|
||||
result.push(item);
|
||||
}
|
||||
if (item.children && expandedHotspots.has(item.name)) {
|
||||
flattenHotspots(item.children, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// 切换展开状态
|
||||
const toggleHotspotExpand = (name: string) => {
|
||||
setExpandedHotspots(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 排序数据
|
||||
const getSortedHotspots = (): HotspotItem[] => {
|
||||
if (!data) return [];
|
||||
|
||||
// 先根据数据模式处理 hotspots
|
||||
const processedHotspots = processHotspotsWithDataMode(data.hotspots, dataMode);
|
||||
const flattened = flattenHotspots(processedHotspots);
|
||||
|
||||
// 扁平模式下排序
|
||||
if (viewMode === 'flat') {
|
||||
return [...flattened].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortColumn) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'incTime':
|
||||
comparison = a.inclusiveTime - b.inclusiveTime;
|
||||
break;
|
||||
case 'incPercent':
|
||||
comparison = a.inclusiveTimePercent - b.inclusiveTimePercent;
|
||||
break;
|
||||
case 'excTime':
|
||||
comparison = a.exclusiveTime - b.exclusiveTime;
|
||||
break;
|
||||
case 'excPercent':
|
||||
comparison = a.exclusiveTimePercent - b.exclusiveTimePercent;
|
||||
break;
|
||||
case 'calls':
|
||||
comparison = a.callCount - b.callCount;
|
||||
break;
|
||||
case 'avgTime':
|
||||
comparison = a.avgCallTime - b.avgCallTime;
|
||||
break;
|
||||
case 'framePercent':
|
||||
comparison = a.inclusiveTimePercent - b.inclusiveTimePercent;
|
||||
break;
|
||||
}
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
// 层级模式下保持原有层级顺序
|
||||
return flattened;
|
||||
};
|
||||
|
||||
const renderSortIcon = (column: SortColumn) => {
|
||||
if (sortColumn !== column) return null;
|
||||
return sortDirection === 'asc' ? <ChevronUp size={10} /> : <ChevronDown size={10} />;
|
||||
};
|
||||
|
||||
if (!profilerService) {
|
||||
return (
|
||||
<div className="advanced-profiler">
|
||||
<div className="profiler-empty-state">
|
||||
<Cpu size={48} />
|
||||
<div className="profiler-empty-state-title">Profiler Service Unavailable</div>
|
||||
<div className="profiler-empty-state-hint">
|
||||
Connect to a running game to start profiling
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="advanced-profiler">
|
||||
{/* Top Toolbar */}
|
||||
<div className="profiler-top-bar">
|
||||
<div className="profiler-thread-selector">
|
||||
<button className="profiler-thread-btn active">Main Thread</button>
|
||||
</div>
|
||||
|
||||
<div className="profiler-frame-time">
|
||||
<span className="profiler-frame-time-label">Frame:</span>
|
||||
<span className={`profiler-frame-time-value ${getFrameTimeClass(data?.currentFrame.frameTime || 0)}`}>
|
||||
{formatTime(data?.currentFrame.frameTime || 0)} ms
|
||||
</span>
|
||||
<span className="profiler-frame-time-label">FPS:</span>
|
||||
<span className="profiler-frame-time-value">
|
||||
{data?.currentFrame.fps || 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="profiler-controls">
|
||||
<button
|
||||
className={`profiler-control-btn ${isPaused ? '' : 'active'}`}
|
||||
onClick={() => setIsPaused(!isPaused)}
|
||||
title={isPaused ? 'Resume' : 'Pause'}
|
||||
>
|
||||
{isPaused ? <Play size={14} /> : <Pause size={14} />}
|
||||
</button>
|
||||
<button
|
||||
className="profiler-control-btn"
|
||||
onClick={handleReset}
|
||||
title="Reset"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
<button className="profiler-control-btn" title="Settings">
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profiler-main">
|
||||
{/* Left Panel - Stats Groups */}
|
||||
<div className="profiler-left-panel">
|
||||
<div className="profiler-search-box">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search stats..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="profiler-group-controls">
|
||||
<select className="profiler-group-select" defaultValue="category">
|
||||
<option value="category">Group by Category</option>
|
||||
<option value="name">Group by Name</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="profiler-type-filters">
|
||||
<button className="profiler-type-filter hier active">Hier</button>
|
||||
<button className="profiler-type-filter float">Float</button>
|
||||
<button className="profiler-type-filter int">Int</button>
|
||||
<button className="profiler-type-filter mem">Mem</button>
|
||||
</div>
|
||||
|
||||
<div className="profiler-groups-list">
|
||||
{data?.categoryStats.map(cat => (
|
||||
<div key={cat.category}>
|
||||
<div
|
||||
className={`profiler-group-item ${expandedCategories.has(cat.category) ? 'selected' : ''}`}
|
||||
onClick={() => toggleCategory(cat.category)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="profiler-group-checkbox"
|
||||
checked={expandedCategories.has(cat.category)}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<span
|
||||
className="category-dot"
|
||||
style={{ background: CATEGORY_COLORS[cat.category] || '#666' }}
|
||||
/>
|
||||
<span className="profiler-group-name">{cat.category}</span>
|
||||
<span className="profiler-group-count">({cat.sampleCount})</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Content */}
|
||||
<div className="profiler-content">
|
||||
{/* Graph View */}
|
||||
<div className="profiler-graph-section">
|
||||
<div className="profiler-graph-header">
|
||||
<BarChart3 size={14} />
|
||||
<span className="profiler-graph-title">Graph View</span>
|
||||
<div className="profiler-graph-stats">
|
||||
<div className="profiler-graph-stat">
|
||||
<span className="profiler-graph-stat-label">Avg:</span>
|
||||
<span className="profiler-graph-stat-value">
|
||||
{formatTime(data?.summary.averageFrameTime || 0)} ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="profiler-graph-stat">
|
||||
<span className="profiler-graph-stat-label">Min:</span>
|
||||
<span className="profiler-graph-stat-value">
|
||||
{formatTime(data?.summary.minFrameTime || 0)} ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="profiler-graph-stat">
|
||||
<span className="profiler-graph-stat-label">Max:</span>
|
||||
<span className="profiler-graph-stat-value">
|
||||
{formatTime(data?.summary.maxFrameTime || 0)} ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profiler-graph-canvas">
|
||||
<canvas ref={canvasRef} />
|
||||
<div className="profiler-graph-overlay">
|
||||
<div className="profiler-graph-line" style={{ top: '50%' }}>
|
||||
<span className="profiler-graph-line-label">16.67ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Call Graph */}
|
||||
<div className="profiler-callgraph-section">
|
||||
<div className="profiler-callgraph-header">
|
||||
<Activity size={14} />
|
||||
<span className="profiler-graph-title">Call Graph</span>
|
||||
<div className="profiler-callgraph-controls">
|
||||
<select
|
||||
className="profiler-callgraph-type-select"
|
||||
value={dataMode}
|
||||
onChange={(e) => setDataMode(e.target.value as DataMode)}
|
||||
>
|
||||
<option value="oneframe">One Frame</option>
|
||||
<option value="average">Average</option>
|
||||
<option value="maximum">Maximum</option>
|
||||
</select>
|
||||
<div className="profiler-callgraph-view-mode">
|
||||
<button
|
||||
className={`profiler-callgraph-view-btn ${viewMode === 'hierarchical' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('hierarchical')}
|
||||
>
|
||||
Hierarchical
|
||||
</button>
|
||||
<button
|
||||
className={`profiler-callgraph-view-btn ${viewMode === 'flat' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('flat')}
|
||||
>
|
||||
Flat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profiler-callgraph-content">
|
||||
<div className="profiler-callgraph-column">
|
||||
<div className="profiler-callgraph-column-header">
|
||||
<ArrowRight size={10} />
|
||||
Calling Functions
|
||||
</div>
|
||||
<div className="profiler-callgraph-list">
|
||||
{data?.callGraph.callers.map((caller, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="profiler-callgraph-item"
|
||||
onClick={() => setSelectedFunction(caller.name)}
|
||||
>
|
||||
<span className="profiler-callgraph-item-name">{caller.name}</span>
|
||||
<span className="profiler-callgraph-item-percent">
|
||||
{formatPercent(caller.percentOfCurrent)}
|
||||
</span>
|
||||
<span className="profiler-callgraph-item-time">
|
||||
{formatTime(caller.totalTime)} ms
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="profiler-callgraph-column">
|
||||
<div className="profiler-callgraph-column-header">
|
||||
Current Function
|
||||
</div>
|
||||
<div className="profiler-callgraph-list">
|
||||
{selectedFunction ? (
|
||||
<div className="profiler-callgraph-item current">
|
||||
<span className="profiler-callgraph-item-name">{selectedFunction}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="profiler-callgraph-item">
|
||||
<span className="profiler-callgraph-item-name" style={{ color: '#666' }}>
|
||||
Select a function from the table
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="profiler-callgraph-column">
|
||||
<div className="profiler-callgraph-column-header">
|
||||
Called Functions
|
||||
<ArrowRight size={10} />
|
||||
</div>
|
||||
<div className="profiler-callgraph-list">
|
||||
{data?.callGraph.callees.map((callee, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="profiler-callgraph-item"
|
||||
onClick={() => setSelectedFunction(callee.name)}
|
||||
>
|
||||
<span className="profiler-callgraph-item-name">{callee.name}</span>
|
||||
<span className="profiler-callgraph-item-percent">
|
||||
{formatPercent(callee.percentOfCurrent)}
|
||||
</span>
|
||||
<span className="profiler-callgraph-item-time">
|
||||
{formatTime(callee.totalTime)} ms
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<div className="profiler-table-section">
|
||||
<div className="profiler-table-header">
|
||||
<div
|
||||
className={`profiler-table-header-cell col-name ${sortColumn === 'name' ? 'sorted' : ''}`}
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
Event Name {renderSortIcon('name')}
|
||||
</div>
|
||||
<div
|
||||
className={`profiler-table-header-cell col-inc-time ${sortColumn === 'incTime' ? 'sorted' : ''}`}
|
||||
onClick={() => handleSort('incTime')}
|
||||
>
|
||||
Inc Time (ms) {renderSortIcon('incTime')}
|
||||
</div>
|
||||
<div
|
||||
className={`profiler-table-header-cell col-inc-percent ${sortColumn === 'incPercent' ? 'sorted' : ''}`}
|
||||
onClick={() => handleSort('incPercent')}
|
||||
>
|
||||
Inc % {renderSortIcon('incPercent')}
|
||||
</div>
|
||||
<div
|
||||
className={`profiler-table-header-cell col-exc-time ${sortColumn === 'excTime' ? 'sorted' : ''}`}
|
||||
onClick={() => handleSort('excTime')}
|
||||
>
|
||||
Exc Time (ms) {renderSortIcon('excTime')}
|
||||
</div>
|
||||
<div
|
||||
className={`profiler-table-header-cell col-exc-percent ${sortColumn === 'excPercent' ? 'sorted' : ''}`}
|
||||
onClick={() => handleSort('excPercent')}
|
||||
>
|
||||
Exc % {renderSortIcon('excPercent')}
|
||||
</div>
|
||||
<div
|
||||
className={`profiler-table-header-cell col-calls ${sortColumn === 'calls' ? 'sorted' : ''}`}
|
||||
onClick={() => handleSort('calls')}
|
||||
>
|
||||
Calls {renderSortIcon('calls')}
|
||||
</div>
|
||||
<div
|
||||
className={`profiler-table-header-cell col-avg-calls ${sortColumn === 'avgTime' ? 'sorted' : ''}`}
|
||||
onClick={() => handleSort('avgTime')}
|
||||
>
|
||||
Avg (ms) {renderSortIcon('avgTime')}
|
||||
</div>
|
||||
<div
|
||||
className={`profiler-table-header-cell col-frame-percent ${sortColumn === 'framePercent' ? 'sorted' : ''}`}
|
||||
onClick={() => handleSort('framePercent')}
|
||||
>
|
||||
% of Frame {renderSortIcon('framePercent')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="profiler-table-body">
|
||||
{getSortedHotspots().map((item, index) => {
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isExpanded = expandedHotspots.has(item.name);
|
||||
const indentPadding = viewMode === 'hierarchical' ? item.depth * 16 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.name + index + item.depth}
|
||||
className={`profiler-table-row ${selectedFunction === item.name ? 'selected' : ''} depth-${item.depth}`}
|
||||
onClick={() => setSelectedFunction(item.name)}
|
||||
>
|
||||
<div className="profiler-table-cell col-name name" style={{ paddingLeft: indentPadding }}>
|
||||
{hasChildren && viewMode === 'hierarchical' ? (
|
||||
<span
|
||||
className={`expand-icon clickable ${isExpanded ? 'expanded' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleHotspotExpand(item.name);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="expand-icon placeholder" style={{ width: 12 }} />
|
||||
)}
|
||||
<span
|
||||
className="category-dot"
|
||||
style={{ background: CATEGORY_COLORS[item.category] || '#666' }}
|
||||
/>
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-inc-time numeric">
|
||||
{formatTime(item.inclusiveTime)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-inc-percent percent">
|
||||
<div className="bar-container">
|
||||
<div
|
||||
className={`bar ${item.inclusiveTimePercent > 50 ? 'critical' : item.inclusiveTimePercent > 25 ? 'warning' : ''}`}
|
||||
style={{ width: `${Math.min(item.inclusiveTimePercent, 100)}%` }}
|
||||
/>
|
||||
<span>{formatPercent(item.inclusiveTimePercent)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profiler-table-cell col-exc-time numeric">
|
||||
{formatTime(item.exclusiveTime)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-exc-percent percent">
|
||||
{formatPercent(item.exclusiveTimePercent)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-calls numeric">
|
||||
{item.callCount}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-avg-calls numeric">
|
||||
{formatTime(item.avgCallTime)}
|
||||
</div>
|
||||
<div className="profiler-table-cell col-frame-percent percent">
|
||||
{formatPercent(item.inclusiveTimePercent)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从传统数据构建高级性能数据
|
||||
*/
|
||||
function buildFromLegacyData(rawData: any): AdvancedProfilerData {
|
||||
const performance = rawData.performance || {};
|
||||
const systems = rawData.systems?.systemsInfo || [];
|
||||
|
||||
const frameTime = performance.frameTime || 0;
|
||||
const fps = frameTime > 0 ? Math.round(1000 / frameTime) : 0;
|
||||
|
||||
// 构建 hotspots
|
||||
const hotspots: HotspotItem[] = systems.map((sys: any) => ({
|
||||
name: sys.name || sys.type || 'Unknown',
|
||||
category: 'ECS',
|
||||
inclusiveTime: sys.executionTime || 0,
|
||||
inclusiveTimePercent: frameTime > 0 ? (sys.executionTime / frameTime) * 100 : 0,
|
||||
exclusiveTime: sys.executionTime || 0,
|
||||
exclusiveTimePercent: frameTime > 0 ? (sys.executionTime / frameTime) * 100 : 0,
|
||||
callCount: 1,
|
||||
avgCallTime: sys.executionTime || 0,
|
||||
depth: 0
|
||||
}));
|
||||
|
||||
// 构建 categoryStats
|
||||
const totalECSTime = hotspots.reduce((sum: number, h: any) => sum + h.inclusiveTime, 0);
|
||||
const categoryStats = [{
|
||||
category: 'ECS',
|
||||
totalTime: totalECSTime,
|
||||
percentOfFrame: frameTime > 0 ? (totalECSTime / frameTime) * 100 : 0,
|
||||
sampleCount: hotspots.length,
|
||||
items: hotspots.map((h: any) => ({
|
||||
name: h.name,
|
||||
inclusiveTime: h.inclusiveTime,
|
||||
exclusiveTime: h.exclusiveTime,
|
||||
callCount: h.callCount,
|
||||
percentOfCategory: totalECSTime > 0 ? (h.inclusiveTime / totalECSTime) * 100 : 0,
|
||||
percentOfFrame: h.inclusiveTimePercent
|
||||
}))
|
||||
}];
|
||||
|
||||
return {
|
||||
currentFrame: {
|
||||
frameNumber: 0,
|
||||
frameTime,
|
||||
fps,
|
||||
memory: {
|
||||
usedHeapSize: (performance.memoryUsage || 0) * 1024 * 1024,
|
||||
totalHeapSize: 0,
|
||||
heapSizeLimit: 0,
|
||||
utilizationPercent: 0,
|
||||
gcCount: 0
|
||||
}
|
||||
},
|
||||
frameTimeHistory: performance.frameTimeHistory?.map((t: number, i: number) => ({
|
||||
frameNumber: i,
|
||||
time: Date.now() - (performance.frameTimeHistory.length - i) * 16,
|
||||
duration: t
|
||||
})) || [],
|
||||
categoryStats,
|
||||
hotspots,
|
||||
callGraph: {
|
||||
currentFunction: null,
|
||||
callers: [],
|
||||
callees: []
|
||||
},
|
||||
longTasks: [],
|
||||
memoryTrend: [],
|
||||
summary: {
|
||||
totalFrames: 0,
|
||||
averageFrameTime: performance.averageFrameTime || frameTime,
|
||||
minFrameTime: performance.minFrameTime || frameTime,
|
||||
maxFrameTime: performance.maxFrameTime || frameTime,
|
||||
p95FrameTime: frameTime,
|
||||
p99FrameTime: frameTime,
|
||||
currentMemoryMB: performance.memoryUsage || 0,
|
||||
peakMemoryMB: performance.memoryUsage || 0,
|
||||
gcCount: 0,
|
||||
longTaskCount: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { X, BarChart3, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { ProfilerServiceToken, type IProfilerService } from '../services/tokens';
|
||||
import { AdvancedProfiler } from './AdvancedProfiler';
|
||||
import '../styles/ProfilerWindow.css';
|
||||
|
||||
interface AdvancedProfilerWindowProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AdvancedProfilerWindow({ onClose }: AdvancedProfilerWindowProps) {
|
||||
const [profilerService, setProfilerService] = useState<IProfilerService | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const service = Core.pluginServices.get(ProfilerServiceToken);
|
||||
if (service) {
|
||||
setProfilerService(service);
|
||||
}
|
||||
} catch {
|
||||
// Core 可能还没有初始化
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profilerService) return;
|
||||
|
||||
const checkStatus = () => {
|
||||
setIsConnected(profilerService.isConnected());
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
const interval = setInterval(checkStatus, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [profilerService]);
|
||||
|
||||
// 处理 ESC 键退出全屏
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isFullscreen) {
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isFullscreen]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setIsFullscreen(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const windowStyle = isFullscreen
|
||||
? { width: '100vw', height: '100vh', maxWidth: 'none', borderRadius: 0 }
|
||||
: { width: '90vw', height: '85vh', maxWidth: '1600px' };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`profiler-window-overlay ${isFullscreen ? 'fullscreen' : ''}`}
|
||||
onClick={isFullscreen ? undefined : onClose}
|
||||
>
|
||||
<div
|
||||
className={`profiler-window advanced-profiler-window ${isFullscreen ? 'fullscreen' : ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={windowStyle}
|
||||
>
|
||||
<div className="profiler-window-header">
|
||||
<div className="profiler-window-title">
|
||||
<BarChart3 size={20} />
|
||||
<h2>Advanced Performance Profiler</h2>
|
||||
{!isConnected && (
|
||||
<span className="paused-indicator" style={{ background: '#ef4444' }}>
|
||||
DISCONNECTED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="profiler-window-controls">
|
||||
<button
|
||||
className="profiler-window-btn"
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? 'Exit Fullscreen (Esc)' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||
</button>
|
||||
<button className="profiler-window-close" onClick={onClose} title="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profiler-window-content" style={{ padding: 0 }}>
|
||||
<AdvancedProfiler profilerService={profilerService} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,724 @@
|
||||
/**
|
||||
* Build Settings Panel.
|
||||
* 构建设置面板。
|
||||
*
|
||||
* Provides build settings interface for managing platform builds,
|
||||
* scenes, and player settings.
|
||||
* 提供构建设置界面,用于管理平台构建、场景和玩家设置。
|
||||
*
|
||||
* 使用 Zustand store 管理状态,避免 useEffect 过多导致的重渲染问题
|
||||
* Uses Zustand store for state management to avoid re-render issues from too many useEffects
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Monitor, Apple, Smartphone, Globe, Server, Gamepad2,
|
||||
Plus, Minus, ChevronDown, ChevronRight, Settings,
|
||||
Package, Loader2, CheckCircle, XCircle, AlertTriangle, X, Copy, Check, FolderOpen
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { BuildService, SceneManagerService, ProjectService } from '@esengine/editor-core';
|
||||
import { BuildStatus } from '@esengine/editor-core';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import {
|
||||
useBuildSettingsStore,
|
||||
type PlatformType,
|
||||
type BuildProfile,
|
||||
type BuildSettings,
|
||||
} from '../stores/BuildSettingsStore';
|
||||
import '../styles/BuildSettingsPanel.css';
|
||||
|
||||
// ==================== Types | 类型定义 ====================
|
||||
// 类型定义已移至 BuildSettingsStore.ts
|
||||
// Type definitions moved to BuildSettingsStore.ts
|
||||
|
||||
/** Platform configuration | 平台配置 */
|
||||
interface PlatformConfig {
|
||||
platform: PlatformType;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
// ==================== Constants | 常量 ====================
|
||||
|
||||
const PLATFORMS: PlatformConfig[] = [
|
||||
{ platform: 'windows', label: 'Windows', icon: <Monitor size={16} />, available: true },
|
||||
{ platform: 'macos', label: 'macOS', icon: <Apple size={16} />, available: true },
|
||||
{ platform: 'linux', label: 'Linux', icon: <Server size={16} />, available: true },
|
||||
{ platform: 'android', label: 'Android', icon: <Smartphone size={16} />, available: true },
|
||||
{ platform: 'ios', label: 'iOS', icon: <Smartphone size={16} />, available: true },
|
||||
{ platform: 'web', label: 'Web', icon: <Globe size={16} />, available: true },
|
||||
{ platform: 'wechat-minigame', label: 'WeChat Mini Game', icon: <Gamepad2 size={16} />, available: true },
|
||||
];
|
||||
|
||||
// ==================== Status Key Mapping | 状态键映射 ====================
|
||||
|
||||
/** Map BuildStatus to translation key | 将 BuildStatus 映射到翻译键 */
|
||||
const buildStatusKeys: Record<BuildStatus, string> = {
|
||||
[BuildStatus.Idle]: 'buildSettings.preparing',
|
||||
[BuildStatus.Preparing]: 'buildSettings.preparing',
|
||||
[BuildStatus.Compiling]: 'buildSettings.compiling',
|
||||
[BuildStatus.Packaging]: 'buildSettings.packaging',
|
||||
[BuildStatus.Copying]: 'buildSettings.copying',
|
||||
[BuildStatus.PostProcessing]: 'buildSettings.postProcessing',
|
||||
[BuildStatus.Completed]: 'buildSettings.completed',
|
||||
[BuildStatus.Failed]: 'buildSettings.failed',
|
||||
[BuildStatus.Cancelled]: 'buildSettings.cancelled'
|
||||
};
|
||||
|
||||
// ==================== Build Error Display Component | 构建错误显示组件 ====================
|
||||
|
||||
/**
|
||||
* Format and display build errors in a readable way.
|
||||
* 以可读的方式格式化和显示构建错误。
|
||||
*/
|
||||
function BuildErrorDisplay({ error }: { error: string }) {
|
||||
const { t } = useLocale();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Extract first line as summary | 提取第一行作为摘要
|
||||
const lines = error.split('\n');
|
||||
const firstErrorMatch = error.match(/X \[ERROR\][^\n]*/);
|
||||
const firstLine = lines[0] || '';
|
||||
const matchedError = firstErrorMatch?.[0] || '';
|
||||
const summary = matchedError
|
||||
? matchedError.slice(0, 100) + (matchedError.length > 100 ? '...' : '')
|
||||
: firstLine.slice(0, 100) + (firstLine.length > 100 ? '...' : '');
|
||||
|
||||
// Check if error is long (needs expansion) | 检查错误是否很长(需要展开)
|
||||
const isLongError = error.length > 200 || lines.length > 3;
|
||||
|
||||
// Copy error to clipboard | 复制错误到剪贴板
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(error);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (e) {
|
||||
console.error('Failed to copy:', e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="build-result-error">
|
||||
<div className="build-error-header">
|
||||
<AlertTriangle size={16} />
|
||||
<span className="build-error-summary">{summary}</span>
|
||||
<button
|
||||
className="build-error-copy-btn"
|
||||
onClick={handleCopy}
|
||||
title={t('buildSettings.copyError')}
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLongError && (
|
||||
<>
|
||||
<button
|
||||
className="build-error-expand-btn"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? t('buildSettings.collapse') : t('buildSettings.showDetails')}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={isExpanded ? 'rotated' : ''}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<pre className="build-error-details">{error}</pre>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Props | 属性 ====================
|
||||
|
||||
interface BuildSettingsPanelProps {
|
||||
projectPath?: string;
|
||||
buildService?: BuildService;
|
||||
sceneManager?: SceneManagerService;
|
||||
projectService?: ProjectService;
|
||||
/** Available scenes in the project | 项目中可用的场景列表 */
|
||||
availableScenes?: string[];
|
||||
onBuild?: (profile: BuildProfile, settings: BuildSettings) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// ==================== Component | 组件 ====================
|
||||
|
||||
export function BuildSettingsPanel({
|
||||
projectPath,
|
||||
buildService,
|
||||
sceneManager,
|
||||
projectService,
|
||||
availableScenes,
|
||||
onBuild,
|
||||
onClose
|
||||
}: BuildSettingsPanelProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// 使用 Zustand store 替代本地状态(使用 useShallow 避免不必要的重渲染)
|
||||
// Use Zustand store instead of local state (use useShallow to avoid unnecessary re-renders)
|
||||
const {
|
||||
profiles,
|
||||
selectedPlatform,
|
||||
selectedProfile,
|
||||
settings,
|
||||
expandedSections,
|
||||
isBuilding,
|
||||
buildProgress,
|
||||
buildResult,
|
||||
showBuildProgress,
|
||||
} = useBuildSettingsStore(useShallow(state => ({
|
||||
profiles: state.profiles,
|
||||
selectedPlatform: state.selectedPlatform,
|
||||
selectedProfile: state.selectedProfile,
|
||||
settings: state.settings,
|
||||
expandedSections: state.expandedSections,
|
||||
isBuilding: state.isBuilding,
|
||||
buildProgress: state.buildProgress,
|
||||
buildResult: state.buildResult,
|
||||
showBuildProgress: state.showBuildProgress,
|
||||
})));
|
||||
|
||||
// 获取 store actions(通过 getState 获取,这些不会触发重渲染)
|
||||
// Get store actions via getState (these don't trigger re-renders)
|
||||
const store = useBuildSettingsStore.getState();
|
||||
const {
|
||||
setSelectedPlatform: handlePlatformSelect,
|
||||
setSelectedProfile: handleProfileSelect,
|
||||
addProfile: handleAddProfile,
|
||||
updateSettings,
|
||||
setSceneEnabled,
|
||||
addDefine,
|
||||
removeDefine: handleRemoveDefine,
|
||||
toggleSection,
|
||||
cancelBuild: handleCancelBuild,
|
||||
closeBuildProgress: handleCloseBuildProgress,
|
||||
} = store;
|
||||
|
||||
// 初始化 store(仅在 mount 时)
|
||||
// Initialize store (only on mount)
|
||||
useEffect(() => {
|
||||
if (projectPath) {
|
||||
useBuildSettingsStore.getState().initialize({
|
||||
projectPath,
|
||||
buildService,
|
||||
projectService,
|
||||
availableScenes,
|
||||
});
|
||||
}
|
||||
return () => useBuildSettingsStore.getState().cleanup();
|
||||
}, [projectPath]); // 只依赖 projectPath,避免频繁重初始化
|
||||
|
||||
// 当前平台的配置列表(使用 useMemo 避免每次重新过滤)
|
||||
// Profiles for current platform (use useMemo to avoid re-filtering every time)
|
||||
const platformProfiles = useMemo(
|
||||
() => profiles.filter(p => p.platform === selectedPlatform),
|
||||
[profiles, selectedPlatform]
|
||||
);
|
||||
|
||||
// 构建处理 | Build handler
|
||||
const handleBuild = useCallback(async () => {
|
||||
if (!selectedProfile || !projectPath) return;
|
||||
|
||||
// Call external handler if provided
|
||||
if (onBuild) {
|
||||
onBuild(selectedProfile, settings);
|
||||
}
|
||||
|
||||
// 使用 store 的构建操作 | Use store's build action
|
||||
await useBuildSettingsStore.getState().startBuild();
|
||||
}, [selectedProfile, projectPath, onBuild, settings]);
|
||||
|
||||
// 添加当前场景 | Add current scene
|
||||
const handleAddScene = useCallback(() => {
|
||||
if (!sceneManager) {
|
||||
console.warn('SceneManagerService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const sceneState = sceneManager.getSceneState();
|
||||
const currentScenePath = sceneState.currentScenePath;
|
||||
|
||||
if (!currentScenePath) {
|
||||
console.warn('No scene is currently open');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查场景是否已在列表中 | Check if scene is already in the list
|
||||
const exists = settings.scenes.some(s => s.path === currentScenePath);
|
||||
if (exists) {
|
||||
console.log('Scene already in list:', currentScenePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 store 添加场景 | Use store to add scene
|
||||
useBuildSettingsStore.getState().addScene(currentScenePath);
|
||||
}, [sceneManager, settings.scenes]);
|
||||
|
||||
// 添加脚本定义(带 prompt)| Add scripting define (with prompt)
|
||||
const handleAddDefine = useCallback(() => {
|
||||
const define = prompt('Enter scripting define:');
|
||||
if (define) {
|
||||
addDefine(define);
|
||||
}
|
||||
}, [addDefine]);
|
||||
|
||||
// 获取状态消息 | Get status message
|
||||
const getStatusMessage = useCallback((status: BuildStatus): string => {
|
||||
return t(buildStatusKeys[status]) || status;
|
||||
}, [t]);
|
||||
|
||||
// Get platform config | 获取平台配置
|
||||
const currentPlatformConfig = PLATFORMS.find(p => p.platform === selectedPlatform);
|
||||
|
||||
return (
|
||||
<div className="build-settings-panel">
|
||||
{/* Header Tabs | 头部标签 */}
|
||||
<div className="build-settings-header">
|
||||
<div className="build-settings-tabs">
|
||||
<div className="build-settings-tab active">
|
||||
<Package size={14} />
|
||||
{t('buildSettings.buildProfiles')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="build-settings-header-actions">
|
||||
<button className="build-settings-header-btn">{t('buildSettings.playerSettings')}</button>
|
||||
<button className="build-settings-header-btn">{t('buildSettings.assetImportOverrides')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Profile Bar | 添加配置栏 */}
|
||||
<div className="build-settings-add-bar">
|
||||
<button className="build-settings-add-btn" onClick={handleAddProfile}>
|
||||
<Plus size={14} />
|
||||
{t('buildSettings.addBuildProfile')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Content | 主要内容 */}
|
||||
<div className="build-settings-content">
|
||||
{/* Left Sidebar | 左侧边栏 */}
|
||||
<div className="build-settings-sidebar">
|
||||
{/* Platforms Section | 平台部分 */}
|
||||
<div className="build-settings-section">
|
||||
<div className="build-settings-section-header">{t('buildSettings.platforms')}</div>
|
||||
<div className="build-settings-platform-list">
|
||||
{PLATFORMS.map(platform => {
|
||||
const isActive = profiles.some(p => p.platform === platform.platform && p.isActive);
|
||||
return (
|
||||
<div
|
||||
key={platform.platform}
|
||||
className={`build-settings-platform-item ${selectedPlatform === platform.platform ? 'selected' : ''}`}
|
||||
onClick={() => handlePlatformSelect(platform.platform)}
|
||||
>
|
||||
<span className="build-settings-platform-icon">{platform.icon}</span>
|
||||
<span className="build-settings-platform-label">{platform.label}</span>
|
||||
{isActive && <span className="build-settings-active-badge">{t('buildSettings.active')}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Profiles Section | 构建配置部分 */}
|
||||
<div className="build-settings-section">
|
||||
<div className="build-settings-section-header">{t('buildSettings.buildProfiles')}</div>
|
||||
<div className="build-settings-profile-list">
|
||||
{profiles
|
||||
.filter(p => p.platform === selectedPlatform)
|
||||
.map(profile => (
|
||||
<div
|
||||
key={profile.id}
|
||||
className={`build-settings-profile-item ${selectedProfile?.id === profile.id ? 'selected' : ''}`}
|
||||
onClick={() => handleProfileSelect(profile)}
|
||||
>
|
||||
<span className="build-settings-profile-icon">
|
||||
{currentPlatformConfig?.icon}
|
||||
</span>
|
||||
<span className="build-settings-profile-name">{profile.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel | 右侧面板 */}
|
||||
<div className="build-settings-details">
|
||||
{selectedProfile ? (
|
||||
<>
|
||||
{/* Profile Header | 配置头部 */}
|
||||
<div className="build-settings-details-header">
|
||||
<div className="build-settings-details-title">
|
||||
<span className="build-settings-details-icon">
|
||||
{currentPlatformConfig?.icon}
|
||||
</span>
|
||||
<div className="build-settings-details-info">
|
||||
<h3>{selectedProfile.name}</h3>
|
||||
<span>{currentPlatformConfig?.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="build-settings-details-actions">
|
||||
<button className="build-settings-btn secondary">{t('buildSettings.switchProfile')}</button>
|
||||
<button className="build-settings-btn primary" onClick={handleBuild}>
|
||||
{t('buildSettings.build')}
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Data Section | 构建数据部分 */}
|
||||
<div className="build-settings-card">
|
||||
<div className="build-settings-card-header">{t('buildSettings.buildData')}</div>
|
||||
|
||||
{/* Scene List | 场景列表 */}
|
||||
<div className="build-settings-field-group">
|
||||
<div
|
||||
className="build-settings-field-header"
|
||||
onClick={() => toggleSection('sceneList')}
|
||||
>
|
||||
{expandedSections.sceneList ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{t('buildSettings.sceneList')}</span>
|
||||
</div>
|
||||
{expandedSections.sceneList && (
|
||||
<div className="build-settings-field-content">
|
||||
<div className="build-settings-scene-list">
|
||||
{settings.scenes.length === 0 ? (
|
||||
<div className="build-settings-empty-list">
|
||||
{t('buildSettings.noScenesFound')}
|
||||
</div>
|
||||
) : (
|
||||
settings.scenes.map((scene, index) => (
|
||||
<div key={index} className="build-settings-scene-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scene.enabled}
|
||||
onChange={e => setSceneEnabled(index, e.target.checked)}
|
||||
/>
|
||||
<span>{scene.path}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="build-settings-field-actions">
|
||||
<button className="build-settings-btn text" onClick={handleAddScene}>
|
||||
{t('buildSettings.addOpenScenes')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scripting Defines | 脚本定义 */}
|
||||
<div className="build-settings-field-group">
|
||||
<div
|
||||
className="build-settings-field-header"
|
||||
onClick={() => toggleSection('scriptingDefines')}
|
||||
>
|
||||
{expandedSections.scriptingDefines ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{t('buildSettings.scriptingDefines')}</span>
|
||||
</div>
|
||||
{expandedSections.scriptingDefines && (
|
||||
<div className="build-settings-field-content">
|
||||
<div className="build-settings-defines-list">
|
||||
{settings.scriptingDefines.length === 0 ? (
|
||||
<div className="build-settings-empty-text">{t('buildSettings.listIsEmpty')}</div>
|
||||
) : (
|
||||
settings.scriptingDefines.map((define, index) => (
|
||||
<div key={index} className="build-settings-define-item">
|
||||
<span>{define}</span>
|
||||
<button onClick={() => handleRemoveDefine(index)}>
|
||||
<Minus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="build-settings-list-actions">
|
||||
<button onClick={handleAddDefine}><Plus size={14} /></button>
|
||||
<button disabled={settings.scriptingDefines.length === 0}>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform Settings Section | 平台设置部分 */}
|
||||
<div className="build-settings-card">
|
||||
<div className="build-settings-card-header">{t('buildSettings.platformSettings')}</div>
|
||||
|
||||
<div className="build-settings-field-group">
|
||||
<div
|
||||
className="build-settings-field-header"
|
||||
onClick={() => toggleSection('platformSettings')}
|
||||
>
|
||||
{expandedSections.platformSettings ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{currentPlatformConfig?.label} {t('buildSettings.settings')}</span>
|
||||
</div>
|
||||
{expandedSections.platformSettings && (
|
||||
<div className="build-settings-field-content">
|
||||
<div className="build-settings-form">
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t('buildSettings.developmentBuild')}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.developmentBuild}
|
||||
onChange={e => updateSettings({ developmentBuild: e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t('buildSettings.sourceMap')}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.sourceMap}
|
||||
onChange={e => updateSettings({ sourceMap: e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t('buildSettings.compressionMethod')}</label>
|
||||
<select
|
||||
value={settings.compressionMethod}
|
||||
onChange={e => updateSettings({ compressionMethod: e.target.value as 'Default' | 'LZ4' | 'LZ4HC' })}
|
||||
>
|
||||
<option value="Default">Default</option>
|
||||
<option value="LZ4">LZ4</option>
|
||||
<option value="LZ4HC">LZ4HC</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t('buildSettings.buildMode')}</label>
|
||||
<div className="build-settings-toggle-group">
|
||||
<select
|
||||
value={settings.buildMode}
|
||||
onChange={e => updateSettings({ buildMode: e.target.value as 'split-bundles' | 'single-bundle' | 'single-file' })}
|
||||
>
|
||||
<option value="split-bundles">{t('buildSettings.splitBundles')}</option>
|
||||
<option value="single-bundle">{t('buildSettings.singleBundle')}</option>
|
||||
<option value="single-file">{t('buildSettings.singleFile')}</option>
|
||||
</select>
|
||||
<span className="build-settings-hint">
|
||||
{settings.buildMode === 'split-bundles'
|
||||
? t('buildSettings.splitBundlesHint')
|
||||
: settings.buildMode === 'single-bundle'
|
||||
? t('buildSettings.singleBundleHint')
|
||||
: t('buildSettings.singleFileHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Player Settings Overrides | 玩家设置覆盖 */}
|
||||
<div className="build-settings-card">
|
||||
<div className="build-settings-card-header">
|
||||
{t('buildSettings.playerSettingsOverrides')}
|
||||
<button className="build-settings-more-btn">
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="build-settings-field-group">
|
||||
<div
|
||||
className="build-settings-field-header"
|
||||
onClick={() => toggleSection('playerSettings')}
|
||||
>
|
||||
{expandedSections.playerSettings ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{t('buildSettings.playerSettings')}</span>
|
||||
</div>
|
||||
{expandedSections.playerSettings && (
|
||||
<div className="build-settings-field-content">
|
||||
<div className="build-settings-form">
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t('buildSettings.companyName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.companyName}
|
||||
onChange={e => updateSettings({ companyName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t('buildSettings.productName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.productName}
|
||||
onChange={e => updateSettings({ productName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t('buildSettings.version')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.version}
|
||||
onChange={e => updateSettings({ version: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="build-settings-form-row">
|
||||
<label>{t('buildSettings.defaultIcon')}</label>
|
||||
<div className="build-settings-icon-picker">
|
||||
<span>{t('buildSettings.none')}</span>
|
||||
<span className="build-settings-icon-hint">(Texture 2D)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="build-settings-no-selection">
|
||||
<p>{t('buildSettings.selectPlatform')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Progress Dialog | 构建进度对话框 */}
|
||||
{showBuildProgress && (
|
||||
<div className="build-progress-overlay">
|
||||
<div className="build-progress-dialog">
|
||||
<div className="build-progress-header">
|
||||
<h3>{t('buildSettings.buildInProgress')}</h3>
|
||||
{!isBuilding && (
|
||||
<button
|
||||
className="build-progress-close"
|
||||
onClick={handleCloseBuildProgress}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="build-progress-content">
|
||||
{/* Status Icon | 状态图标 */}
|
||||
<div className="build-progress-status-icon">
|
||||
{isBuilding ? (
|
||||
<Loader2 size={36} className="build-progress-spinner" />
|
||||
) : buildResult?.success ? (
|
||||
<CheckCircle size={40} className="build-progress-success" />
|
||||
) : (
|
||||
<XCircle size={40} className="build-progress-error" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Message | 状态消息 */}
|
||||
<div className="build-progress-message">
|
||||
{isBuilding ? (
|
||||
buildProgress?.message || getStatusMessage(buildProgress?.status || BuildStatus.Preparing)
|
||||
) : buildResult?.success ? (
|
||||
t('buildSettings.buildSucceeded')
|
||||
) : (
|
||||
t('buildSettings.buildFailed')
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar | 进度条 */}
|
||||
{isBuilding && buildProgress && (
|
||||
<div className="build-progress-bar-container">
|
||||
<div
|
||||
className="build-progress-bar"
|
||||
style={{ width: `${buildProgress.progress}%` }}
|
||||
/>
|
||||
<span className="build-progress-percent">
|
||||
{Math.round(buildProgress.progress)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Build Result Details | 构建结果详情 */}
|
||||
{!isBuilding && buildResult && (
|
||||
<div className="build-result-details">
|
||||
{buildResult.success && (
|
||||
<>
|
||||
<div className="build-result-row">
|
||||
<span className="build-result-label">{t('buildSettings.outputPath')}:</span>
|
||||
<span className="build-result-value">{buildResult.outputPath}</span>
|
||||
</div>
|
||||
<div className="build-result-row">
|
||||
<span className="build-result-label">{t('buildSettings.duration')}:</span>
|
||||
<span className="build-result-value">
|
||||
{(buildResult.duration / 1000).toFixed(2)}s
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error Message | 错误消息 */}
|
||||
{buildResult.error && (
|
||||
<BuildErrorDisplay error={buildResult.error} />
|
||||
)}
|
||||
|
||||
{/* Warnings | 警告 */}
|
||||
{buildResult.warnings.length > 0 && (
|
||||
<div className="build-result-warnings">
|
||||
<div className="build-result-warnings-header">
|
||||
<AlertTriangle size={14} />
|
||||
<span>{t('buildSettings.warnings')} ({buildResult.warnings.length})</span>
|
||||
</div>
|
||||
<ul className="build-result-warnings-list">
|
||||
{buildResult.warnings.map((warning, index) => (
|
||||
<li key={index}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions | 操作按钮 */}
|
||||
<div className="build-progress-actions">
|
||||
{isBuilding ? (
|
||||
<button
|
||||
className="build-settings-btn secondary"
|
||||
onClick={handleCancelBuild}
|
||||
>
|
||||
{t('buildSettings.cancel')}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="build-settings-btn secondary"
|
||||
onClick={handleCloseBuildProgress}
|
||||
>
|
||||
{t('buildSettings.close')}
|
||||
</button>
|
||||
{buildResult?.success && buildResult.outputPath && (
|
||||
<button
|
||||
className="build-settings-btn primary"
|
||||
onClick={() => {
|
||||
// 使用 Tauri 打开文件夹
|
||||
// Use Tauri to open folder
|
||||
invoke('open_folder', { path: buildResult.outputPath }).catch(e => {
|
||||
console.error('Failed to open folder:', e);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
{t('buildSettings.openFolder')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BuildSettingsPanel;
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Build Settings Window.
|
||||
* 构建设置窗口。
|
||||
*
|
||||
* A modal window that displays the build settings panel.
|
||||
* 显示构建设置面板的模态窗口。
|
||||
*/
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import type { BuildService, SceneManagerService, ProjectService } from '@esengine/editor-core';
|
||||
import { BuildSettingsPanel } from './BuildSettingsPanel';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import '../styles/BuildSettingsWindow.css';
|
||||
|
||||
interface BuildSettingsWindowProps {
|
||||
projectPath?: string;
|
||||
buildService?: BuildService;
|
||||
sceneManager?: SceneManagerService;
|
||||
projectService?: ProjectService;
|
||||
/** Available scenes in the project | 项目中可用的场景列表 */
|
||||
availableScenes?: string[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function BuildSettingsWindow({
|
||||
projectPath,
|
||||
buildService,
|
||||
sceneManager,
|
||||
projectService,
|
||||
availableScenes,
|
||||
onClose
|
||||
}: BuildSettingsWindowProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="build-settings-window-overlay">
|
||||
<div className="build-settings-window">
|
||||
<div className="build-settings-window-header">
|
||||
<h2>{t('build.settingsTitle')}</h2>
|
||||
<button
|
||||
className="build-settings-window-close"
|
||||
onClick={onClose}
|
||||
title="Close"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="build-settings-window-content">
|
||||
<BuildSettingsPanel
|
||||
projectPath={projectPath}
|
||||
buildService={buildService}
|
||||
sceneManager={sceneManager}
|
||||
projectService={projectService}
|
||||
availableScenes={availableScenes}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BuildSettingsWindow;
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Cpu } from 'lucide-react';
|
||||
import { ICompiler, CompileResult, CompilerContext } from '@esengine/editor-core';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import '../styles/CompileDialog.css';
|
||||
|
||||
interface CompileDialogProps<TOptions = unknown> {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
compiler: ICompiler<TOptions>;
|
||||
context: CompilerContext;
|
||||
initialOptions?: TOptions;
|
||||
}
|
||||
|
||||
export function CompileDialog<TOptions = unknown>({
|
||||
isOpen,
|
||||
onClose,
|
||||
compiler,
|
||||
context,
|
||||
initialOptions
|
||||
}: CompileDialogProps<TOptions>) {
|
||||
const { t } = useLocale();
|
||||
const [options, setOptions] = useState<TOptions>(initialOptions as TOptions);
|
||||
const [isCompiling, setIsCompiling] = useState(false);
|
||||
const [result, setResult] = useState<CompileResult | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && initialOptions) {
|
||||
setOptions(initialOptions);
|
||||
setResult(null);
|
||||
setValidationError(null);
|
||||
}
|
||||
}, [isOpen, initialOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (compiler.validateOptions && options) {
|
||||
const error = compiler.validateOptions(options);
|
||||
setValidationError(error);
|
||||
}
|
||||
}, [options, compiler]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCompile = async () => {
|
||||
if (validationError) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCompiling(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const compileResult = await compiler.compile(options, context);
|
||||
setResult(compileResult);
|
||||
} catch (error) {
|
||||
setResult({
|
||||
success: false,
|
||||
message: `${t('compileDialog.compileFailed')}: ${error}`,
|
||||
errors: [String(error)]
|
||||
});
|
||||
} finally {
|
||||
setIsCompiling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="compile-dialog-overlay">
|
||||
<div className="compile-dialog">
|
||||
<div className="compile-dialog-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Cpu size={20} />
|
||||
<h3>{compiler.name}</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="compile-dialog-close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="compile-dialog-content">
|
||||
{compiler.description && (
|
||||
<div className="compile-dialog-description">
|
||||
{compiler.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{compiler.createConfigUI && compiler.createConfigUI(setOptions, context)}
|
||||
|
||||
{validationError && (
|
||||
<div className="compile-dialog-error">
|
||||
{validationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className={`compile-dialog-result ${result.success ? 'success' : 'error'}`}>
|
||||
<div className="compile-dialog-result-message">
|
||||
{result.message}
|
||||
</div>
|
||||
{result.outputFiles && result.outputFiles.length > 0 && (
|
||||
<div className="compile-dialog-output-files">
|
||||
<div style={{ fontWeight: 600, marginBottom: '8px' }}>{t('compileDialog.outputFiles')}:</div>
|
||||
{result.outputFiles.map((file, index) => (
|
||||
<div key={index} className="compile-dialog-output-file">
|
||||
{file}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{result.errors && result.errors.length > 0 && (
|
||||
<div className="compile-dialog-errors">
|
||||
<div style={{ fontWeight: 600, marginBottom: '8px' }}>{t('compileDialog.errors')}:</div>
|
||||
{result.errors.map((error, index) => (
|
||||
<div key={index} className="compile-dialog-error-item">
|
||||
{error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="compile-dialog-footer">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="compile-dialog-btn compile-dialog-btn-cancel"
|
||||
disabled={isCompiling}
|
||||
>
|
||||
{t('compileDialog.close')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCompile}
|
||||
className="compile-dialog-btn compile-dialog-btn-primary"
|
||||
disabled={isCompiling || !!validationError}
|
||||
>
|
||||
{isCompiling ? t('compileDialog.compiling') : t('compileDialog.compile')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Core, IService, ServiceType } from '@esengine/ecs-framework';
|
||||
import { CompilerRegistry, ICompiler, CompilerContext, CompileResult, IFileSystem, IDialog, FileEntry } from '@esengine/editor-core';
|
||||
import { X, Play, Loader2 } from 'lucide-react';
|
||||
import { open as tauriOpen, save as tauriSave, message as tauriMessage, confirm as tauriConfirm } from '@tauri-apps/plugin-dialog';
|
||||
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import '../styles/CompilerConfigDialog.css';
|
||||
|
||||
interface DirectoryEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
}
|
||||
|
||||
interface CompilerConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
compilerId: string;
|
||||
projectPath: string | null;
|
||||
currentFileName?: string;
|
||||
onClose: () => void;
|
||||
onCompileComplete?: (result: CompileResult) => void;
|
||||
}
|
||||
|
||||
export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
|
||||
isOpen,
|
||||
compilerId,
|
||||
projectPath,
|
||||
currentFileName,
|
||||
onClose,
|
||||
onCompileComplete
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const [compiler, setCompiler] = useState<ICompiler | null>(null);
|
||||
const [options, setOptions] = useState<unknown>(null);
|
||||
const [isCompiling, setIsCompiling] = useState(false);
|
||||
const [compileResult, setCompileResult] = useState<CompileResult | null>(null);
|
||||
const optionsRef = useRef<unknown>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && compilerId) {
|
||||
try {
|
||||
const registry = Core.services.resolve(CompilerRegistry);
|
||||
const comp = registry.get(compilerId);
|
||||
setCompiler(comp || null);
|
||||
} catch (error) {
|
||||
console.error('[CompilerConfigDialog] Failed to resolve CompilerRegistry:', error);
|
||||
setCompiler(null);
|
||||
}
|
||||
}
|
||||
}, [isOpen, compilerId]);
|
||||
|
||||
const handleOptionsChange = useCallback((newOptions: unknown) => {
|
||||
optionsRef.current = newOptions;
|
||||
setOptions(newOptions);
|
||||
}, []);
|
||||
|
||||
const createFileSystem = (): IFileSystem => ({
|
||||
readFile: async (path: string) => {
|
||||
return await invoke<string>('read_file_content', { path });
|
||||
},
|
||||
writeFile: async (path: string, content: string) => {
|
||||
await invoke('write_file_content', { path, content });
|
||||
},
|
||||
writeBinary: async (path: string, data: Uint8Array) => {
|
||||
await invoke('write_binary_file', { filePath: path, content: Array.from(data) });
|
||||
},
|
||||
exists: async (path: string) => {
|
||||
return await invoke<boolean>('path_exists', { path });
|
||||
},
|
||||
createDirectory: async (path: string) => {
|
||||
await invoke('create_directory', { path });
|
||||
},
|
||||
listDirectory: async (path: string): Promise<FileEntry[]> => {
|
||||
const entries = await invoke<DirectoryEntry[]>('list_directory', { path });
|
||||
return entries.map((e) => ({
|
||||
name: e.name,
|
||||
path: e.path,
|
||||
isDirectory: e.is_dir
|
||||
}));
|
||||
},
|
||||
deleteFile: async (path: string) => {
|
||||
await invoke('delete_file', { path });
|
||||
},
|
||||
deleteDirectory: async (path: string) => {
|
||||
await invoke('delete_folder', { path });
|
||||
},
|
||||
scanFiles: async (dir: string, pattern: string) => {
|
||||
// Check if directory exists, create if not
|
||||
const dirExists = await invoke<boolean>('path_exists', { path: dir });
|
||||
if (!dirExists) {
|
||||
await invoke('create_directory', { path: dir });
|
||||
return []; // New directory has no files
|
||||
}
|
||||
const entries = await invoke<DirectoryEntry[]>('list_directory', { path: dir });
|
||||
const ext = pattern.replace(/\*/g, '');
|
||||
return entries
|
||||
.filter((e) => !e.is_dir && e.name.endsWith(ext))
|
||||
.map((e) => e.name.replace(ext, ''));
|
||||
},
|
||||
convertToAssetUrl: (filePath: string) => {
|
||||
return convertFileSrc(filePath);
|
||||
},
|
||||
dispose: () => {}
|
||||
});
|
||||
|
||||
const createDialog = (): IDialog => ({
|
||||
openDialog: async (opts) => {
|
||||
const result = await tauriOpen({
|
||||
directory: opts.directory,
|
||||
multiple: opts.multiple,
|
||||
title: opts.title,
|
||||
defaultPath: opts.defaultPath
|
||||
});
|
||||
return result;
|
||||
},
|
||||
saveDialog: async (opts) => {
|
||||
const result = await tauriSave({
|
||||
title: opts.title,
|
||||
defaultPath: opts.defaultPath,
|
||||
filters: opts.filters
|
||||
});
|
||||
return result;
|
||||
},
|
||||
showMessage: async (title: string, message: string, type?: 'info' | 'warning' | 'error') => {
|
||||
await tauriMessage(message, { title, kind: type || 'info' });
|
||||
},
|
||||
showConfirm: async (title: string, message: string) => {
|
||||
return await tauriConfirm(message, { title });
|
||||
},
|
||||
dispose: () => {}
|
||||
});
|
||||
|
||||
const createContext = (): CompilerContext => ({
|
||||
projectPath,
|
||||
moduleContext: {
|
||||
fileSystem: createFileSystem(),
|
||||
dialog: createDialog()
|
||||
},
|
||||
getService: <T extends IService>(serviceClass: ServiceType<T>): T | undefined => {
|
||||
try {
|
||||
return Core.services.resolve(serviceClass);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleCompile = async () => {
|
||||
if (!compiler || !optionsRef.current) return;
|
||||
|
||||
setIsCompiling(true);
|
||||
setCompileResult(null);
|
||||
|
||||
try {
|
||||
const context = createContext();
|
||||
const result = await compiler.compile(optionsRef.current, context);
|
||||
setCompileResult(result);
|
||||
onCompileComplete?.(result);
|
||||
|
||||
if (result.success) {
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
setCompileResult({
|
||||
success: false,
|
||||
message: t('compilerConfig.compileFailed', { error: String(error) }),
|
||||
errors: [String(error)]
|
||||
});
|
||||
} finally {
|
||||
setIsCompiling(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const context = createContext();
|
||||
|
||||
return (
|
||||
<div className="compiler-dialog-overlay">
|
||||
<div className="compiler-dialog">
|
||||
<div className="compiler-dialog-header">
|
||||
<h3>{compiler?.name || t('compilerConfig.title')}</h3>
|
||||
<button className="close-button" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="compiler-dialog-body">
|
||||
{compiler?.createConfigUI ? (
|
||||
compiler.createConfigUI(handleOptionsChange, context)
|
||||
) : (
|
||||
<div className="no-config">
|
||||
{compiler ? t('compilerConfig.noConfigUI') : t('compilerConfig.compilerNotFound')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{compileResult && (
|
||||
<div className={`compile-result ${compileResult.success ? 'success' : 'error'}`}>
|
||||
<div className="result-message">{compileResult.message}</div>
|
||||
{compileResult.outputFiles && compileResult.outputFiles.length > 0 && (
|
||||
<div className="output-files">
|
||||
{t('compilerConfig.generatedFiles', { count: compileResult.outputFiles.length })}
|
||||
</div>
|
||||
)}
|
||||
{compileResult.errors && compileResult.errors.length > 0 && (
|
||||
<div className="error-list">
|
||||
{compileResult.errors.map((err, i) => (
|
||||
<div key={i} className="error-item">{err}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="compiler-dialog-footer">
|
||||
<button
|
||||
className="cancel-button"
|
||||
onClick={onClose}
|
||||
disabled={isCompiling}
|
||||
>
|
||||
{t('compilerConfig.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="compile-button"
|
||||
onClick={handleCompile}
|
||||
disabled={isCompiling || !compiler || !options}
|
||||
>
|
||||
{isCompiling ? (
|
||||
<>
|
||||
<Loader2 size={16} className="spinning" />
|
||||
{t('compilerConfig.compiling')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} />
|
||||
{t('compilerConfig.compile')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { X } from 'lucide-react';
|
||||
import '../styles/ConfirmDialog.css';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText: string;
|
||||
cancelText: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ title, message, confirmText, cancelText, onConfirm, onCancel }: ConfirmDialogProps) {
|
||||
return (
|
||||
<div className="confirm-dialog-overlay" onClick={onCancel}>
|
||||
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="confirm-dialog-header">
|
||||
<h2>{title}</h2>
|
||||
<button className="close-btn" onClick={onCancel}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="confirm-dialog-content">
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
<div className="confirm-dialog-footer">
|
||||
<button className="confirm-dialog-btn cancel" onClick={onCancel}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button className="confirm-dialog-btn confirm" onClick={onConfirm}>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,373 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import '../styles/ContextMenu.css';
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
separator?: boolean;
|
||||
/** 快捷键提示文本 | Shortcut hint text */
|
||||
shortcut?: string;
|
||||
/** 子菜单项 | Submenu items */
|
||||
children?: ContextMenuItem[];
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
items: ContextMenuItem[];
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface SubMenuProps {
|
||||
items: ContextMenuItem[];
|
||||
parentRect: DOMRect;
|
||||
onClose: () => void;
|
||||
level: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算子菜单位置,处理屏幕边界
|
||||
* Calculate submenu position, handle screen boundaries
|
||||
*/
|
||||
function calculateSubmenuPosition(
|
||||
parentRect: DOMRect,
|
||||
menuWidth: number,
|
||||
menuHeight: number
|
||||
): { x: number; y: number; flipHorizontal: boolean } {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const padding = 10;
|
||||
|
||||
let x = parentRect.right;
|
||||
let y = parentRect.top;
|
||||
let flipHorizontal = false;
|
||||
|
||||
// 检查右侧空间是否足够 | Check if there's enough space on the right
|
||||
if (x + menuWidth > viewportWidth - padding) {
|
||||
// 尝试显示在左侧 | Try to show on the left side
|
||||
const leftPosition = parentRect.left - menuWidth;
|
||||
if (leftPosition >= padding) {
|
||||
x = leftPosition;
|
||||
flipHorizontal = true;
|
||||
} else {
|
||||
// 两侧都不够,选择空间更大的一侧 | Neither side has enough space, choose the larger one
|
||||
if (parentRect.left > viewportWidth - parentRect.right) {
|
||||
x = padding;
|
||||
flipHorizontal = true;
|
||||
} else {
|
||||
x = viewportWidth - menuWidth - padding;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查底部空间是否足够 | Check if there's enough space at the bottom
|
||||
if (y + menuHeight > viewportHeight - padding) {
|
||||
y = Math.max(padding, viewportHeight - menuHeight - padding);
|
||||
}
|
||||
|
||||
// 确保不超出顶部 | Ensure it doesn't go above the top
|
||||
if (y < padding) {
|
||||
y = padding;
|
||||
}
|
||||
|
||||
return { x, y, flipHorizontal };
|
||||
}
|
||||
|
||||
/**
|
||||
* 子菜单组件
|
||||
* SubMenu component
|
||||
*/
|
||||
function SubMenu({ items, parentRect, onClose, level }: SubMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [activeSubmenuIndex, setActiveSubmenuIndex] = useState<number | null>(null);
|
||||
const [submenuRect, setSubmenuRect] = useState<DOMRect | null>(null);
|
||||
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 计算位置 | Calculate position
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
const menu = menuRef.current;
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const { x, y } = calculateSubmenuPosition(parentRect, rect.width, rect.height);
|
||||
setPosition({ x, y });
|
||||
}
|
||||
}, [parentRect]);
|
||||
|
||||
// 清理定时器 | Cleanup timer
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleItemMouseEnter = useCallback((index: number, item: ContextMenuItem, e: React.MouseEvent) => {
|
||||
// 清除关闭定时器 | Clear close timer
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
setActiveSubmenuIndex(index);
|
||||
const itemRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setSubmenuRect(itemRect);
|
||||
} else {
|
||||
setActiveSubmenuIndex(null);
|
||||
setSubmenuRect(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleItemMouseLeave = useCallback((item: ContextMenuItem) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
// 延迟关闭子菜单,给用户时间移动到子菜单
|
||||
// Delay closing submenu to give user time to move to it
|
||||
closeTimeoutRef.current = setTimeout(() => {
|
||||
setActiveSubmenuIndex(null);
|
||||
setSubmenuRect(null);
|
||||
}, 150);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmenuMouseEnter = useCallback(() => {
|
||||
// 鼠标进入子菜单区域,取消关闭定时器
|
||||
// Mouse entered submenu area, cancel close timer
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始位置在屏幕外,等待计算后显示
|
||||
// Initial position off-screen, wait for calculation before showing
|
||||
const style: React.CSSProperties = position
|
||||
? { left: `${position.x}px`, top: `${position.y}px`, opacity: 1 }
|
||||
: { left: '-9999px', top: '-9999px', opacity: 0 };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="context-menu submenu"
|
||||
style={style}
|
||||
onMouseEnter={handleSubmenuMouseEnter}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
if (item.separator) {
|
||||
return <div key={index} className="context-menu-separator" />;
|
||||
}
|
||||
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''} ${activeSubmenuIndex === index ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!item.disabled && !hasChildren) {
|
||||
item.onClick();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => handleItemMouseEnter(index, item, e)}
|
||||
onMouseLeave={() => handleItemMouseLeave(item)}
|
||||
>
|
||||
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
|
||||
<span className="context-menu-label">{item.label}</span>
|
||||
{item.shortcut && <span className="context-menu-shortcut">{item.shortcut}</span>}
|
||||
{hasChildren && <ChevronRight size={12} className="context-menu-arrow" />}
|
||||
{activeSubmenuIndex === index && submenuRect && item.children && (
|
||||
<SubMenu
|
||||
items={item.children}
|
||||
parentRect={submenuRect}
|
||||
onClose={onClose}
|
||||
level={level + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [adjustedPosition, setAdjustedPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [activeSubmenuIndex, setActiveSubmenuIndex] = useState<number | null>(null);
|
||||
const [submenuRect, setSubmenuRect] = useState<DOMRect | null>(null);
|
||||
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 计算调整后的位置 | Calculate adjusted position
|
||||
useEffect(() => {
|
||||
const adjustPosition = () => {
|
||||
if (menuRef.current) {
|
||||
const menu = menuRef.current;
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
const STATUS_BAR_HEIGHT = 28;
|
||||
const TITLE_BAR_HEIGHT = 32;
|
||||
const padding = 10;
|
||||
|
||||
let x = position.x;
|
||||
let y = position.y;
|
||||
|
||||
// 检查右边界 | Check right boundary
|
||||
if (x + rect.width > viewportWidth - padding) {
|
||||
x = Math.max(padding, viewportWidth - rect.width - padding);
|
||||
}
|
||||
|
||||
// 检查下边界 | Check bottom boundary
|
||||
if (y + rect.height > viewportHeight - STATUS_BAR_HEIGHT - padding) {
|
||||
y = Math.max(TITLE_BAR_HEIGHT + padding, viewportHeight - STATUS_BAR_HEIGHT - rect.height - padding);
|
||||
}
|
||||
|
||||
// 确保不超出左边界 | Ensure not beyond left boundary
|
||||
if (x < padding) {
|
||||
x = padding;
|
||||
}
|
||||
|
||||
// 确保不超出上边界 | Ensure not beyond top boundary
|
||||
if (y < TITLE_BAR_HEIGHT + padding) {
|
||||
y = TITLE_BAR_HEIGHT + padding;
|
||||
}
|
||||
|
||||
setAdjustedPosition({ x, y });
|
||||
}
|
||||
};
|
||||
|
||||
adjustPosition();
|
||||
const rafId = requestAnimationFrame(adjustPosition);
|
||||
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [position]);
|
||||
|
||||
// 点击外部关闭 | Close on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 mousedown 而不是 click,以便更快响应
|
||||
// Use mousedown instead of click for faster response
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// 清理定时器 | Cleanup timer
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleItemMouseEnter = useCallback((index: number, item: ContextMenuItem, e: React.MouseEvent) => {
|
||||
// 清除关闭定时器 | Clear close timer
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
setActiveSubmenuIndex(index);
|
||||
const itemRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setSubmenuRect(itemRect);
|
||||
} else {
|
||||
setActiveSubmenuIndex(null);
|
||||
setSubmenuRect(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleItemMouseLeave = useCallback((item: ContextMenuItem) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
// 延迟关闭子菜单,给用户时间移动到子菜单
|
||||
// Delay closing submenu to give user time to move to it
|
||||
closeTimeoutRef.current = setTimeout(() => {
|
||||
setActiveSubmenuIndex(null);
|
||||
setSubmenuRect(null);
|
||||
}, 150);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmenuMouseEnter = useCallback(() => {
|
||||
// 鼠标进入子菜单区域,取消关闭定时器
|
||||
// Mouse entered submenu area, cancel close timer
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始位置在屏幕外,等待计算后显示
|
||||
// Initial position off-screen, wait for calculation before showing
|
||||
const style: React.CSSProperties = adjustedPosition
|
||||
? { left: `${adjustedPosition.x}px`, top: `${adjustedPosition.y}px`, opacity: 1 }
|
||||
: { left: '-9999px', top: '-9999px', opacity: 0 };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="context-menu"
|
||||
style={style}
|
||||
onMouseEnter={handleSubmenuMouseEnter}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
if (item.separator) {
|
||||
return <div key={index} className="context-menu-separator" />;
|
||||
}
|
||||
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''} ${activeSubmenuIndex === index ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!item.disabled && !hasChildren) {
|
||||
item.onClick();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => handleItemMouseEnter(index, item, e)}
|
||||
onMouseLeave={() => handleItemMouseLeave(item)}
|
||||
>
|
||||
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
|
||||
<span className="context-menu-label">{item.label}</span>
|
||||
{item.shortcut && <span className="context-menu-shortcut">{item.shortcut}</span>}
|
||||
{hasChildren && <ChevronRight size={12} className="context-menu-arrow" />}
|
||||
{activeSubmenuIndex === index && submenuRect && item.children && (
|
||||
<SubMenu
|
||||
items={item.children}
|
||||
parentRect={submenuRect}
|
||||
onClose={onClose}
|
||||
level={1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* EditorViewport Component
|
||||
* 编辑器视口组件
|
||||
*
|
||||
* A reusable viewport component for editor panels that need engine rendering.
|
||||
* Supports camera controls, overlays, and preview scenes.
|
||||
*
|
||||
* 用于需要引擎渲染的编辑器面板的可重用视口组件。
|
||||
* 支持相机控制、覆盖层和预览场景。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import type { ViewportCameraConfig, IViewportOverlay } from '@esengine/editor-core';
|
||||
import { ViewportService } from '../services/ViewportService';
|
||||
import '../styles/EditorViewport.css';
|
||||
|
||||
/**
|
||||
* EditorViewport configuration
|
||||
* 编辑器视口配置
|
||||
*/
|
||||
export interface EditorViewportConfig {
|
||||
/** Unique viewport identifier | 唯一视口标识符 */
|
||||
viewportId: string;
|
||||
/** Initial camera config | 初始相机配置 */
|
||||
initialCamera?: ViewportCameraConfig;
|
||||
/** Whether to show grid | 是否显示网格 */
|
||||
showGrid?: boolean;
|
||||
/** Whether to show gizmos | 是否显示辅助线 */
|
||||
showGizmos?: boolean;
|
||||
/** Background clear color | 背景清除颜色 */
|
||||
clearColor?: { r: number; g: number; b: number; a: number };
|
||||
/** Min zoom level | 最小缩放级别 */
|
||||
minZoom?: number;
|
||||
/** Max zoom level | 最大缩放级别 */
|
||||
maxZoom?: number;
|
||||
/** Enable camera pan | 启用相机平移 */
|
||||
enablePan?: boolean;
|
||||
/** Enable camera zoom | 启用相机缩放 */
|
||||
enableZoom?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* EditorViewport props
|
||||
* 编辑器视口属性
|
||||
*/
|
||||
export interface EditorViewportProps extends EditorViewportConfig {
|
||||
/** Class name for styling | 样式类名 */
|
||||
className?: string;
|
||||
/** Called when camera changes | 相机变化时的回调 */
|
||||
onCameraChange?: (camera: ViewportCameraConfig) => void;
|
||||
/** Called when viewport is ready | 视口准备就绪时的回调 */
|
||||
onReady?: () => void;
|
||||
/** Called on mouse down | 鼠标按下时的回调 */
|
||||
onMouseDown?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
|
||||
/** Called on mouse move | 鼠标移动时的回调 */
|
||||
onMouseMove?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
|
||||
/** Called on mouse up | 鼠标抬起时的回调 */
|
||||
onMouseUp?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
|
||||
/** Called on mouse wheel | 鼠标滚轮时的回调 */
|
||||
onWheel?: (e: React.WheelEvent, worldPos: { x: number; y: number }) => void;
|
||||
/** Render custom overlays | 渲染自定义覆盖层 */
|
||||
renderOverlays?: () => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* EditorViewport handle for imperative access
|
||||
* 编辑器视口句柄,用于命令式访问
|
||||
*/
|
||||
export interface EditorViewportHandle {
|
||||
/** Get current camera | 获取当前相机 */
|
||||
getCamera(): ViewportCameraConfig;
|
||||
/** Set camera | 设置相机 */
|
||||
setCamera(camera: ViewportCameraConfig): void;
|
||||
/** Reset camera to initial state | 重置相机到初始状态 */
|
||||
resetCamera(): void;
|
||||
/** Convert screen coordinates to world coordinates | 将屏幕坐标转换为世界坐标 */
|
||||
screenToWorld(screenX: number, screenY: number): { x: number; y: number };
|
||||
/** Convert world coordinates to screen coordinates | 将世界坐标转换为屏幕坐标 */
|
||||
worldToScreen(worldX: number, worldY: number): { x: number; y: number };
|
||||
/** Get canvas element | 获取画布元素 */
|
||||
getCanvas(): HTMLCanvasElement | null;
|
||||
/** Request render | 请求渲染 */
|
||||
requestRender(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* EditorViewport Component
|
||||
* 编辑器视口组件
|
||||
*/
|
||||
export const EditorViewport = forwardRef<EditorViewportHandle, EditorViewportProps>(function EditorViewport(
|
||||
{
|
||||
viewportId,
|
||||
initialCamera = { x: 0, y: 0, zoom: 1 },
|
||||
showGrid = true,
|
||||
showGizmos = false,
|
||||
clearColor,
|
||||
minZoom = 0.1,
|
||||
maxZoom = 10,
|
||||
enablePan = true,
|
||||
enableZoom = true,
|
||||
className,
|
||||
onCameraChange,
|
||||
onReady,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
onWheel,
|
||||
renderOverlays
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
// Camera state
|
||||
const [camera, setCamera] = useState<ViewportCameraConfig>(initialCamera);
|
||||
const cameraRef = useRef(camera);
|
||||
|
||||
// Drag state
|
||||
const isDraggingRef = useRef(false);
|
||||
const lastMousePosRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
// Keep camera ref in sync
|
||||
useEffect(() => {
|
||||
cameraRef.current = camera;
|
||||
}, [camera]);
|
||||
|
||||
// Screen to world conversion
|
||||
const screenToWorld = useCallback((screenX: number, screenY: number): { x: number; y: number } => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// Convert to canvas pixel coordinates
|
||||
const canvasX = (screenX - rect.left) * dpr;
|
||||
const canvasY = (screenY - rect.top) * dpr;
|
||||
|
||||
// Convert to centered coordinates (Y-up)
|
||||
const centeredX = canvasX - canvas.width / 2;
|
||||
const centeredY = canvas.height / 2 - canvasY;
|
||||
|
||||
// Apply inverse zoom and add camera position
|
||||
const cam = cameraRef.current;
|
||||
const worldX = centeredX / cam.zoom + cam.x;
|
||||
const worldY = centeredY / cam.zoom + cam.y;
|
||||
|
||||
return { x: worldX, y: worldY };
|
||||
}, []);
|
||||
|
||||
// World to screen conversion
|
||||
const worldToScreen = useCallback((worldX: number, worldY: number): { x: number; y: number } => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cam = cameraRef.current;
|
||||
|
||||
// Apply camera transform
|
||||
const centeredX = (worldX - cam.x) * cam.zoom;
|
||||
const centeredY = (worldY - cam.y) * cam.zoom;
|
||||
|
||||
// Convert from centered coordinates
|
||||
const canvasX = centeredX + canvas.width / 2;
|
||||
const canvasY = canvas.height / 2 - centeredY;
|
||||
|
||||
// Convert to screen coordinates
|
||||
const screenX = canvasX / dpr + rect.left;
|
||||
const screenY = canvasY / dpr + rect.top;
|
||||
|
||||
return { x: screenX, y: screenY };
|
||||
}, []);
|
||||
|
||||
// Request render
|
||||
const requestRender = useCallback(() => {
|
||||
const viewportService = ViewportService.getInstance();
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.renderToViewport(viewportId);
|
||||
}
|
||||
}, [viewportId]);
|
||||
|
||||
// Expose imperative handle
|
||||
useImperativeHandle(ref, () => ({
|
||||
getCamera: () => cameraRef.current,
|
||||
setCamera: (newCamera: ViewportCameraConfig) => {
|
||||
setCamera(newCamera);
|
||||
onCameraChange?.(newCamera);
|
||||
},
|
||||
resetCamera: () => {
|
||||
setCamera(initialCamera);
|
||||
onCameraChange?.(initialCamera);
|
||||
},
|
||||
screenToWorld,
|
||||
worldToScreen,
|
||||
getCanvas: () => canvasRef.current,
|
||||
requestRender
|
||||
}), [initialCamera, screenToWorld, worldToScreen, onCameraChange, requestRender]);
|
||||
|
||||
// Initialize viewport
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const canvasId = `editor-viewport-canvas-${viewportId}`;
|
||||
canvas.id = canvasId;
|
||||
|
||||
const viewportService = ViewportService.getInstance();
|
||||
|
||||
// Wait for service to be initialized
|
||||
const checkInit = () => {
|
||||
if (viewportService.isInitialized()) {
|
||||
// Register viewport
|
||||
viewportService.registerViewport(viewportId, canvasId);
|
||||
viewportService.setViewportConfig(viewportId, showGrid, showGizmos);
|
||||
viewportService.setViewportCamera(viewportId, camera);
|
||||
|
||||
setIsReady(true);
|
||||
onReady?.();
|
||||
} else {
|
||||
// Retry after a short delay
|
||||
setTimeout(checkInit, 100);
|
||||
}
|
||||
};
|
||||
|
||||
checkInit();
|
||||
|
||||
return () => {
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.unregisterViewport(viewportId);
|
||||
}
|
||||
};
|
||||
}, [viewportId]);
|
||||
|
||||
// Update viewport config when props change
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
|
||||
const viewportService = ViewportService.getInstance();
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.setViewportConfig(viewportId, showGrid, showGizmos);
|
||||
}
|
||||
}, [viewportId, showGrid, showGizmos, isReady]);
|
||||
|
||||
// Sync camera to viewport service
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
|
||||
const viewportService = ViewportService.getInstance();
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.setViewportCamera(viewportId, camera);
|
||||
}
|
||||
}, [viewportId, camera, isReady]);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const resizeCanvas = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
|
||||
if (isReady) {
|
||||
const viewportService = ViewportService.getInstance();
|
||||
if (viewportService.isInitialized()) {
|
||||
viewportService.resizeViewport(viewportId, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
resizeCanvas();
|
||||
|
||||
let rafId: number | null = null;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
resizeCanvas();
|
||||
rafId = null;
|
||||
});
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [viewportId, isReady]);
|
||||
|
||||
// Mouse handlers
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
|
||||
// Middle or right button for camera pan
|
||||
if (enablePan && (e.button === 1 || e.button === 2)) {
|
||||
isDraggingRef.current = true;
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
onMouseDown?.(e, worldPos);
|
||||
}, [enablePan, screenToWorld, onMouseDown]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
|
||||
if (isDraggingRef.current && enablePan) {
|
||||
const deltaX = e.clientX - lastMousePosRef.current.x;
|
||||
const deltaY = e.clientY - lastMousePosRef.current.y;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
setCamera(prev => {
|
||||
const newCamera = {
|
||||
...prev,
|
||||
x: prev.x - (deltaX * dpr) / prev.zoom,
|
||||
y: prev.y + (deltaY * dpr) / prev.zoom
|
||||
};
|
||||
onCameraChange?.(newCamera);
|
||||
return newCamera;
|
||||
});
|
||||
|
||||
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
onMouseMove?.(e, worldPos);
|
||||
}, [enablePan, screenToWorld, onMouseMove, onCameraChange]);
|
||||
|
||||
const handleMouseUp = useCallback((e: React.MouseEvent) => {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
isDraggingRef.current = false;
|
||||
onMouseUp?.(e, worldPos);
|
||||
}, [screenToWorld, onMouseUp]);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY);
|
||||
|
||||
if (enableZoom) {
|
||||
e.preventDefault();
|
||||
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
|
||||
setCamera(prev => {
|
||||
const newZoom = Math.max(minZoom, Math.min(maxZoom, prev.zoom * zoomFactor));
|
||||
const newCamera = { ...prev, zoom: newZoom };
|
||||
onCameraChange?.(newCamera);
|
||||
return newCamera;
|
||||
});
|
||||
}
|
||||
|
||||
onWheel?.(e, worldPos);
|
||||
}, [enableZoom, minZoom, maxZoom, screenToWorld, onWheel, onCameraChange]);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`editor-viewport ${className || ''}`}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="editor-viewport-canvas"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
{renderOverlays?.()}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default EditorViewport;
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { DomainError } from '../domain/errors';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: (error: Error) => ReactNode;
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
console.error('ErrorBoundary caught error:', error, errorInfo);
|
||||
this.props.onError?.(error, errorInfo);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback(this.state.error);
|
||||
}
|
||||
|
||||
return <DefaultErrorFallback error={this.state.error} />;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function DefaultErrorFallback({ error }: { error: Error }): JSX.Element {
|
||||
const message = error instanceof DomainError ? error.getUserMessage() : error.message;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#fee',
|
||||
border: '1px solid #fcc',
|
||||
borderRadius: '4px',
|
||||
margin: '20px'
|
||||
}}
|
||||
>
|
||||
<h2 style={{ color: '#c00', marginTop: 0 }}>出错了</h2>
|
||||
<p>{message}</p>
|
||||
<details style={{ marginTop: '10px' }}>
|
||||
<summary style={{ cursor: 'pointer' }}>技术详情</summary>
|
||||
<pre
|
||||
style={{
|
||||
marginTop: '10px',
|
||||
padding: '10px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
{error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { X } from 'lucide-react';
|
||||
import '../styles/ErrorDialog.css';
|
||||
|
||||
interface ErrorDialogProps {
|
||||
title: string;
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ErrorDialog({ title, message, onClose }: ErrorDialogProps) {
|
||||
return (
|
||||
<div className="error-dialog-overlay" onClick={onClose}>
|
||||
<div className="error-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="error-dialog-header">
|
||||
<h2>{title}</h2>
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="error-dialog-content">
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
<div className="error-dialog-footer">
|
||||
<button className="error-dialog-btn" onClick={onClose}>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, File, FolderTree, FolderOpen } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import '../styles/ExportRuntimeDialog.css';
|
||||
|
||||
interface ExportRuntimeDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onExport: (options: ExportOptions) => void;
|
||||
hasProject: boolean;
|
||||
availableFiles: string[];
|
||||
currentFileName?: string;
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
mode: 'single' | 'workspace';
|
||||
assetOutputPath: string;
|
||||
typeOutputPath: string;
|
||||
selectedFiles: string[];
|
||||
fileFormats: Map<string, 'json' | 'binary'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出运行时资产对话框
|
||||
*/
|
||||
export const ExportRuntimeDialog: React.FC<ExportRuntimeDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onExport,
|
||||
hasProject,
|
||||
availableFiles,
|
||||
currentFileName,
|
||||
projectPath
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const [selectedMode, setSelectedMode] = useState<'single' | 'workspace'>('workspace');
|
||||
const [assetOutputPath, setAssetOutputPath] = useState('');
|
||||
const [typeOutputPath, setTypeOutputPath] = useState('');
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||
const [fileFormats, setFileFormats] = useState<Map<string, 'json' | 'binary'>>(new Map());
|
||||
const [selectAll, setSelectAll] = useState(true);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportProgress, setExportProgress] = useState(0);
|
||||
const [exportMessage, setExportMessage] = useState('');
|
||||
|
||||
// 从 localStorage 加载上次的路径
|
||||
useEffect(() => {
|
||||
if (isOpen && projectPath) {
|
||||
const savedAssetPath = localStorage.getItem('export-asset-path');
|
||||
const savedTypePath = localStorage.getItem('export-type-path');
|
||||
|
||||
if (savedAssetPath) {
|
||||
setAssetOutputPath(savedAssetPath);
|
||||
}
|
||||
if (savedTypePath) {
|
||||
setTypeOutputPath(savedTypePath);
|
||||
}
|
||||
}
|
||||
}, [isOpen, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (selectedMode === 'workspace') {
|
||||
const newSelectedFiles = new Set(availableFiles);
|
||||
setSelectedFiles(newSelectedFiles);
|
||||
setSelectAll(true);
|
||||
|
||||
const newFormats = new Map<string, 'json' | 'binary'>();
|
||||
availableFiles.forEach((file) => {
|
||||
newFormats.set(file, 'binary');
|
||||
});
|
||||
setFileFormats(newFormats);
|
||||
} else {
|
||||
setSelectedFiles(new Set());
|
||||
setSelectAll(false);
|
||||
if (currentFileName) {
|
||||
const newFormats = new Map<string, 'json' | 'binary'>();
|
||||
newFormats.set(currentFileName, 'binary');
|
||||
setFileFormats(newFormats);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isOpen, selectedMode, availableFiles, currentFileName]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectAll) {
|
||||
setSelectedFiles(new Set());
|
||||
setSelectAll(false);
|
||||
} else {
|
||||
setSelectedFiles(new Set(availableFiles));
|
||||
setSelectAll(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFile = (file: string) => {
|
||||
const newSelected = new Set(selectedFiles);
|
||||
if (newSelected.has(file)) {
|
||||
newSelected.delete(file);
|
||||
} else {
|
||||
newSelected.add(file);
|
||||
}
|
||||
setSelectedFiles(newSelected);
|
||||
setSelectAll(newSelected.size === availableFiles.length);
|
||||
};
|
||||
|
||||
const handleFileFormatChange = (file: string, format: 'json' | 'binary') => {
|
||||
const newFormats = new Map(fileFormats);
|
||||
newFormats.set(file, format);
|
||||
setFileFormats(newFormats);
|
||||
};
|
||||
|
||||
const handleBrowseAssetPath = async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: t('exportRuntime.selectAssetDir'),
|
||||
defaultPath: assetOutputPath || projectPath
|
||||
});
|
||||
if (selected) {
|
||||
const path = selected as string;
|
||||
setAssetOutputPath(path);
|
||||
localStorage.setItem('export-asset-path', path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to browse asset path:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseTypePath = async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: t('exportRuntime.selectTypeDir'),
|
||||
defaultPath: typeOutputPath || projectPath
|
||||
});
|
||||
if (selected) {
|
||||
const path = selected as string;
|
||||
setTypeOutputPath(path);
|
||||
localStorage.setItem('export-type-path', path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to browse type path:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!assetOutputPath) {
|
||||
setExportMessage(t('exportRuntime.errorSelectAssetPath'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!typeOutputPath) {
|
||||
setExportMessage(t('exportRuntime.errorSelectTypePath'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedMode === 'workspace' && selectedFiles.size === 0) {
|
||||
setExportMessage(t('exportRuntime.errorSelectFile'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedMode === 'single' && !currentFileName) {
|
||||
setExportMessage(t('exportRuntime.errorNoCurrentFile'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存路径到 localStorage
|
||||
localStorage.setItem('export-asset-path', assetOutputPath);
|
||||
localStorage.setItem('export-type-path', typeOutputPath);
|
||||
|
||||
setIsExporting(true);
|
||||
setExportProgress(0);
|
||||
setExportMessage(t('exportRuntime.exporting'));
|
||||
|
||||
try {
|
||||
await onExport({
|
||||
mode: selectedMode,
|
||||
assetOutputPath,
|
||||
typeOutputPath,
|
||||
selectedFiles: selectedMode === 'workspace' ? Array.from(selectedFiles) : [currentFileName!],
|
||||
fileFormats
|
||||
});
|
||||
|
||||
setExportProgress(100);
|
||||
setExportMessage(t('exportRuntime.exportSuccess'));
|
||||
} catch (error) {
|
||||
setExportMessage(t('exportRuntime.exportFailed', { error: String(error) }));
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="export-dialog-overlay">
|
||||
<div className="export-dialog" style={{ maxWidth: '700px', width: '90%' }}>
|
||||
<div className="export-dialog-header">
|
||||
<h3>{t('exportRuntime.title')}</h3>
|
||||
<button onClick={onClose} className="export-dialog-close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="export-dialog-content" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
{/* Tab 页签 */}
|
||||
<div className="export-mode-tabs">
|
||||
<button
|
||||
className={`export-mode-tab ${selectedMode === 'workspace' ? 'active' : ''}`}
|
||||
onClick={() => hasProject ? setSelectedMode('workspace') : null}
|
||||
disabled={!hasProject}
|
||||
>
|
||||
<FolderTree size={16} />
|
||||
{t('exportRuntime.workspaceExport')}
|
||||
</button>
|
||||
<button
|
||||
className={`export-mode-tab ${selectedMode === 'single' ? 'active' : ''}`}
|
||||
onClick={() => setSelectedMode('single')}
|
||||
>
|
||||
<File size={16} />
|
||||
{t('exportRuntime.currentFile')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 资产输出路径 */}
|
||||
<div className="export-section">
|
||||
<h4>{t('exportRuntime.assetOutputPath')}</h4>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={assetOutputPath}
|
||||
onChange={(e) => setAssetOutputPath(e.target.value)}
|
||||
placeholder={t('exportRuntime.selectAssetDirPlaceholder')}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
border: '1px solid #3a3a3a',
|
||||
borderRadius: '4px',
|
||||
color: '#cccccc',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleBrowseAssetPath}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px'
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
{t('exportRuntime.browse')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TypeScript 类型定义输出路径 */}
|
||||
<div className="export-section">
|
||||
<h4>{t('exportRuntime.typeOutputPath')}</h4>
|
||||
<div style={{ marginBottom: '12px', fontSize: '11px', color: '#999', lineHeight: '1.5', whiteSpace: 'pre-line' }}>
|
||||
{selectedMode === 'workspace'
|
||||
? t('exportRuntime.typeOutputHintWorkspace')
|
||||
: t('exportRuntime.typeOutputHintSingle')
|
||||
}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={typeOutputPath}
|
||||
onChange={(e) => setTypeOutputPath(e.target.value)}
|
||||
placeholder={t('exportRuntime.selectTypeDirPlaceholder')}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
border: '1px solid #3a3a3a',
|
||||
borderRadius: '4px',
|
||||
color: '#cccccc',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleBrowseTypePath}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px'
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
{t('exportRuntime.browse')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件列表 */}
|
||||
{selectedMode === 'workspace' && availableFiles.length > 0 && (
|
||||
<div className="export-section">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<h4 style={{ margin: 0, fontSize: '13px', color: '#ccc' }}>
|
||||
{t('exportRuntime.selectFilesToExport')} ({selectedFiles.size}/{availableFiles.length})
|
||||
</h4>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#3a3a3a',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
{selectAll ? t('exportRuntime.deselectAll') : t('exportRuntime.selectAll')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="export-file-list">
|
||||
{availableFiles.map((file) => (
|
||||
<div
|
||||
key={file}
|
||||
className={`export-file-item ${selectedFiles.has(file) ? 'selected' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="export-file-checkbox"
|
||||
checked={selectedFiles.has(file)}
|
||||
onChange={() => handleToggleFile(file)}
|
||||
/>
|
||||
<div className="export-file-name">
|
||||
<File size={14} />
|
||||
{file}.btree
|
||||
</div>
|
||||
<select
|
||||
className="export-file-format"
|
||||
value={fileFormats.get(file) || 'binary'}
|
||||
onChange={(e) => handleFileFormatChange(file, e.target.value as 'json' | 'binary')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="binary">{t('exportRuntime.binary')}</option>
|
||||
<option value="json">{t('exportRuntime.json')}</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 单文件模式 */}
|
||||
{selectedMode === 'single' && (
|
||||
<div className="export-section">
|
||||
<h4>{t('exportRuntime.currentFile')}</h4>
|
||||
{currentFileName ? (
|
||||
<div className="export-file-list">
|
||||
<div className="export-file-item selected">
|
||||
<div className="export-file-name" style={{ paddingLeft: '8px' }}>
|
||||
<File size={14} />
|
||||
{currentFileName}.btree
|
||||
</div>
|
||||
<select
|
||||
className="export-file-format"
|
||||
value={fileFormats.get(currentFileName) || 'binary'}
|
||||
onChange={(e) => handleFileFormatChange(currentFileName, e.target.value as 'json' | 'binary')}
|
||||
>
|
||||
<option value="binary">{t('exportRuntime.binary')}</option>
|
||||
<option value="json">{t('exportRuntime.json')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
padding: '40px 20px',
|
||||
textAlign: 'center',
|
||||
color: '#999',
|
||||
fontSize: '13px',
|
||||
backgroundColor: '#252525',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #3a3a3a'
|
||||
}}>
|
||||
<File size={32} style={{ margin: '0 auto 12px', opacity: 0.5 }} />
|
||||
<div>{t('exportRuntime.noOpenFile')}</div>
|
||||
<div style={{ fontSize: '11px', marginTop: '8px' }}>
|
||||
{t('exportRuntime.openFileHint')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="export-dialog-footer">
|
||||
{exportMessage && (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
fontSize: '12px',
|
||||
color: exportMessage.includes('Error') || exportMessage.includes('错误') ? '#f48771' : exportMessage.includes('success') || exportMessage.includes('成功') ? '#89d185' : '#ccc',
|
||||
paddingLeft: '8px'
|
||||
}}>
|
||||
{exportMessage}
|
||||
</div>
|
||||
)}
|
||||
{isExporting && (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
height: '4px',
|
||||
backgroundColor: '#3a3a3a',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden',
|
||||
marginRight: '12px'
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${exportProgress}%`,
|
||||
backgroundColor: '#0e639c',
|
||||
transition: 'width 0.3s'
|
||||
}}></div>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={onClose} className="export-dialog-btn export-dialog-btn-cancel">
|
||||
{t('exportRuntime.close')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="export-dialog-btn export-dialog-btn-primary"
|
||||
disabled={isExporting}
|
||||
style={{ opacity: isExporting ? 0.5 : 1 }}
|
||||
>
|
||||
{isExporting ? t('exportRuntime.exporting') : t('exportRuntime.export')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { AlertTriangle, X, RefreshCw, Save } from 'lucide-react';
|
||||
import '../styles/ConfirmDialog.css';
|
||||
|
||||
interface ExternalModificationDialogProps {
|
||||
sceneName: string;
|
||||
onReload: () => void;
|
||||
onOverwrite: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部修改对话框
|
||||
* External Modification Dialog
|
||||
*
|
||||
* 当场景文件被外部修改时显示,让用户选择操作
|
||||
* Shown when scene file is modified externally, let user choose action
|
||||
*/
|
||||
export function ExternalModificationDialog({
|
||||
sceneName,
|
||||
onReload,
|
||||
onOverwrite,
|
||||
onCancel
|
||||
}: ExternalModificationDialogProps) {
|
||||
return (
|
||||
<div className="confirm-dialog-overlay" onClick={onCancel}>
|
||||
<div className="confirm-dialog external-modification-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="confirm-dialog-header">
|
||||
<AlertTriangle size={20} className="warning-icon" />
|
||||
<h2>文件已被外部修改</h2>
|
||||
<button className="close-btn" onClick={onCancel}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="confirm-dialog-content">
|
||||
<p>
|
||||
场景 <strong>{sceneName}</strong> 已在编辑器外部被修改。
|
||||
</p>
|
||||
<p className="hint-text">
|
||||
请选择如何处理:
|
||||
</p>
|
||||
</div>
|
||||
<div className="confirm-dialog-footer external-modification-footer">
|
||||
<button className="confirm-dialog-btn cancel" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
<button className="confirm-dialog-btn reload" onClick={onReload}>
|
||||
<RefreshCw size={14} />
|
||||
重新加载
|
||||
</button>
|
||||
<button className="confirm-dialog-btn overwrite" onClick={onOverwrite}>
|
||||
<Save size={14} />
|
||||
覆盖保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* FlexLayoutDockContainer - Dockable panel container based on FlexLayout
|
||||
* FlexLayoutDockContainer - 基于 FlexLayout 的可停靠面板容器
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useEffect, useState, useMemo, useImperativeHandle, forwardRef } from 'react';
|
||||
import { Layout, Model, TabNode, TabSetNode, IJsonModel, Actions, Action, DockLocation } from 'flexlayout-react';
|
||||
import 'flexlayout-react/style/light.css';
|
||||
import '../styles/FlexLayoutDock.css';
|
||||
import { LayoutMerger, LayoutBuilder, FlexDockPanel } from '../shared/layout';
|
||||
|
||||
export type { FlexDockPanel };
|
||||
|
||||
/** LocalStorage key for persisting layout | 持久化布局的 localStorage 键 */
|
||||
const LAYOUT_STORAGE_KEY = 'esengine-editor-layout';
|
||||
|
||||
/** Layout version for migration | 布局版本用于迁移 */
|
||||
const LAYOUT_VERSION = 1;
|
||||
|
||||
/** Saved layout data structure | 保存的布局数据结构 */
|
||||
interface SavedLayoutData {
|
||||
version: number;
|
||||
layout: IJsonModel;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save layout to localStorage.
|
||||
* 保存布局到 localStorage。
|
||||
*/
|
||||
function saveLayoutToStorage(layout: IJsonModel): void {
|
||||
try {
|
||||
const data: SavedLayoutData = {
|
||||
version: LAYOUT_VERSION,
|
||||
layout,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.warn('Failed to save layout to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load layout from localStorage.
|
||||
* 从 localStorage 加载布局。
|
||||
*/
|
||||
function loadLayoutFromStorage(): IJsonModel | null {
|
||||
try {
|
||||
const saved = localStorage.getItem(LAYOUT_STORAGE_KEY);
|
||||
if (!saved) return null;
|
||||
|
||||
const data: SavedLayoutData = JSON.parse(saved);
|
||||
|
||||
// Version check for future migrations
|
||||
if (data.version !== LAYOUT_VERSION) {
|
||||
console.info('Layout version mismatch, using default layout');
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.layout;
|
||||
} catch (error) {
|
||||
console.warn('Failed to load layout from localStorage:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear saved layout from localStorage.
|
||||
* 从 localStorage 清除保存的布局。
|
||||
*/
|
||||
function clearLayoutStorage(): void {
|
||||
try {
|
||||
localStorage.removeItem(LAYOUT_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear layout from localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public handle for FlexLayoutDockContainer.
|
||||
* FlexLayoutDockContainer 的公开句柄。
|
||||
*/
|
||||
export interface FlexLayoutDockContainerHandle {
|
||||
/** Reset layout to default | 重置布局到默认状态 */
|
||||
resetLayout: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel IDs that should persist in DOM when switching tabs.
|
||||
* These panels contain WebGL canvas or other stateful content that cannot be unmounted.
|
||||
* 切换 tab 时需要保持 DOM 存在的面板 ID。
|
||||
* 这些面板包含 WebGL canvas 或其他不能卸载的有状态内容。
|
||||
*/
|
||||
const PERSISTENT_PANEL_IDS = ['viewport'];
|
||||
|
||||
/** Tab header height in pixels | Tab 标签栏高度(像素) */
|
||||
const TAB_HEADER_HEIGHT = 28;
|
||||
|
||||
interface PanelRect {
|
||||
domRect: DOMRect;
|
||||
isSelected: boolean;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get panel rectangle from FlexLayout model.
|
||||
* 从 FlexLayout 模型获取面板矩形。
|
||||
*/
|
||||
function getPanelRectFromModel(model: Model, panelId: string): PanelRect | null {
|
||||
const node = model.getNodeById(panelId);
|
||||
if (!node || node.getType() !== 'tab') return null;
|
||||
|
||||
const parent = node.getParent();
|
||||
if (!parent || parent.getType() !== 'tabset') return null;
|
||||
|
||||
const tabset = parent as any;
|
||||
const selectedNode = tabset.getSelectedNode();
|
||||
const isSelected = selectedNode?.getId() === panelId;
|
||||
const tabsetRect = tabset.getRect();
|
||||
|
||||
if (!tabsetRect) return null;
|
||||
|
||||
return {
|
||||
domRect: new DOMRect(
|
||||
tabsetRect.x,
|
||||
tabsetRect.y + TAB_HEADER_HEIGHT,
|
||||
tabsetRect.width,
|
||||
tabsetRect.height - TAB_HEADER_HEIGHT
|
||||
),
|
||||
isSelected
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get panel rectangle from DOM placeholder element.
|
||||
* 从 DOM 占位符元素获取面板矩形。
|
||||
*/
|
||||
function getPanelRectFromDOM(panelId: string): PanelRect | null {
|
||||
const placeholder = document.querySelector(`[data-panel-id="${panelId}"]`);
|
||||
if (!placeholder) return null;
|
||||
|
||||
const placeholderRect = placeholder.getBoundingClientRect();
|
||||
if (placeholderRect.width <= 0 || placeholderRect.height <= 0) return null;
|
||||
|
||||
const container = document.querySelector('.flexlayout-dock-container');
|
||||
if (!container) return null;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const parentTab = placeholder.closest('.flexlayout__tabset_content');
|
||||
const isVisible = parentTab ? (parentTab as HTMLElement).offsetParent !== null : false;
|
||||
|
||||
return {
|
||||
domRect: new DOMRect(
|
||||
placeholderRect.x - containerRect.x,
|
||||
placeholderRect.y - containerRect.y,
|
||||
placeholderRect.width,
|
||||
placeholderRect.height
|
||||
),
|
||||
isSelected: false,
|
||||
isVisible
|
||||
};
|
||||
}
|
||||
|
||||
interface FlexLayoutDockContainerProps {
|
||||
panels: FlexDockPanel[];
|
||||
onPanelClose?: (panelId: string) => void;
|
||||
activePanelId?: string;
|
||||
messageHub?: { subscribe: (event: string, callback: (data: any) => void) => () => void } | null;
|
||||
}
|
||||
|
||||
export const FlexLayoutDockContainer = forwardRef<FlexLayoutDockContainerHandle, FlexLayoutDockContainerProps>(
|
||||
function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, messageHub }, ref) {
|
||||
const layoutRef = useRef<Layout>(null);
|
||||
const previousLayoutJsonRef = useRef<string | null>(null);
|
||||
const previousPanelIdsRef = useRef<string>('');
|
||||
const previousPanelTitlesRef = useRef<Map<string, string>>(new Map());
|
||||
/** Skip saving on next model change (used when resetting layout) | 下次模型变化时跳过保存(重置布局时使用) */
|
||||
const skipNextSaveRef = useRef(false);
|
||||
|
||||
// Persistent panel state | 持久化面板状态
|
||||
const [persistentPanelRects, setPersistentPanelRects] = useState<Map<string, DOMRect>>(new Map());
|
||||
const [visiblePersistentPanels, setVisiblePersistentPanels] = useState<Set<string>>(
|
||||
() => new Set(PERSISTENT_PANEL_IDS)
|
||||
);
|
||||
const [isAnyTabsetMaximized, setIsAnyTabsetMaximized] = useState(false);
|
||||
|
||||
const persistentPanels = useMemo(
|
||||
() => panels.filter((p) => PERSISTENT_PANEL_IDS.includes(p.id)),
|
||||
[panels]
|
||||
);
|
||||
|
||||
const createDefaultLayout = useCallback((): IJsonModel => {
|
||||
return LayoutBuilder.createDefaultLayout(panels, activePanelId);
|
||||
}, [panels, activePanelId]);
|
||||
|
||||
/**
|
||||
* Try to load saved layout and merge with current panels.
|
||||
* 尝试加载保存的布局并与当前面板合并。
|
||||
*/
|
||||
const loadSavedLayoutOrDefault = useCallback((): IJsonModel => {
|
||||
const savedLayout = loadLayoutFromStorage();
|
||||
if (savedLayout) {
|
||||
try {
|
||||
// Merge saved layout with current panels (handle new/removed panels)
|
||||
const defaultLayout = createDefaultLayout();
|
||||
const mergedLayout = LayoutMerger.merge(savedLayout, defaultLayout, panels);
|
||||
return mergedLayout;
|
||||
} catch (error) {
|
||||
console.warn('Failed to merge saved layout, using default:', error);
|
||||
}
|
||||
}
|
||||
return createDefaultLayout();
|
||||
}, [createDefaultLayout, panels]);
|
||||
|
||||
const [model, setModel] = useState<Model>(() => {
|
||||
try {
|
||||
return Model.fromJson(loadSavedLayoutOrDefault());
|
||||
} catch (error) {
|
||||
console.warn('Failed to load saved layout, using default:', error);
|
||||
return Model.fromJson(createDefaultLayout());
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Reset layout to default and clear saved layout.
|
||||
* 重置布局到默认状态并清除保存的布局。
|
||||
*/
|
||||
const resetLayout = useCallback(() => {
|
||||
clearLayoutStorage();
|
||||
skipNextSaveRef.current = true;
|
||||
previousLayoutJsonRef.current = null;
|
||||
previousPanelIdsRef.current = '';
|
||||
const defaultLayout = createDefaultLayout();
|
||||
setModel(Model.fromJson(defaultLayout));
|
||||
}, [createDefaultLayout]);
|
||||
|
||||
// Expose resetLayout method via ref | 通过 ref 暴露 resetLayout 方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
resetLayout
|
||||
}), [resetLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
// 检查面板ID列表是否真的变化了(而不只是标题等属性变化)
|
||||
const currentPanelIds = panels.map((p) => p.id).sort().join(',');
|
||||
const previousIds = previousPanelIdsRef.current;
|
||||
|
||||
// 检查标题是否变化
|
||||
const currentTitles = new Map(panels.map((p) => [p.id, p.title]));
|
||||
const titleChanges: Array<{ id: string; newTitle: string }> = [];
|
||||
|
||||
for (const panel of panels) {
|
||||
const previousTitle = previousPanelTitlesRef.current.get(panel.id);
|
||||
if (previousTitle && previousTitle !== panel.title) {
|
||||
titleChanges.push({ id: panel.id, newTitle: panel.title });
|
||||
}
|
||||
}
|
||||
|
||||
// 更新标题引用
|
||||
previousPanelTitlesRef.current = currentTitles;
|
||||
|
||||
// 如果只是标题变化,更新tab名称
|
||||
if (titleChanges.length > 0 && currentPanelIds === previousIds && model) {
|
||||
titleChanges.forEach(({ id, newTitle }) => {
|
||||
const node = model.getNodeById(id);
|
||||
if (node && node.getType() === 'tab') {
|
||||
model.doAction(Actions.renameTab(id, newTitle));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPanelIds === previousIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算新增和移除的面板
|
||||
const prevSet = new Set(previousIds.split(',').filter((id) => id));
|
||||
const currSet = new Set(currentPanelIds.split(',').filter((id) => id));
|
||||
const newPanelIds = Array.from(currSet).filter((id) => !prevSet.has(id));
|
||||
const removedPanelIds = Array.from(prevSet).filter((id) => !currSet.has(id));
|
||||
|
||||
previousPanelIdsRef.current = currentPanelIds;
|
||||
|
||||
// 如果已经有布局且只是添加新面板,使用Action动态添加
|
||||
// 检查新面板是否需要独立 tabset(如 bottom 位置的面板)
|
||||
// Check if new panels require separate tabset (e.g., bottom position panels)
|
||||
const newPanelsWithConfig = panels.filter((p) => newPanelIds.includes(p.id));
|
||||
const hasSpecialLayoutPanels = newPanelsWithConfig.some((p) =>
|
||||
p.layout?.requiresSeparateTabset || p.layout?.position === 'bottom'
|
||||
);
|
||||
if (model && newPanelIds.length > 0 && removedPanelIds.length === 0 && previousIds && !hasSpecialLayoutPanels) {
|
||||
// 找到要添加的面板
|
||||
const newPanels = panels.filter((p) => newPanelIds.includes(p.id));
|
||||
|
||||
// 构建面板位置映射 | Build panel position map
|
||||
const panelPositionMap = new Map(panels.map((p) => [p.id, p.layout?.position || 'center']));
|
||||
|
||||
// 找到中心区域的tabset ID | Find center tabset ID
|
||||
let centerTabsetId: string | null = null;
|
||||
|
||||
model.visitNodes((node: any) => {
|
||||
if (node.getType() === 'tabset') {
|
||||
const tabset = node as any;
|
||||
// 检查是否是中心tabset(包含 center 位置的面板)
|
||||
// Check if this is center tabset (contains center position panels)
|
||||
const children = tabset.getChildren();
|
||||
const hasCenterPanel = children.some((child: any) => {
|
||||
const id = child.getId();
|
||||
const position = panelPositionMap.get(id);
|
||||
return position === 'center' || position === undefined;
|
||||
});
|
||||
if (hasCenterPanel && !centerTabsetId) {
|
||||
centerTabsetId = tabset.getId();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (centerTabsetId) {
|
||||
// 动态添加tab到中心tabset
|
||||
newPanels.forEach((panel) => {
|
||||
model.doAction(Actions.addNode(
|
||||
{
|
||||
type: 'tab',
|
||||
name: panel.title,
|
||||
id: panel.id,
|
||||
component: panel.id,
|
||||
enableClose: panel.closable !== false
|
||||
},
|
||||
centerTabsetId!,
|
||||
DockLocation.CENTER,
|
||||
-1 // 添加到末尾
|
||||
));
|
||||
});
|
||||
|
||||
// 选中最后添加的面板
|
||||
const lastPanel = newPanels[newPanels.length - 1];
|
||||
if (lastPanel) {
|
||||
setTimeout(() => {
|
||||
const node = model.getNodeById(lastPanel.id);
|
||||
if (node) {
|
||||
model.doAction(Actions.selectTab(lastPanel.id));
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 否则完全重建布局
|
||||
const defaultLayout = createDefaultLayout();
|
||||
|
||||
// 如果有保存的布局,尝试合并
|
||||
// 注意:如果新面板需要特殊布局(独立 tabset),直接使用默认布局
|
||||
// Note: If new panels need special layout (separate tabset), use default layout directly
|
||||
if (previousLayoutJsonRef.current && previousIds && !hasSpecialLayoutPanels) {
|
||||
try {
|
||||
const savedLayout = JSON.parse(previousLayoutJsonRef.current);
|
||||
const mergedLayout = LayoutMerger.merge(savedLayout, defaultLayout, panels);
|
||||
const newModel = Model.fromJson(mergedLayout);
|
||||
setModel(newModel);
|
||||
return;
|
||||
} catch (error) {
|
||||
// 合并失败,使用默认布局
|
||||
}
|
||||
}
|
||||
|
||||
// 使用默认布局
|
||||
const newModel = Model.fromJson(defaultLayout);
|
||||
setModel(newModel);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to update layout model: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}, [createDefaultLayout, panels]);
|
||||
|
||||
/**
|
||||
* Track persistent panel positions and visibility.
|
||||
* Uses FlexLayout model to determine if panel tab is selected,
|
||||
* falls back to DOM measurement if model data unavailable.
|
||||
* 追踪持久化面板的位置和可见性。
|
||||
* 使用 FlexLayout 模型判断面板 tab 是否被选中,
|
||||
* 如果模型数据不可用则回退到 DOM 测量。
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!model) return;
|
||||
|
||||
const updatePersistentPanelPositions = () => {
|
||||
const newRects = new Map<string, DOMRect>();
|
||||
const newVisible = new Set<string>();
|
||||
|
||||
for (const panelId of PERSISTENT_PANEL_IDS) {
|
||||
// Try to get position from FlexLayout model
|
||||
const rect = getPanelRectFromModel(model, panelId);
|
||||
if (rect) {
|
||||
newRects.set(panelId, rect.domRect);
|
||||
if (rect.isSelected) {
|
||||
newVisible.add(panelId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fallback: measure placeholder element in DOM
|
||||
const placeholderRect = getPanelRectFromDOM(panelId);
|
||||
if (placeholderRect) {
|
||||
newRects.set(panelId, placeholderRect.domRect);
|
||||
if (placeholderRect.isVisible) {
|
||||
newVisible.add(panelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setPersistentPanelRects(newRects);
|
||||
setVisiblePersistentPanels(newVisible);
|
||||
};
|
||||
|
||||
// Initial update after DOM render
|
||||
requestAnimationFrame(updatePersistentPanelPositions);
|
||||
|
||||
// Observe layout changes
|
||||
const container = document.querySelector('.flexlayout-dock-container');
|
||||
if (!container) return;
|
||||
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
requestAnimationFrame(updatePersistentPanelPositions);
|
||||
});
|
||||
mutationObserver.observe(container, { childList: true, subtree: true, attributes: true });
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
requestAnimationFrame(updatePersistentPanelPositions);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [model]);
|
||||
|
||||
const factory = useCallback((node: TabNode) => {
|
||||
const componentId = node.getComponent() || '';
|
||||
|
||||
// Persistent panels render as placeholder, actual content is in overlay
|
||||
// 持久化面板渲染为占位符,实际内容在覆盖层中
|
||||
if (PERSISTENT_PANEL_IDS.includes(componentId)) {
|
||||
return <div className="persistent-panel-placeholder" data-panel-id={componentId} />;
|
||||
}
|
||||
|
||||
const panel = panels.find((p) => p.id === componentId);
|
||||
return panel?.content ?? <div>Panel not found</div>;
|
||||
}, [panels]);
|
||||
|
||||
const onAction = useCallback((action: Action) => {
|
||||
if (action.type === Actions.DELETE_TAB) {
|
||||
const tabId = (action.data as { node: string }).node;
|
||||
if (onPanelClose) {
|
||||
onPanelClose(tabId);
|
||||
}
|
||||
}
|
||||
return action;
|
||||
}, [onPanelClose]);
|
||||
|
||||
const onModelChange = useCallback((newModel: Model) => {
|
||||
// 保存布局状态以便在panels变化时恢复
|
||||
const layoutJson = newModel.toJson();
|
||||
previousLayoutJsonRef.current = JSON.stringify(layoutJson);
|
||||
|
||||
// Save to localStorage (unless skipped) | 保存到 localStorage(除非跳过)
|
||||
if (skipNextSaveRef.current) {
|
||||
skipNextSaveRef.current = false;
|
||||
} else {
|
||||
saveLayoutToStorage(layoutJson);
|
||||
}
|
||||
|
||||
// Check if any tabset is maximized
|
||||
let hasMaximized = false;
|
||||
newModel.visitNodes((node) => {
|
||||
if (node.getType() === 'tabset') {
|
||||
const tabset = node as TabSetNode;
|
||||
if (tabset.isMaximized()) {
|
||||
hasMaximized = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
setIsAnyTabsetMaximized(hasMaximized);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messageHub || !model) return;
|
||||
|
||||
const unsubscribe = messageHub.subscribe('panel:select', (data: { panelId: string }) => {
|
||||
const { panelId } = data;
|
||||
const node = model.getNodeById(panelId);
|
||||
if (node && node.getType() === 'tab') {
|
||||
model.doAction(Actions.selectTab(panelId));
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe?.();
|
||||
}, [messageHub, model]);
|
||||
|
||||
return (
|
||||
<div className="flexlayout-dock-container">
|
||||
<Layout
|
||||
ref={layoutRef}
|
||||
model={model}
|
||||
factory={factory}
|
||||
onAction={onAction}
|
||||
onModelChange={onModelChange}
|
||||
/>
|
||||
|
||||
{/* Persistent panel overlay - always mounted, visibility controlled by CSS */}
|
||||
{/* 持久化面板覆盖层 - 始终挂载,通过 CSS 控制可见性 */}
|
||||
{persistentPanels.map((panel) => (
|
||||
<PersistentPanelContainer
|
||||
key={panel.id}
|
||||
panel={panel}
|
||||
rect={persistentPanelRects.get(panel.id)}
|
||||
isVisible={visiblePersistentPanels.has(panel.id)}
|
||||
isMaximized={isAnyTabsetMaximized}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Container for persistent panel content.
|
||||
* 持久化面板内容容器。
|
||||
*/
|
||||
function PersistentPanelContainer({
|
||||
panel,
|
||||
rect,
|
||||
isVisible,
|
||||
isMaximized
|
||||
}: {
|
||||
panel: FlexDockPanel;
|
||||
rect?: DOMRect;
|
||||
isVisible: boolean;
|
||||
isMaximized: boolean;
|
||||
}) {
|
||||
const hasValidRect = rect && rect.width > 0 && rect.height > 0;
|
||||
|
||||
// Hide persistent panel completely when another tabset is maximized
|
||||
// (unless this panel itself is in the maximized tabset)
|
||||
const shouldHide = isMaximized && !isVisible;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="persistent-panel-container"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: hasValidRect ? rect.x : 0,
|
||||
top: hasValidRect ? rect.y : 0,
|
||||
width: hasValidRect ? rect.width : '100%',
|
||||
height: hasValidRect ? rect.height : '100%',
|
||||
visibility: (isVisible && !shouldHide) ? 'visible' : 'hidden',
|
||||
pointerEvents: (isVisible && !shouldHide) ? 'auto' : 'none',
|
||||
// 使用较低的 z-index,确保不会遮挡 FlexLayout 的 tab bar
|
||||
zIndex: 0,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{panel.content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useState } from 'react';
|
||||
import { Github, AlertCircle, CheckCircle, Loader, ExternalLink } from 'lucide-react';
|
||||
import { GitHubService } from '../services/GitHubService';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import '../styles/GitHubAuth.css';
|
||||
|
||||
interface GitHubAuthProps {
|
||||
githubService: GitHubService;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function GitHubAuth({ githubService, onSuccess }: GitHubAuthProps) {
|
||||
const { t } = useLocale();
|
||||
const [useOAuth, setUseOAuth] = useState(true);
|
||||
const [githubToken, setGithubToken] = useState('');
|
||||
const [userCode, setUserCode] = useState('');
|
||||
const [verificationUri, setVerificationUri] = useState('');
|
||||
const [authStatus, setAuthStatus] = useState<'idle' | 'pending' | 'authorized' | 'error'>('idle');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleOAuthLogin = async () => {
|
||||
setAuthStatus('pending');
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const deviceCodeResp = await githubService.requestDeviceCode();
|
||||
|
||||
setUserCode(deviceCodeResp.user_code);
|
||||
setVerificationUri(deviceCodeResp.verification_uri);
|
||||
|
||||
await open(deviceCodeResp.verification_uri);
|
||||
|
||||
await githubService.authenticateWithDeviceFlow(
|
||||
deviceCodeResp.device_code,
|
||||
deviceCodeResp.interval,
|
||||
(status) => {
|
||||
setAuthStatus(status === 'pending' ? 'pending' : status === 'authorized' ? 'authorized' : 'error');
|
||||
}
|
||||
);
|
||||
|
||||
setAuthStatus('authorized');
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error('[GitHubAuth] OAuth failed:', err);
|
||||
setAuthStatus('error');
|
||||
const errorMessage = err instanceof Error ? err.message : 'OAuth authorization failed';
|
||||
const fullError = err instanceof Error && err.stack ? `${errorMessage}\n\nDetails: ${err.stack}` : errorMessage;
|
||||
setError(fullError);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTokenAuth = async () => {
|
||||
if (!githubToken.trim()) {
|
||||
setError(t('github.enterToken'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await githubService.authenticate(githubToken);
|
||||
setError('');
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error('[GitHubAuth] Token auth failed:', err);
|
||||
setError(t('github.authFailedToken'));
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateTokenPage = async () => {
|
||||
await githubService.openAuthorizationPage();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="github-auth">
|
||||
<Github size={48} style={{ color: '#0366d6' }} />
|
||||
<p>{t('github.githubLogin')}</p>
|
||||
|
||||
<div className="auth-tabs">
|
||||
<button
|
||||
className={`auth-tab ${useOAuth ? 'active' : ''}`}
|
||||
onClick={() => setUseOAuth(true)}
|
||||
>
|
||||
{t('github.oauthLogin')}
|
||||
</button>
|
||||
<button
|
||||
className={`auth-tab ${!useOAuth ? 'active' : ''}`}
|
||||
onClick={() => setUseOAuth(false)}
|
||||
>
|
||||
{t('github.tokenLogin')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{useOAuth ? (
|
||||
<div className="oauth-auth">
|
||||
{authStatus === 'idle' && (
|
||||
<>
|
||||
<div className="oauth-instructions">
|
||||
<p>{t('github.oauthStep1')}</p>
|
||||
<p>{t('github.oauthStep2')}</p>
|
||||
<p>{t('github.oauthStep3')}</p>
|
||||
</div>
|
||||
|
||||
<button className="btn-primary" onClick={handleOAuthLogin}>
|
||||
<Github size={16} />
|
||||
{t('github.startAuth')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{authStatus === 'pending' && (
|
||||
<div className="oauth-pending">
|
||||
<Loader size={48} className="spinning" style={{ color: '#0366d6' }} />
|
||||
<h4>{t('github.authorizing')}</h4>
|
||||
|
||||
{userCode && (
|
||||
<div className="user-code-display">
|
||||
<label>{t('github.userCode')}</label>
|
||||
<div className="code-box">
|
||||
<span className="code-text">{userCode}</span>
|
||||
<button
|
||||
className="btn-copy"
|
||||
onClick={() => copyToClipboard(userCode)}
|
||||
title={t('github.copyCode')}
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={() => open(verificationUri)}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('github.openBrowser')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authStatus === 'authorized' && (
|
||||
<div className="oauth-success">
|
||||
<CheckCircle size={48} style={{ color: '#34c759' }} />
|
||||
<h4>{t('github.authorized')}</h4>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authStatus === 'error' && (
|
||||
<div className="oauth-error">
|
||||
<AlertCircle size={48} style={{ color: '#ff3b30' }} />
|
||||
<h4>{t('github.authFailed')}</h4>
|
||||
{error && (
|
||||
<div className="error-details">
|
||||
<pre>{error}</pre>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn-secondary" onClick={() => setAuthStatus('idle')}>
|
||||
{t('github.back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="token-auth">
|
||||
<div className="form-group">
|
||||
<label>{t('github.tokenLabel')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={githubToken}
|
||||
onChange={(e) => setGithubToken(e.target.value)}
|
||||
placeholder={t('github.tokenPlaceholder')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleTokenAuth();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<small>{t('github.tokenHint')}</small>
|
||||
</div>
|
||||
|
||||
<button className="btn-link" onClick={openCreateTokenPage}>
|
||||
<ExternalLink size={14} />
|
||||
{t('github.createToken')}
|
||||
</button>
|
||||
|
||||
<button className="btn-primary" onClick={handleTokenAuth}>
|
||||
{t('github.login')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !useOAuth && (
|
||||
<div className="error-message">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { GitHubService } from '../services/GitHubService';
|
||||
import { GitHubAuth } from './GitHubAuth';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import '../styles/GitHubLoginDialog.css';
|
||||
|
||||
interface GitHubLoginDialogProps {
|
||||
githubService: GitHubService;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GitHubLoginDialog({ githubService, onClose }: GitHubLoginDialogProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="github-login-overlay" onClick={onClose}>
|
||||
<div className="github-login-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="github-login-header">
|
||||
<h2>{t('github.title')}</h2>
|
||||
<button className="github-login-close" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="github-login-content">
|
||||
<GitHubAuth
|
||||
githubService={githubService}
|
||||
onSuccess={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
SkipForward,
|
||||
Save,
|
||||
FolderOpen,
|
||||
Undo2,
|
||||
Redo2,
|
||||
Eye,
|
||||
Globe,
|
||||
QrCode,
|
||||
ChevronDown
|
||||
} from 'lucide-react';
|
||||
import type { MessageHub, CommandManager } from '@esengine/editor-core';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import '../styles/MainToolbar.css';
|
||||
|
||||
export type PlayState = 'stopped' | 'playing' | 'paused';
|
||||
|
||||
interface MainToolbarProps {
|
||||
messageHub?: MessageHub;
|
||||
commandManager?: CommandManager;
|
||||
onSaveScene?: () => void;
|
||||
onOpenScene?: () => void;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
onPlay?: () => void;
|
||||
onPause?: () => void;
|
||||
onStop?: () => void;
|
||||
onStep?: () => void;
|
||||
onRunInBrowser?: () => void;
|
||||
onRunOnDevice?: () => void;
|
||||
}
|
||||
|
||||
interface ToolButtonProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function ToolButton({ icon, label, active, disabled, onClick }: ToolButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`toolbar-button ${active ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolSeparator() {
|
||||
return <div className="toolbar-separator" />;
|
||||
}
|
||||
|
||||
export function MainToolbar({
|
||||
messageHub,
|
||||
commandManager,
|
||||
onSaveScene,
|
||||
onOpenScene,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPlay,
|
||||
onPause,
|
||||
onStop,
|
||||
onStep,
|
||||
onRunInBrowser,
|
||||
onRunOnDevice
|
||||
}: MainToolbarProps) {
|
||||
const { t } = useLocale();
|
||||
const [playState, setPlayState] = useState<PlayState>('stopped');
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
const [showRunMenu, setShowRunMenu] = useState(false);
|
||||
const runMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close run menu when clicking outside
|
||||
useEffect(() => {
|
||||
if (!showRunMenu) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (runMenuRef.current && !runMenuRef.current.contains(e.target as Node)) {
|
||||
setShowRunMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
}, 10);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
}, [showRunMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (commandManager) {
|
||||
const updateUndoRedo = () => {
|
||||
setCanUndo(commandManager.canUndo());
|
||||
setCanRedo(commandManager.canRedo());
|
||||
};
|
||||
updateUndoRedo();
|
||||
|
||||
if (messageHub) {
|
||||
const unsubscribe = messageHub.subscribe('command:executed', updateUndoRedo);
|
||||
return () => unsubscribe();
|
||||
}
|
||||
}
|
||||
}, [commandManager, messageHub]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messageHub) {
|
||||
const unsubscribePlay = messageHub.subscribe('preview:started', () => {
|
||||
setPlayState('playing');
|
||||
});
|
||||
const unsubscribePause = messageHub.subscribe('preview:paused', () => {
|
||||
setPlayState('paused');
|
||||
});
|
||||
const unsubscribeStop = messageHub.subscribe('preview:stopped', () => {
|
||||
setPlayState('stopped');
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribePlay();
|
||||
unsubscribePause();
|
||||
unsubscribeStop();
|
||||
};
|
||||
}
|
||||
}, [messageHub]);
|
||||
|
||||
const handlePlay = () => {
|
||||
if (playState === 'stopped' || playState === 'paused') {
|
||||
onPlay?.();
|
||||
messageHub?.publish('preview:start', {});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
if (playState === 'playing') {
|
||||
onPause?.();
|
||||
messageHub?.publish('preview:pause', {});
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (playState !== 'stopped') {
|
||||
onStop?.();
|
||||
messageHub?.publish('preview:stop', {});
|
||||
}
|
||||
};
|
||||
|
||||
const handleStep = () => {
|
||||
onStep?.();
|
||||
messageHub?.publish('preview:step', {});
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
if (commandManager?.canUndo()) {
|
||||
commandManager.undo();
|
||||
onUndo?.();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRedo = () => {
|
||||
if (commandManager?.canRedo()) {
|
||||
commandManager.redo();
|
||||
onRedo?.();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunInBrowser = () => {
|
||||
setShowRunMenu(false);
|
||||
onRunInBrowser?.();
|
||||
messageHub?.publish('viewport:run-in-browser', {});
|
||||
};
|
||||
|
||||
const handleRunOnDevice = () => {
|
||||
setShowRunMenu(false);
|
||||
onRunOnDevice?.();
|
||||
messageHub?.publish('viewport:run-on-device', {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-toolbar">
|
||||
{/* File Operations */}
|
||||
<div className="toolbar-group">
|
||||
<ToolButton
|
||||
icon={<Save size={16} />}
|
||||
label={t('toolbar.save')}
|
||||
onClick={onSaveScene}
|
||||
/>
|
||||
<ToolButton
|
||||
icon={<FolderOpen size={16} />}
|
||||
label={t('toolbar.open')}
|
||||
onClick={onOpenScene}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ToolSeparator />
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<div className="toolbar-group">
|
||||
<ToolButton
|
||||
icon={<Undo2 size={16} />}
|
||||
label={t('toolbar.undo')}
|
||||
disabled={!canUndo}
|
||||
onClick={handleUndo}
|
||||
/>
|
||||
<ToolButton
|
||||
icon={<Redo2 size={16} />}
|
||||
label={t('toolbar.redo')}
|
||||
disabled={!canRedo}
|
||||
onClick={handleRedo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Play Controls - Absolutely Centered */}
|
||||
<div className="toolbar-center-wrapper">
|
||||
<div className="toolbar-group toolbar-center">
|
||||
<ToolButton
|
||||
icon={playState === 'playing' ? <Pause size={18} /> : <Play size={18} />}
|
||||
label={playState === 'playing' ? t('toolbar.pause') : t('toolbar.play')}
|
||||
onClick={playState === 'playing' ? handlePause : handlePlay}
|
||||
/>
|
||||
<ToolButton
|
||||
icon={<Square size={16} />}
|
||||
label={t('toolbar.stop')}
|
||||
disabled={playState === 'stopped'}
|
||||
onClick={handleStop}
|
||||
/>
|
||||
<ToolButton
|
||||
icon={<SkipForward size={16} />}
|
||||
label={t('toolbar.step')}
|
||||
disabled={playState === 'playing'}
|
||||
onClick={handleStep}
|
||||
/>
|
||||
|
||||
<ToolSeparator />
|
||||
|
||||
{/* Run Options Dropdown */}
|
||||
<div className="toolbar-dropdown" ref={runMenuRef}>
|
||||
<button
|
||||
className="toolbar-button toolbar-dropdown-trigger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowRunMenu(prev => !prev);
|
||||
}}
|
||||
title={t('toolbar.runOptions')}
|
||||
type="button"
|
||||
>
|
||||
<Globe size={16} />
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{showRunMenu && (
|
||||
<div className="toolbar-dropdown-menu">
|
||||
<button type="button" onClick={handleRunInBrowser}>
|
||||
<Globe size={14} />
|
||||
<span>{t('toolbar.runInBrowser')}</span>
|
||||
</button>
|
||||
<button type="button" onClick={handleRunOnDevice}>
|
||||
<QrCode size={14} />
|
||||
<span>{t('toolbar.runOnDevice')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Mode Indicator - Right aligned */}
|
||||
<div className="toolbar-right">
|
||||
{playState !== 'stopped' && (
|
||||
<div className="preview-indicator">
|
||||
<Eye size={14} />
|
||||
<span>{t('toolbar.preview')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { UIRegistry, MessageHub, PluginManager } from '@esengine/editor-core';
|
||||
import type { MenuItem as PluginMenuItem } from '@esengine/editor-core';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import '../styles/MenuBar.css';
|
||||
|
||||
interface MenuItem {
|
||||
label?: string;
|
||||
shortcut?: string;
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
separator?: boolean;
|
||||
submenu?: MenuItem[];
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface MenuBarProps {
|
||||
uiRegistry?: UIRegistry;
|
||||
messageHub?: MessageHub;
|
||||
pluginManager?: PluginManager;
|
||||
onNewScene?: () => void;
|
||||
onOpenScene?: () => void;
|
||||
onSaveScene?: () => void;
|
||||
onSaveSceneAs?: () => void;
|
||||
onOpenProject?: () => void;
|
||||
onCloseProject?: () => void;
|
||||
onExit?: () => void;
|
||||
onOpenPluginManager?: () => void;
|
||||
onOpenProfiler?: () => void;
|
||||
onOpenPortManager?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
onToggleDevtools?: () => void;
|
||||
onOpenAbout?: () => void;
|
||||
onCreatePlugin?: () => void;
|
||||
onReloadPlugins?: () => void;
|
||||
onOpenBuildSettings?: () => void;
|
||||
}
|
||||
|
||||
export function MenuBar({
|
||||
uiRegistry,
|
||||
messageHub,
|
||||
pluginManager,
|
||||
onNewScene,
|
||||
onOpenScene,
|
||||
onSaveScene,
|
||||
onSaveSceneAs,
|
||||
onOpenProject,
|
||||
onCloseProject,
|
||||
onExit,
|
||||
onOpenPluginManager,
|
||||
onOpenProfiler: _onOpenProfiler,
|
||||
onOpenPortManager,
|
||||
onOpenSettings,
|
||||
onToggleDevtools,
|
||||
onOpenAbout,
|
||||
onCreatePlugin,
|
||||
onReloadPlugins,
|
||||
onOpenBuildSettings
|
||||
}: MenuBarProps) {
|
||||
const { t } = useLocale();
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateMenuItems = () => {
|
||||
if (uiRegistry) {
|
||||
const items = uiRegistry.getChildMenus('window');
|
||||
setPluginMenuItems(items);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateMenuItems();
|
||||
}, [uiRegistry, pluginManager]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messageHub) {
|
||||
const unsubscribeInstalled = messageHub.subscribe('plugin:installed', () => {
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
const unsubscribeEnabled = messageHub.subscribe('plugin:enabled', () => {
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
const unsubscribeDisabled = messageHub.subscribe('plugin:disabled', () => {
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeInstalled();
|
||||
unsubscribeEnabled();
|
||||
unsubscribeDisabled();
|
||||
};
|
||||
}
|
||||
}, [messageHub, uiRegistry, pluginManager]);
|
||||
|
||||
const menus: Record<string, MenuItem[]> = {
|
||||
file: [
|
||||
{ label: t('menu.file.newScene'), shortcut: 'Ctrl+N', onClick: onNewScene },
|
||||
{ label: t('menu.file.openScene'), shortcut: 'Ctrl+O', onClick: onOpenScene },
|
||||
{ separator: true },
|
||||
{ label: t('menu.file.saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene },
|
||||
{ label: t('menu.file.saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs },
|
||||
{ separator: true },
|
||||
{ label: t('menu.file.buildSettings'), shortcut: 'Ctrl+Shift+B', onClick: onOpenBuildSettings },
|
||||
{ separator: true },
|
||||
{ label: t('menu.file.openProject'), onClick: onOpenProject },
|
||||
{ label: t('menu.file.closeProject'), onClick: onCloseProject },
|
||||
{ separator: true },
|
||||
{ label: t('menu.file.exit'), onClick: onExit }
|
||||
],
|
||||
edit: [
|
||||
{ label: t('menu.edit.undo'), shortcut: 'Ctrl+Z', disabled: true },
|
||||
{ label: t('menu.edit.redo'), shortcut: 'Ctrl+Y', disabled: true },
|
||||
{ separator: true },
|
||||
{ label: t('menu.edit.cut'), shortcut: 'Ctrl+X', disabled: true },
|
||||
{ label: t('menu.edit.copy'), shortcut: 'Ctrl+C', disabled: true },
|
||||
{ label: t('menu.edit.paste'), shortcut: 'Ctrl+V', disabled: true },
|
||||
{ label: t('menu.edit.delete'), shortcut: 'Delete', disabled: true },
|
||||
{ separator: true },
|
||||
{ label: t('menu.edit.selectAll'), shortcut: 'Ctrl+A', disabled: true }
|
||||
],
|
||||
window: [
|
||||
...pluginMenuItems.map((item) => ({
|
||||
label: item.label || '',
|
||||
icon: item.icon,
|
||||
disabled: item.disabled,
|
||||
onClick: item.onClick
|
||||
})),
|
||||
...(pluginMenuItems.length > 0 ? [{ separator: true } as MenuItem] : []),
|
||||
{ label: t('menu.window.pluginManager'), onClick: onOpenPluginManager },
|
||||
{ separator: true },
|
||||
{ label: t('menu.window.devtools'), onClick: onToggleDevtools }
|
||||
],
|
||||
tools: [
|
||||
{ label: t('menu.tools.createPlugin'), onClick: onCreatePlugin },
|
||||
{ label: t('menu.tools.reloadPlugins'), shortcut: 'Ctrl+R', onClick: onReloadPlugins },
|
||||
{ separator: true },
|
||||
{ label: t('menu.tools.portManager'), onClick: onOpenPortManager },
|
||||
{ separator: true },
|
||||
{ label: t('menu.tools.settings'), onClick: onOpenSettings }
|
||||
],
|
||||
help: [
|
||||
{ label: t('menu.help.documentation'), disabled: true },
|
||||
{ separator: true },
|
||||
{ label: t('menu.help.about'), onClick: onOpenAbout }
|
||||
]
|
||||
};
|
||||
|
||||
// 菜单键到翻译键的映射 | Map menu keys to translation keys
|
||||
const menuTitleKeys: Record<string, string> = {
|
||||
file: 'menu.file.title',
|
||||
edit: 'menu.edit.title',
|
||||
window: 'menu.window.title',
|
||||
tools: 'menu.tools.title',
|
||||
help: 'menu.help.title'
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpenMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMenuClick = (menuKey: string) => {
|
||||
setOpenMenu(openMenu === menuKey ? null : menuKey);
|
||||
};
|
||||
|
||||
const handleMenuItemClick = (item: MenuItem) => {
|
||||
if (!item.disabled && !item.separator && item.onClick && item.label) {
|
||||
item.onClick();
|
||||
setOpenMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="menu-bar" ref={menuRef}>
|
||||
{Object.keys(menus).map((menuKey) => (
|
||||
<div key={menuKey} className="menu-item">
|
||||
<button
|
||||
className={`menu-button ${openMenu === menuKey ? 'active' : ''}`}
|
||||
onClick={() => handleMenuClick(menuKey)}
|
||||
>
|
||||
{t(menuTitleKeys[menuKey] || menuKey)}
|
||||
</button>
|
||||
{openMenu === menuKey && menus[menuKey] && (
|
||||
<div className="menu-dropdown">
|
||||
{menus[menuKey].map((item, index) => {
|
||||
if (item.separator) {
|
||||
return <div key={index} className="menu-separator" />;
|
||||
}
|
||||
const IconComponent = item.icon ? (LucideIcons as any)[item.icon] : null;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={`menu-dropdown-item ${item.disabled ? 'disabled' : ''}`}
|
||||
onClick={() => handleMenuItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<span className="menu-item-content">
|
||||
{IconComponent && <IconComponent size={16} />}
|
||||
<span>{item.label || ''}</span>
|
||||
</span>
|
||||
{item.shortcut && <span className="menu-shortcut">{item.shortcut}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
size: number;
|
||||
alpha: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface MiniParticleLogoProps {
|
||||
/** Logo text to display / 要显示的Logo文字 */
|
||||
text?: string;
|
||||
/** Canvas width / 画布宽度 */
|
||||
width?: number;
|
||||
/** Canvas height / 画布高度 */
|
||||
height?: number;
|
||||
/** Font size / 字体大小 */
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mini Particle Logo Component
|
||||
* 小型粒子Logo组件 - 用于About对话框等小空间显示
|
||||
*/
|
||||
export function MiniParticleLogo({
|
||||
text = 'ECS',
|
||||
width = 80,
|
||||
height = 80,
|
||||
fontSize = 28
|
||||
}: MiniParticleLogoProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number | null>(null);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
|
||||
const createParticles = useCallback((
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
displayText: string,
|
||||
textSize: number
|
||||
): Particle[] => {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
if (!tempCtx) return [];
|
||||
|
||||
tempCtx.font = `bold ${textSize}px "Segoe UI", Arial, sans-serif`;
|
||||
const textMetrics = tempCtx.measureText(displayText);
|
||||
const textWidth = textMetrics.width;
|
||||
const textHeight = textSize;
|
||||
|
||||
tempCanvas.width = textWidth + 10;
|
||||
tempCanvas.height = textHeight + 10;
|
||||
tempCtx.font = `bold ${textSize}px "Segoe UI", Arial, sans-serif`;
|
||||
tempCtx.textAlign = 'center';
|
||||
tempCtx.textBaseline = 'middle';
|
||||
tempCtx.fillStyle = '#ffffff';
|
||||
tempCtx.fillText(displayText, tempCanvas.width / 2, tempCanvas.height / 2);
|
||||
|
||||
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
||||
const pixels = imageData.data;
|
||||
const particles: Particle[] = [];
|
||||
const gap = 2; // 小间隔以增加粒子密度 / Small gap for higher particle density
|
||||
|
||||
const offsetX = (canvasWidth - tempCanvas.width) / 2;
|
||||
const offsetY = (canvasHeight - tempCanvas.height) / 2;
|
||||
|
||||
const colors = ['#569CD6', '#4EC9B0', '#9CDCFE', '#C586C0', '#DCDCAA'];
|
||||
|
||||
for (let y = 0; y < tempCanvas.height; y += gap) {
|
||||
for (let x = 0; x < tempCanvas.width; x += gap) {
|
||||
const index = (y * tempCanvas.width + x) * 4;
|
||||
const alpha = pixels[index + 3] ?? 0;
|
||||
|
||||
if (alpha > 128) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const distance = Math.random() * Math.max(canvasWidth, canvasHeight) * 0.8;
|
||||
|
||||
particles.push({
|
||||
x: canvasWidth / 2 + Math.cos(angle) * distance,
|
||||
y: canvasHeight / 2 + Math.sin(angle) * distance,
|
||||
targetX: offsetX + x,
|
||||
targetY: offsetY + y,
|
||||
size: Math.random() * 1 + 0.8,
|
||||
alpha: Math.random() * 0.5 + 0.5,
|
||||
color: colors[Math.floor(Math.random() * colors.length)] ?? '#569CD6'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return particles;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
particlesRef.current = createParticles(width, height, text, fontSize);
|
||||
|
||||
const startTime = performance.now();
|
||||
const duration = 1500; // 动画持续时间 / Animation duration
|
||||
let isCancelled = false;
|
||||
|
||||
const easeOutQuart = (t: number) => 1 - Math.pow(1 - t, 4);
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
if (isCancelled) return;
|
||||
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const easedProgress = easeOutQuart(progress);
|
||||
|
||||
// 透明背景 / Transparent background
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
for (const particle of particlesRef.current) {
|
||||
const moveProgress = Math.min(easedProgress * 1.2, 1);
|
||||
const currentX = particle.x + (particle.targetX - particle.x) * moveProgress;
|
||||
const currentY = particle.y + (particle.targetY - particle.y) * moveProgress;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(currentX, currentY, particle.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = particle.color;
|
||||
ctx.globalAlpha = particle.alpha;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// 动画完成后添加微光效果 / Add subtle glow after animation completes
|
||||
if (progress >= 1) {
|
||||
const glowAlpha = 0.3 + Math.sin(currentTime / 500) * 0.1;
|
||||
ctx.save();
|
||||
ctx.shadowColor = '#4EC9B0';
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${glowAlpha})`;
|
||||
ctx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, width / 2, height / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
if (animationRef.current !== null) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [width, height, text, fontSize, createParticles]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
display: 'block',
|
||||
borderRadius: '16px'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Module List Setting Component.
|
||||
* 模块列表设置组件。
|
||||
*
|
||||
* Renders a list of engine modules with checkboxes to enable/disable.
|
||||
* 渲染引擎模块列表,带复选框以启用/禁用。
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { ChevronDown, ChevronRight, Package, AlertCircle } from 'lucide-react';
|
||||
import type { ModuleManifest, ModuleCategory } from '@esengine/editor-core';
|
||||
import './styles/ModuleListSetting.css';
|
||||
|
||||
/**
|
||||
* Module entry with enabled state.
|
||||
* 带启用状态的模块条目。
|
||||
*/
|
||||
interface ModuleEntry extends ModuleManifest {
|
||||
enabled: boolean;
|
||||
canDisable: boolean;
|
||||
disableReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for ModuleListSetting.
|
||||
*/
|
||||
interface ModuleListSettingProps {
|
||||
/** Module manifests (static) | 模块清单列表(静态) */
|
||||
modules?: ModuleManifest[];
|
||||
/** Function to get modules dynamically (sizes from module.json) | 动态获取模块的函数(大小来自 module.json) */
|
||||
getModules?: () => ModuleManifest[];
|
||||
/**
|
||||
* Module IDs list. Meaning depends on useBlacklist.
|
||||
* 模块 ID 列表。含义取决于 useBlacklist。
|
||||
* - useBlacklist=false: enabled modules (whitelist)
|
||||
* - useBlacklist=true: disabled modules (blacklist)
|
||||
*/
|
||||
value: string[];
|
||||
/** Callback when modules change | 模块变更回调 */
|
||||
onModulesChange: (moduleIds: string[]) => void;
|
||||
/**
|
||||
* Use blacklist mode: value contains disabled modules instead of enabled.
|
||||
* 使用黑名单模式:value 包含禁用的模块而不是启用的。
|
||||
* Default: false (whitelist mode)
|
||||
*/
|
||||
useBlacklist?: boolean;
|
||||
/** Validate if module can be disabled | 验证模块是否可禁用 */
|
||||
validateDisable?: (moduleId: string) => Promise<{ canDisable: boolean; reason?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable string.
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module List Setting Component.
|
||||
* 模块列表设置组件。
|
||||
*/
|
||||
export const ModuleListSetting: React.FC<ModuleListSettingProps> = ({
|
||||
modules: staticModules,
|
||||
getModules,
|
||||
value,
|
||||
onModulesChange,
|
||||
useBlacklist = false,
|
||||
validateDisable
|
||||
}) => {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['Core', 'Rendering']));
|
||||
const [validationError, setValidationError] = useState<{ moduleId: string; message: string } | null>(null);
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
|
||||
// Get modules from function or static prop
|
||||
// 从函数或静态 prop 获取模块
|
||||
const modules = useMemo(() => {
|
||||
if (getModules) {
|
||||
return getModules();
|
||||
}
|
||||
return staticModules || [];
|
||||
}, [getModules, staticModules]);
|
||||
|
||||
// Build module entries with enabled state | 构建带启用状态的模块条目
|
||||
// In blacklist mode: enabled = NOT in value list
|
||||
// In whitelist mode: enabled = IN value list
|
||||
const moduleEntries: ModuleEntry[] = useMemo(() => {
|
||||
return modules.map(mod => {
|
||||
let enabled: boolean;
|
||||
if (mod.isCore) {
|
||||
enabled = true; // Core modules always enabled
|
||||
} else if (useBlacklist) {
|
||||
enabled = !value.includes(mod.id); // Blacklist: NOT in list = enabled
|
||||
} else {
|
||||
enabled = value.includes(mod.id); // Whitelist: IN list = enabled
|
||||
}
|
||||
return {
|
||||
...mod,
|
||||
enabled,
|
||||
canDisable: !mod.isCore,
|
||||
disableReason: mod.isCore ? 'Core module cannot be disabled' : undefined
|
||||
};
|
||||
});
|
||||
}, [modules, value, useBlacklist]);
|
||||
|
||||
// Group by category | 按分类分组
|
||||
const groupedModules = useMemo(() => {
|
||||
const groups = new Map<string, ModuleEntry[]>();
|
||||
const categoryOrder: ModuleCategory[] = ['Core', 'Rendering', 'Physics', 'AI', 'Audio', 'Networking', 'Other'];
|
||||
|
||||
// Initialize groups | 初始化分组
|
||||
for (const cat of categoryOrder) {
|
||||
groups.set(cat, []);
|
||||
}
|
||||
|
||||
// Group modules | 分组模块
|
||||
for (const mod of moduleEntries) {
|
||||
const cat = mod.category || 'Other';
|
||||
if (!groups.has(cat)) {
|
||||
groups.set(cat, []);
|
||||
}
|
||||
groups.get(cat)!.push(mod);
|
||||
}
|
||||
|
||||
// Filter empty groups | 过滤空分组
|
||||
const result = new Map<string, ModuleEntry[]>();
|
||||
for (const [cat, mods] of groups) {
|
||||
if (mods.length > 0) {
|
||||
result.set(cat, mods);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [moduleEntries]);
|
||||
|
||||
// Calculate total size (JS + WASM) | 计算总大小(JS + WASM)
|
||||
const { totalJsSize, totalWasmSize, totalSize } = useMemo(() => {
|
||||
let js = 0;
|
||||
let wasm = 0;
|
||||
for (const m of moduleEntries) {
|
||||
if (m.enabled) {
|
||||
js += m.jsSize || 0;
|
||||
wasm += m.wasmSize || 0;
|
||||
}
|
||||
}
|
||||
return { totalJsSize: js, totalWasmSize: wasm, totalSize: js + wasm };
|
||||
}, [moduleEntries]);
|
||||
|
||||
// Toggle category expansion | 切换分类展开
|
||||
const toggleCategory = useCallback((category: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) {
|
||||
next.delete(category);
|
||||
} else {
|
||||
next.add(category);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle module toggle | 处理模块切换
|
||||
const handleModuleToggle = useCallback(async (module: ModuleEntry, enabled: boolean) => {
|
||||
if (module.isCore) return;
|
||||
|
||||
// If disabling, validate first | 如果禁用,先验证
|
||||
if (!enabled && validateDisable) {
|
||||
setLoading(module.id);
|
||||
try {
|
||||
const result = await validateDisable(module.id);
|
||||
if (!result.canDisable) {
|
||||
setValidationError({
|
||||
moduleId: module.id,
|
||||
message: result.reason || `Cannot disable ${module.displayName}`
|
||||
});
|
||||
setLoading(null);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Update module list based on mode
|
||||
let newValue: string[];
|
||||
|
||||
if (useBlacklist) {
|
||||
// Blacklist mode: value contains disabled modules
|
||||
if (enabled) {
|
||||
// Remove from blacklist (and also remove dependencies)
|
||||
const toRemove = new Set([module.id]);
|
||||
// Also enable dependencies if they were disabled
|
||||
for (const depId of module.dependencies) {
|
||||
toRemove.add(depId);
|
||||
}
|
||||
newValue = value.filter(id => !toRemove.has(id));
|
||||
} else {
|
||||
// Add to blacklist
|
||||
newValue = [...value, module.id];
|
||||
}
|
||||
} else {
|
||||
// Whitelist mode: value contains enabled modules
|
||||
if (enabled) {
|
||||
// Add to whitelist (and dependencies)
|
||||
newValue = [...value];
|
||||
const toEnable = [module.id, ...module.dependencies];
|
||||
for (const id of toEnable) {
|
||||
if (!newValue.includes(id)) {
|
||||
newValue.push(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remove from whitelist
|
||||
newValue = value.filter(id => id !== module.id);
|
||||
}
|
||||
}
|
||||
|
||||
onModulesChange(newValue);
|
||||
}, [value, useBlacklist, onModulesChange, validateDisable]);
|
||||
|
||||
return (
|
||||
<div className="module-list-setting">
|
||||
{/* Module categories | 模块分类 */}
|
||||
<div className="module-list-categories">
|
||||
{Array.from(groupedModules.entries()).map(([category, mods]) => (
|
||||
<div key={category} className="module-category-group">
|
||||
<div
|
||||
className="module-category-header"
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
{expandedCategories.has(category) ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
<span className="module-category-name">{category}</span>
|
||||
<span className="module-category-count">
|
||||
{mods.filter(m => m.enabled).length}/{mods.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedCategories.has(category) && (
|
||||
<div className="module-category-items">
|
||||
{mods.map(mod => (
|
||||
<div
|
||||
key={mod.id}
|
||||
className={`module-item ${mod.enabled ? 'enabled' : ''} ${loading === mod.id ? 'loading' : ''}`}
|
||||
>
|
||||
<label className="module-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mod.enabled}
|
||||
disabled={mod.isCore || loading === mod.id}
|
||||
onChange={(e) => handleModuleToggle(mod, e.target.checked)}
|
||||
/>
|
||||
<Package size={14} className="module-icon" />
|
||||
<span className="module-name">{mod.displayName}</span>
|
||||
{mod.isCore && (
|
||||
<span className="module-badge core">Core</span>
|
||||
)}
|
||||
</label>
|
||||
{(mod.jsSize || mod.wasmSize) ? (
|
||||
<span className="module-size">
|
||||
{mod.isCore ? '' : '+'}
|
||||
{formatBytes((mod.jsSize || 0) + (mod.wasmSize || 0))}
|
||||
{(mod.wasmSize ?? 0) > 0 && (
|
||||
<span className="module-wasm-indicator" title="Includes WASM">⚡</span>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Size footer | 大小页脚 */}
|
||||
<div className="module-list-footer">
|
||||
<span className="module-list-size-label">Runtime size:</span>
|
||||
<span className="module-list-size-value">
|
||||
{formatBytes(totalSize)}
|
||||
{totalWasmSize > 0 && (
|
||||
<span className="module-size-breakdown">
|
||||
(JS: {formatBytes(totalJsSize)} + WASM: {formatBytes(totalWasmSize)})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Validation error toast | 验证错误提示 */}
|
||||
{validationError && (
|
||||
<div className="module-validation-error">
|
||||
<AlertCircle size={14} />
|
||||
<span>{validationError.message}</span>
|
||||
<button onClick={() => setValidationError(null)}>Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModuleListSetting;
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
|
||||
interface NodeIconProps {
|
||||
iconName?: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点图标组件
|
||||
*
|
||||
* 根据图标名称渲染对应的 Lucide 图标
|
||||
*/
|
||||
export const NodeIcon: React.FC<NodeIconProps> = ({ iconName, size = 16, color }) => {
|
||||
if (!iconName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const IconComponent = (LucideIcons as any)[iconName];
|
||||
|
||||
if (!IconComponent) {
|
||||
return <span>{iconName}</span>;
|
||||
}
|
||||
|
||||
return <IconComponent size={size} color={color} />;
|
||||
};
|
||||
@@ -0,0 +1,471 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
|
||||
import { LogService, LogEntry } from '@esengine/editor-core';
|
||||
import { LogLevel } from '@esengine/ecs-framework';
|
||||
import {
|
||||
Search, Filter, Settings, X, Trash2, ChevronDown,
|
||||
Bug, Info, AlertTriangle, XCircle, AlertCircle, Wifi, Pause, Play, Copy
|
||||
} from 'lucide-react';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
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 <Bug size={size} />;
|
||||
case LogLevel.Info:
|
||||
return <Info size={size} />;
|
||||
case LogLevel.Warn:
|
||||
return <AlertTriangle size={size} />;
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
return <XCircle size={size} />;
|
||||
default:
|
||||
return <AlertCircle size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={`output-log-entry ${getLevelClass(log.level)} ${isExpanded ? 'expanded' : ''} ${log.source === 'remote' ? 'log-entry-remote' : ''} ${hasStack ? 'has-stack' : ''}`}
|
||||
>
|
||||
<div className="output-log-entry-main" onClick={hasStack ? onToggle : undefined} style={{ cursor: hasStack ? 'pointer' : 'default' }}>
|
||||
<div className="output-log-entry-icon">
|
||||
{getLevelIcon(log.level)}
|
||||
</div>
|
||||
<div className="output-log-entry-time">
|
||||
{formatTime(log.timestamp)}
|
||||
</div>
|
||||
<div className={`output-log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
|
||||
[{log.source === 'remote' ? 'Remote' : log.source}]
|
||||
</div>
|
||||
<div className="output-log-entry-message">
|
||||
{message}
|
||||
</div>
|
||||
<button
|
||||
className="output-log-entry-copy"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
title="复制"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && stack && (
|
||||
<div className="output-log-entry-stack">
|
||||
<div className="output-log-stack-header">调用堆栈:</div>
|
||||
{stack.split('\n').filter(line => line.trim()).map((line, index) => (
|
||||
<div key={index} className="output-log-stack-line">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LogEntryItem.displayName = 'LogEntryItem';
|
||||
|
||||
export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLogPanelProps) {
|
||||
const { t } = useLocale();
|
||||
const [logs, setLogs] = useState<LogEntry[]>(() => logService.getLogs().slice(-MAX_LOGS));
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [levelFilter, setLevelFilter] = useState<Set<LogLevel>>(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<Set<string>>(new Set());
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
const filterMenuRef = useRef<HTMLDivElement>(null);
|
||||
const settingsMenuRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="output-log-panel">
|
||||
{/* Toolbar */}
|
||||
<div className="output-log-toolbar">
|
||||
<div className="output-log-toolbar-left">
|
||||
<div className="output-log-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('outputLog.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="output-log-search-clear"
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="output-log-toolbar-right">
|
||||
{/* Filter Dropdown */}
|
||||
<div className="output-log-dropdown" ref={filterMenuRef}>
|
||||
<button
|
||||
className={`output-log-btn ${showFilterMenu ? 'active' : ''} ${activeFilterCount > 0 ? 'has-filter' : ''}`}
|
||||
onClick={() => {
|
||||
setShowFilterMenu(!showFilterMenu);
|
||||
setShowSettingsMenu(false);
|
||||
}}
|
||||
>
|
||||
<Filter size={14} />
|
||||
<span>{t('outputLog.filters')}</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="filter-badge">{activeFilterCount}</span>
|
||||
)}
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{showFilterMenu && (
|
||||
<div className="output-log-menu">
|
||||
<div className="output-log-menu-header">
|
||||
{t('outputLog.logLevels')}
|
||||
</div>
|
||||
<label className="output-log-menu-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={levelFilter.has(LogLevel.Debug)}
|
||||
onChange={() => toggleLevelFilter(LogLevel.Debug)}
|
||||
/>
|
||||
<Bug size={14} className="level-icon debug" />
|
||||
<span>Debug</span>
|
||||
<span className="level-count">{levelCounts[LogLevel.Debug]}</span>
|
||||
</label>
|
||||
<label className="output-log-menu-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={levelFilter.has(LogLevel.Info)}
|
||||
onChange={() => toggleLevelFilter(LogLevel.Info)}
|
||||
/>
|
||||
<Info size={14} className="level-icon info" />
|
||||
<span>Info</span>
|
||||
<span className="level-count">{levelCounts[LogLevel.Info]}</span>
|
||||
</label>
|
||||
<label className="output-log-menu-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={levelFilter.has(LogLevel.Warn)}
|
||||
onChange={() => toggleLevelFilter(LogLevel.Warn)}
|
||||
/>
|
||||
<AlertTriangle size={14} className="level-icon warn" />
|
||||
<span>Warning</span>
|
||||
<span className="level-count">{levelCounts[LogLevel.Warn]}</span>
|
||||
</label>
|
||||
<label className="output-log-menu-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={levelFilter.has(LogLevel.Error)}
|
||||
onChange={() => toggleLevelFilter(LogLevel.Error)}
|
||||
/>
|
||||
<XCircle size={14} className="level-icon error" />
|
||||
<span>Error</span>
|
||||
<span className="level-count">{levelCounts[LogLevel.Error]}</span>
|
||||
</label>
|
||||
<div className="output-log-menu-divider" />
|
||||
<label className="output-log-menu-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showRemoteOnly}
|
||||
onChange={() => setShowRemoteOnly(!showRemoteOnly)}
|
||||
/>
|
||||
<Wifi size={14} className="level-icon remote" />
|
||||
<span>{t('outputLog.remoteOnly')}</span>
|
||||
<span className="level-count">{remoteLogCount}</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto Scroll Toggle */}
|
||||
<button
|
||||
className={`output-log-icon-btn ${autoScroll ? 'active' : ''}`}
|
||||
onClick={() => setAutoScroll(!autoScroll)}
|
||||
title={autoScroll
|
||||
? t('outputLog.pauseAutoScroll')
|
||||
: t('outputLog.resumeAutoScroll')
|
||||
}
|
||||
>
|
||||
{autoScroll ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
|
||||
{/* Settings Dropdown */}
|
||||
<div className="output-log-dropdown" ref={settingsMenuRef}>
|
||||
<button
|
||||
className={`output-log-icon-btn ${showSettingsMenu ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setShowSettingsMenu(!showSettingsMenu);
|
||||
setShowFilterMenu(false);
|
||||
}}
|
||||
title={t('outputLog.settings')}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
{showSettingsMenu && (
|
||||
<div className="output-log-menu settings-menu">
|
||||
<button
|
||||
className="output-log-menu-action"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>{t('outputLog.clearLogs')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
{onClose && (
|
||||
<button
|
||||
className="output-log-close-btn"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log Content */}
|
||||
<div
|
||||
className="output-log-content"
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="output-log-empty">
|
||||
<AlertCircle size={32} />
|
||||
<p>{searchQuery
|
||||
? t('outputLog.noMatchingLogs')
|
||||
: t('outputLog.noLogs')
|
||||
}</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredLogs.map((log, index) => (
|
||||
<LogEntryItem
|
||||
key={`${log.id}-${index}`}
|
||||
log={log}
|
||||
isExpanded={expandedLogIds.has(String(log.id))}
|
||||
onToggle={() => toggleLogExpanded(String(log.id))}
|
||||
onCopy={() => handleCopyLog(log)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="output-log-status">
|
||||
<span>{filteredLogs.length} / {logs.length} {t('outputLog.logs')}</span>
|
||||
{!autoScroll && (
|
||||
<button
|
||||
className="output-log-scroll-btn"
|
||||
onClick={() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
↓ {t('outputLog.scrollToBottom')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
import { PluginError } from '../domain/errors';
|
||||
|
||||
interface PluginErrorBoundaryProps {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
children: React.ReactNode;
|
||||
onPluginError?: (pluginId: string, error: Error) => void;
|
||||
}
|
||||
|
||||
export function PluginErrorBoundary({
|
||||
pluginId,
|
||||
pluginName,
|
||||
children,
|
||||
onPluginError
|
||||
}: PluginErrorBoundaryProps): JSX.Element {
|
||||
const handleError = (error: Error) => {
|
||||
onPluginError?.(pluginId, error);
|
||||
};
|
||||
|
||||
const renderFallback = (error: Error) => {
|
||||
const pluginError =
|
||||
error instanceof PluginError ? error : new PluginError(error.message, pluginId, pluginName, 'execute', error);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#fff3cd',
|
||||
border: '1px solid #ffc107',
|
||||
borderRadius: '4px',
|
||||
margin: '10px'
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 8px 0', color: '#856404' }}>插件错误</h3>
|
||||
<p style={{ margin: '0 0 8px 0' }}>{pluginError.getUserMessage()}</p>
|
||||
<small style={{ color: '#666' }}>插件ID: {pluginId}</small>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={renderFallback} onError={handleError}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState } from 'react';
|
||||
import { X, FolderOpen } from 'lucide-react';
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import '../styles/PluginGeneratorWindow.css';
|
||||
|
||||
interface PluginGeneratorWindowProps {
|
||||
onClose: () => void;
|
||||
projectPath: string | null;
|
||||
onSuccess?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function PluginGeneratorWindow({ onClose, projectPath, onSuccess }: PluginGeneratorWindowProps) {
|
||||
const { t } = useLocale();
|
||||
const [pluginName, setPluginName] = useState('');
|
||||
const [pluginVersion, setPluginVersion] = useState('1.0.0');
|
||||
const [outputPath, setOutputPath] = useState(projectPath ? `${projectPath}/plugins` : '');
|
||||
const [includeExample, setIncludeExample] = useState(true);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSelectPath = async () => {
|
||||
try {
|
||||
const selected = await TauriAPI.openProjectDialog();
|
||||
if (selected) {
|
||||
setOutputPath(selected);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select path:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const validatePluginName = (name: string): boolean => {
|
||||
if (!name) {
|
||||
setError(t('pluginGenerator.errorEmpty'));
|
||||
return false;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9-_]+$/.test(name)) {
|
||||
setError(t('pluginGenerator.errorInvalidName'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setError(null);
|
||||
|
||||
if (!validatePluginName(pluginName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!outputPath) {
|
||||
setError(t('pluginGenerator.errorNoPath'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/@plugin-generator', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pluginName,
|
||||
pluginVersion,
|
||||
outputPath,
|
||||
includeExample
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to generate plugin');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
alert(t('pluginGenerator.success'));
|
||||
|
||||
if (result.path) {
|
||||
await TauriAPI.showInFolder(result.path);
|
||||
}
|
||||
|
||||
if (onSuccess) {
|
||||
await onSuccess();
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to generate plugin:', error);
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content plugin-generator-window">
|
||||
<div className="modal-header">
|
||||
<h2>{t('pluginGenerator.title')}</h2>
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label>{t('pluginGenerator.pluginName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pluginName}
|
||||
onChange={(e) => setPluginName(e.target.value)}
|
||||
placeholder={t('pluginGenerator.pluginNamePlaceholder')}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('pluginGenerator.pluginVersion')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pluginVersion}
|
||||
onChange={(e) => setPluginVersion(e.target.value)}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('pluginGenerator.outputPath')}</label>
|
||||
<div className="path-input-group">
|
||||
<input
|
||||
type="text"
|
||||
value={outputPath}
|
||||
onChange={(e) => setOutputPath(e.target.value)}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<button
|
||||
className="select-path-btn"
|
||||
onClick={handleSelectPath}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
{t('pluginGenerator.selectPath')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group checkbox-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeExample}
|
||||
onChange={(e) => setIncludeExample(e.target.checked)}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<span>{t('pluginGenerator.includeExample')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? t('pluginGenerator.generating') : t('pluginGenerator.generate')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{t('pluginGenerator.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Plugin List Setting Component
|
||||
* 插件列表设置组件
|
||||
*
|
||||
* 简洁的插件列表,只显示:
|
||||
* - 勾选框表示启用状态
|
||||
* - 插件名称、版本
|
||||
* - 插件描述
|
||||
* - [Runtime] [Editor] 标签
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { PluginManager, type RegisteredPlugin, type ModuleCategory, ProjectService } from '@esengine/editor-core';
|
||||
import { Check, Lock, Package } from 'lucide-react';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import '../styles/PluginListSetting.css';
|
||||
|
||||
interface PluginListSettingProps {
|
||||
pluginManager: PluginManager;
|
||||
}
|
||||
|
||||
const categoryLabels: Record<ModuleCategory, { zh: string; en: string }> = {
|
||||
Core: { zh: '核心', en: 'Core' },
|
||||
Rendering: { zh: '渲染', en: 'Rendering' },
|
||||
Physics: { zh: '物理', en: 'Physics' },
|
||||
AI: { zh: 'AI', en: 'AI' },
|
||||
Audio: { zh: '音频', en: 'Audio' },
|
||||
Networking: { zh: '网络', en: 'Networking' },
|
||||
Other: { zh: '其他', en: 'Other' }
|
||||
};
|
||||
|
||||
const categoryOrder: ModuleCategory[] = ['Core', 'Rendering', 'Physics', 'AI', 'Audio', 'Networking', 'Other'];
|
||||
|
||||
export function PluginListSetting({ pluginManager }: PluginListSettingProps) {
|
||||
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPlugins();
|
||||
}, [pluginManager]);
|
||||
|
||||
const loadPlugins = () => {
|
||||
const allPlugins = pluginManager.getAllPlugins();
|
||||
setPlugins(allPlugins);
|
||||
};
|
||||
|
||||
const showWarning = (message: string) => {
|
||||
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
|
||||
if (notificationService) {
|
||||
notificationService.show(message, 'warning', 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (pluginId: string) => {
|
||||
const plugin = plugins.find(p => p.plugin.manifest.id === pluginId);
|
||||
if (!plugin) return;
|
||||
|
||||
const manifest = plugin.plugin.manifest;
|
||||
|
||||
// 核心插件不可禁用
|
||||
if (manifest.isCore) {
|
||||
showWarning('核心插件不可禁用');
|
||||
return;
|
||||
}
|
||||
|
||||
const newEnabled = !plugin.enabled;
|
||||
|
||||
// 检查依赖(启用时)
|
||||
if (newEnabled) {
|
||||
const deps = manifest.dependencies || [];
|
||||
const missingDeps = deps.filter((depId: string) => {
|
||||
const depPlugin = plugins.find(p => p.plugin.manifest.id === depId);
|
||||
return depPlugin && !depPlugin.enabled;
|
||||
});
|
||||
|
||||
if (missingDeps.length > 0) {
|
||||
showWarning(`需要先启用依赖插件: ${missingDeps.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 调用 PluginManager 的动态启用/禁用方法
|
||||
console.log(`[PluginListSetting] ${newEnabled ? 'Enabling' : 'Disabling'} plugin: ${pluginId}`);
|
||||
let success: boolean;
|
||||
if (newEnabled) {
|
||||
success = await pluginManager.enable(pluginId);
|
||||
} else {
|
||||
success = await pluginManager.disable(pluginId);
|
||||
}
|
||||
console.log(`[PluginListSetting] ${newEnabled ? 'Enable' : 'Disable'} result: ${success}`);
|
||||
|
||||
if (!success) {
|
||||
showWarning(newEnabled ? '启用插件失败' : '禁用插件失败');
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
setPlugins(plugins.map(p => {
|
||||
if (p.plugin.manifest.id === pluginId) {
|
||||
return { ...p, enabled: newEnabled };
|
||||
}
|
||||
return p;
|
||||
}));
|
||||
|
||||
// 保存到项目配置
|
||||
savePluginConfigToProject();
|
||||
|
||||
// 通知用户(如果有编辑器模块变更)
|
||||
const hasEditorModule = !!plugin.plugin.editorModule;
|
||||
if (hasEditorModule) {
|
||||
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
|
||||
if (notificationService) {
|
||||
notificationService.show(
|
||||
newEnabled ? `已启用插件: ${manifest.displayName}` : `已禁用插件: ${manifest.displayName}`,
|
||||
'success',
|
||||
2000
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存插件配置到项目文件
|
||||
*/
|
||||
const savePluginConfigToProject = async () => {
|
||||
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
|
||||
if (!projectService || !projectService.isProjectOpen()) {
|
||||
console.warn('[PluginListSetting] Cannot save: project not open');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前启用的插件列表(排除核心插件)
|
||||
const enabledPlugins = pluginManager.getEnabledPlugins()
|
||||
.filter(p => !p.plugin.manifest.isCore)
|
||||
.map(p => p.plugin.manifest.id);
|
||||
|
||||
console.log('[PluginListSetting] Saving enabled plugins:', enabledPlugins);
|
||||
|
||||
try {
|
||||
await projectService.setEnabledPlugins(enabledPlugins);
|
||||
console.log('[PluginListSetting] Plugin config saved successfully');
|
||||
} catch (error) {
|
||||
console.error('[PluginListSetting] Failed to save plugin config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 按类别分组并排序
|
||||
const groupedPlugins = plugins.reduce((acc, plugin) => {
|
||||
const category = plugin.plugin.manifest.category;
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(plugin);
|
||||
return acc;
|
||||
}, {} as Record<ModuleCategory, RegisteredPlugin[]>);
|
||||
|
||||
// 按照 categoryOrder 排序
|
||||
const sortedCategories = categoryOrder.filter(cat => groupedPlugins[cat]?.length > 0);
|
||||
|
||||
return (
|
||||
<div className="plugin-list-setting">
|
||||
{sortedCategories.map(category => (
|
||||
<div key={category} className="plugin-category">
|
||||
<div className="plugin-category-header">
|
||||
{categoryLabels[category]?.zh || category}
|
||||
</div>
|
||||
<div className="plugin-list">
|
||||
{groupedPlugins[category]?.map(plugin => {
|
||||
const manifest = plugin.plugin.manifest;
|
||||
const hasRuntime = !!plugin.plugin.runtimeModule;
|
||||
const hasEditor = !!plugin.plugin.editorModule;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={manifest.id}
|
||||
className={`plugin-item ${plugin.enabled ? 'enabled' : ''} ${manifest.isCore ? 'core' : ''}`}
|
||||
onClick={() => handleToggle(manifest.id)}
|
||||
>
|
||||
<div className="plugin-checkbox">
|
||||
{manifest.isCore ? (
|
||||
<Lock size={10} />
|
||||
) : (
|
||||
plugin.enabled && <Check size={10} />
|
||||
)}
|
||||
</div>
|
||||
<div className="plugin-info">
|
||||
<div className="plugin-header">
|
||||
<span className="plugin-name">{manifest.displayName}</span>
|
||||
<span className="plugin-version">v{manifest.version}</span>
|
||||
</div>
|
||||
{manifest.description && (
|
||||
<div className="plugin-description">
|
||||
{manifest.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="plugin-modules">
|
||||
{hasRuntime && (
|
||||
<span className="plugin-module-badge runtime">Runtime</span>
|
||||
)}
|
||||
{hasEditor && (
|
||||
<span className="plugin-module-badge editor">Editor</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{plugins.length === 0 && (
|
||||
<div className="plugin-list-empty">
|
||||
<Package size={32} />
|
||||
<p>没有可用的插件</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { X, Server, WifiOff, Wifi } from 'lucide-react';
|
||||
import { SettingsService } from '../services/SettingsService';
|
||||
import { getProfilerService } from '../services/getService';
|
||||
import '../styles/PortManager.css';
|
||||
|
||||
interface PortManagerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PortManager({ onClose }: PortManagerProps) {
|
||||
const [isServerRunning, setIsServerRunning] = useState(false);
|
||||
const [serverPort, setServerPort] = useState<string>('8080');
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const settings = SettingsService.getInstance();
|
||||
const savedPort = settings.get('profiler.port', 8080);
|
||||
console.log('[PortManager] Initial port from settings:', savedPort);
|
||||
setServerPort(String(savedPort));
|
||||
|
||||
const handleSettingsChange = ((event: CustomEvent) => {
|
||||
console.log('[PortManager] settings:changed event received:', event.detail);
|
||||
const newPort = event.detail['profiler.port'];
|
||||
if (newPort !== undefined) {
|
||||
console.log('[PortManager] Updating port to:', newPort);
|
||||
setServerPort(String(newPort));
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
window.addEventListener('settings:changed', handleSettingsChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('settings:changed', handleSettingsChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkServerStatus();
|
||||
}, []);
|
||||
|
||||
const checkServerStatus = async () => {
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const status = await invoke<boolean>('get_profiler_status');
|
||||
setIsServerRunning(status);
|
||||
} catch (error) {
|
||||
console.error('[PortManager] Failed to check server status:', error);
|
||||
setIsServerRunning(false);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopServer = async () => {
|
||||
setIsStopping(true);
|
||||
try {
|
||||
const profilerService = getProfilerService();
|
||||
if (profilerService) {
|
||||
await profilerService.manualStopServer();
|
||||
setIsServerRunning(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PortManager] Failed to stop server:', error);
|
||||
} finally {
|
||||
setIsStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartServer = async () => {
|
||||
setIsStarting(true);
|
||||
try {
|
||||
const profilerService = getProfilerService();
|
||||
if (profilerService) {
|
||||
await profilerService.manualStartServer();
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await checkServerStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PortManager] Failed to start server:', error);
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="port-manager-overlay" onClick={onClose}>
|
||||
<div className="port-manager" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="port-manager-header">
|
||||
<div className="port-manager-title">
|
||||
<Server size={20} />
|
||||
<h2>Port Manager</h2>
|
||||
</div>
|
||||
<button className="port-manager-close" onClick={onClose} title="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="port-manager-content">
|
||||
<div className="port-section">
|
||||
<h3>Profiler Server</h3>
|
||||
<div className="port-info">
|
||||
<div className="port-item">
|
||||
<span className="port-label">Status:</span>
|
||||
<span className={`port-status ${isServerRunning ? 'running' : 'stopped'}`}>
|
||||
{isChecking ? 'Checking...' : isServerRunning ? 'Running' : 'Stopped'}
|
||||
</span>
|
||||
</div>
|
||||
{isServerRunning && (
|
||||
<div className="port-item">
|
||||
<span className="port-label">Port:</span>
|
||||
<span className="port-value">{serverPort}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isServerRunning && (
|
||||
<div className="port-actions">
|
||||
<button
|
||||
className="action-btn danger"
|
||||
onClick={handleStopServer}
|
||||
disabled={isStopping}
|
||||
>
|
||||
<WifiOff size={16} />
|
||||
<span>{isStopping ? 'Stopping...' : 'Stop Server'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isServerRunning && (
|
||||
<>
|
||||
<div className="port-actions">
|
||||
<button
|
||||
className="action-btn primary"
|
||||
onClick={handleStartServer}
|
||||
disabled={isStarting}
|
||||
>
|
||||
<Wifi size={16} />
|
||||
<span>{isStarting ? 'Starting...' : 'Start Server'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="port-hint">
|
||||
<p>No server is currently running.</p>
|
||||
<p className="hint-text">Click "Start Server" to start the profiler server.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="port-tips">
|
||||
<h4>Tips</h4>
|
||||
<ul>
|
||||
<li>Use this when the Profiler server port is stuck and cannot be restarted</li>
|
||||
<li>The server will automatically stop when the Profiler window is closed</li>
|
||||
<li>Current configured port: {serverPort}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState } from 'react';
|
||||
import { Folder, Sparkles, X } from 'lucide-react';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import '../styles/ProjectCreationWizard.css';
|
||||
|
||||
// 项目模板类型(使用翻译键)
|
||||
// Project template type (using translation keys)
|
||||
interface ProjectTemplate {
|
||||
id: string;
|
||||
nameKey: string; // 翻译键 | Translation key
|
||||
descriptionKey: string;
|
||||
}
|
||||
|
||||
const templates: ProjectTemplate[] = [
|
||||
{
|
||||
id: 'blank',
|
||||
nameKey: 'project.wizard.templates.blank',
|
||||
descriptionKey: 'project.wizard.templates.blankDesc'
|
||||
}
|
||||
];
|
||||
|
||||
interface ProjectCreationWizardProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreateProject: (projectName: string, projectPath: string, templateId: string) => void;
|
||||
onBrowsePath: () => Promise<string | null>;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function ProjectCreationWizard({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreateProject,
|
||||
onBrowsePath,
|
||||
locale: _locale
|
||||
}: ProjectCreationWizardProps) {
|
||||
const { t } = useLocale();
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>('blank');
|
||||
const [projectName, setProjectName] = useState('MyProject');
|
||||
const [projectPath, setProjectPath] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const currentTemplate = templates.find(tmpl => tmpl.id === selectedTemplate);
|
||||
|
||||
const handleBrowse = async () => {
|
||||
const path = await onBrowsePath();
|
||||
if (path) {
|
||||
setProjectPath(path);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (projectName && projectPath) {
|
||||
onCreateProject(projectName, projectPath, selectedTemplate);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="project-wizard-overlay">
|
||||
<div className="project-wizard">
|
||||
<div className="wizard-header">
|
||||
<h1>{t('project.wizard.title')}</h1>
|
||||
<button className="wizard-close-btn" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="wizard-body">
|
||||
{/* Templates grid */}
|
||||
<div className="wizard-templates">
|
||||
<div className="templates-header">
|
||||
<h3>{t('project.wizard.selectTemplate')}</h3>
|
||||
</div>
|
||||
<div className="templates-grid">
|
||||
{templates.map(template => (
|
||||
<button
|
||||
key={template.id}
|
||||
className={`template-card ${selectedTemplate === template.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedTemplate(template.id)}
|
||||
>
|
||||
<div className="template-preview">
|
||||
<Sparkles size={32} />
|
||||
</div>
|
||||
<div className="template-name">
|
||||
{t(template.nameKey)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right - Preview and settings */}
|
||||
<div className="wizard-details">
|
||||
<div className="details-preview">
|
||||
<div className="preview-placeholder">
|
||||
<Sparkles size={48} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="details-info">
|
||||
<h2>{currentTemplate ? t(currentTemplate.nameKey) : ''}</h2>
|
||||
<p>{currentTemplate ? t(currentTemplate.descriptionKey) : ''}</p>
|
||||
</div>
|
||||
|
||||
<div className="details-settings">
|
||||
<h3>{t('project.wizard.projectSettings')}</h3>
|
||||
|
||||
<div className="setting-field">
|
||||
<label>{t('project.wizard.projectName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder="MyProject"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting-field">
|
||||
<label>{t('project.wizard.projectLocation')}</label>
|
||||
<div className="path-input-group">
|
||||
<input
|
||||
type="text"
|
||||
value={projectPath}
|
||||
onChange={(e) => setProjectPath(e.target.value)}
|
||||
placeholder="D:\Projects"
|
||||
/>
|
||||
<button className="browse-btn" onClick={handleBrowse}>
|
||||
<Folder size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-footer">
|
||||
<button className="wizard-btn secondary" onClick={onClose}>
|
||||
{t('project.wizard.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="wizard-btn primary"
|
||||
onClick={handleCreate}
|
||||
disabled={!projectName || !projectPath}
|
||||
>
|
||||
{t('project.wizard.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import '../styles/PromptDialog.css';
|
||||
|
||||
interface PromptDialogProps {
|
||||
title: string;
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
confirmText: string;
|
||||
cancelText: string;
|
||||
onConfirm: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function PromptDialog({
|
||||
title,
|
||||
message,
|
||||
defaultValue = '',
|
||||
placeholder,
|
||||
confirmText,
|
||||
cancelText,
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: PromptDialogProps) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (value.trim()) {
|
||||
onConfirm(value.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="prompt-dialog-overlay" onClick={onCancel}>
|
||||
<div className="prompt-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="prompt-dialog-header">
|
||||
<h2>{title}</h2>
|
||||
<button className="close-btn" onClick={onCancel}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="prompt-dialog-content">
|
||||
<p>{message}</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="prompt-dialog-input"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
<div className="prompt-dialog-footer">
|
||||
<button className="prompt-dialog-btn cancel" onClick={onCancel}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
className="prompt-dialog-btn confirm"
|
||||
onClick={handleConfirm}
|
||||
disabled={!value.trim()}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { X, Copy, Check } from 'lucide-react';
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
import '../styles/QRCodeDialog.css';
|
||||
|
||||
interface QRCodeDialogProps {
|
||||
url: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const QRCodeDialog: React.FC<QRCodeDialogProps> = ({ url, isOpen, onClose }) => {
|
||||
const [qrCodeData, setQrCodeData] = useState<string>('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && url) {
|
||||
setLoading(true);
|
||||
TauriAPI.generateQRCode(url)
|
||||
.then((base64) => {
|
||||
setQrCodeData(`data:image/png;base64,${base64}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to generate QR code:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [isOpen, url]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy URL:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="qrcode-dialog-overlay">
|
||||
<div className="qrcode-dialog">
|
||||
<div className="qrcode-dialog-header">
|
||||
<h3>扫码在移动设备上预览</h3>
|
||||
<button className="qrcode-dialog-close" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="qrcode-dialog-content">
|
||||
{loading ? (
|
||||
<div className="qrcode-loading">生成二维码中...</div>
|
||||
) : qrCodeData ? (
|
||||
<img src={qrCodeData} alt="QR Code" width={200} height={200} />
|
||||
) : (
|
||||
<div className="qrcode-error">生成失败</div>
|
||||
)}
|
||||
|
||||
<div className="qrcode-url-container">
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
readOnly
|
||||
className="qrcode-url-input"
|
||||
/>
|
||||
<button
|
||||
className="qrcode-copy-button"
|
||||
onClick={handleCopy}
|
||||
title={copied ? '已复制' : '复制链接'}
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="qrcode-hint">
|
||||
确保手机和电脑在同一局域网内
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QRCodeDialog;
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState, useRef, useEffect, ReactNode } from 'react';
|
||||
import '../styles/ResizablePanel.css';
|
||||
|
||||
interface ResizablePanelProps {
|
||||
direction: 'horizontal' | 'vertical';
|
||||
leftOrTop: ReactNode;
|
||||
rightOrBottom: ReactNode;
|
||||
defaultSize?: number;
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
side?: 'left' | 'right' | 'top' | 'bottom';
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
export function ResizablePanel({
|
||||
direction,
|
||||
leftOrTop,
|
||||
rightOrBottom,
|
||||
defaultSize = 250,
|
||||
minSize = 150,
|
||||
maxSize = 600,
|
||||
side = 'left',
|
||||
storageKey
|
||||
}: ResizablePanelProps) {
|
||||
const getInitialSize = () => {
|
||||
if (storageKey) {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
const parsedSize = parseInt(saved, 10);
|
||||
if (!isNaN(parsedSize)) {
|
||||
return Math.max(minSize, Math.min(maxSize, parsedSize));
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultSize;
|
||||
};
|
||||
|
||||
const [size, setSize] = useState(getInitialSize);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (storageKey && !isDragging) {
|
||||
localStorage.setItem(storageKey, size.toString());
|
||||
}
|
||||
}, [size, isDragging, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
let newSize: number;
|
||||
|
||||
if (direction === 'horizontal') {
|
||||
if (side === 'right') {
|
||||
newSize = rect.right - e.clientX;
|
||||
} else {
|
||||
newSize = e.clientX - rect.left;
|
||||
}
|
||||
} else {
|
||||
if (side === 'bottom') {
|
||||
newSize = rect.bottom - e.clientY;
|
||||
} else {
|
||||
newSize = e.clientY - rect.top;
|
||||
}
|
||||
}
|
||||
|
||||
newSize = Math.max(minSize, Math.min(maxSize, newSize));
|
||||
setSize(newSize);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, direction, minSize, maxSize, side]);
|
||||
|
||||
const handleMouseDown = () => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const className = `resizable-panel resizable-panel-${direction}`;
|
||||
const resizerClassName = `resizer resizer-${direction}`;
|
||||
|
||||
if (direction === 'horizontal') {
|
||||
if (side === 'right') {
|
||||
return (
|
||||
<div ref={containerRef} className={className}>
|
||||
<div className="panel-section" style={{ flex: 1 }}>
|
||||
{leftOrTop}
|
||||
</div>
|
||||
<div
|
||||
className={resizerClassName}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isDragging ? 'ew-resize' : 'col-resize' }}
|
||||
>
|
||||
<div className="resizer-handle" />
|
||||
</div>
|
||||
<div className="panel-section" style={{ width: `${size}px` }}>
|
||||
{rightOrBottom}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div ref={containerRef} className={className}>
|
||||
<div className="panel-section" style={{ width: `${size}px` }}>
|
||||
{leftOrTop}
|
||||
</div>
|
||||
<div
|
||||
className={resizerClassName}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isDragging ? 'ew-resize' : 'col-resize' }}
|
||||
>
|
||||
<div className="resizer-handle" />
|
||||
</div>
|
||||
<div className="panel-section" style={{ flex: 1 }}>
|
||||
{rightOrBottom}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (side === 'bottom') {
|
||||
return (
|
||||
<div ref={containerRef} className={className}>
|
||||
<div className="panel-section" style={{ flex: 1 }}>
|
||||
{leftOrTop}
|
||||
</div>
|
||||
<div
|
||||
className={resizerClassName}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isDragging ? 'ns-resize' : 'row-resize' }}
|
||||
>
|
||||
<div className="resizer-handle" />
|
||||
</div>
|
||||
<div className="panel-section" style={{ height: `${size}px` }}>
|
||||
{rightOrBottom}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div ref={containerRef} className={className}>
|
||||
<div className="panel-section" style={{ height: `${size}px` }}>
|
||||
{leftOrTop}
|
||||
</div>
|
||||
<div
|
||||
className={resizerClassName}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isDragging ? 'ns-resize' : 'row-resize' }}
|
||||
>
|
||||
<div className="resizer-handle" />
|
||||
</div>
|
||||
<div className="panel-section" style={{ flex: 1 }}>
|
||||
{rightOrBottom}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,725 @@
|
||||
/**
|
||||
* Settings Window - 设置窗口
|
||||
* 重新设计以匹配编辑器设计稿
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
X,
|
||||
Search,
|
||||
Settings as SettingsIcon,
|
||||
ChevronDown,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { SettingsService } from '../services/SettingsService';
|
||||
import { SettingsRegistry, SettingCategory, SettingDescriptor, ProjectService, PluginManager, IPluginManager, ModuleManifest, isTranslationKey, getTranslationKey } from '@esengine/editor-core';
|
||||
import { PluginListSetting } from './PluginListSetting';
|
||||
import { ModuleListSetting } from './ModuleListSetting';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import '../styles/SettingsWindow.css';
|
||||
|
||||
interface SettingsWindowProps {
|
||||
onClose: () => void;
|
||||
settingsRegistry: SettingsRegistry;
|
||||
initialCategoryId?: string;
|
||||
}
|
||||
|
||||
// 主分类结构
|
||||
interface MainCategory {
|
||||
id: string;
|
||||
title: string;
|
||||
subCategories: SettingCategory[];
|
||||
}
|
||||
|
||||
export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: SettingsWindowProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
/**
|
||||
* Resolve localizable text - if it starts with '$', treat as translation key
|
||||
* 解析可本地化文本 - 如果以 '$' 开头,作为翻译键处理
|
||||
*/
|
||||
const resolveText = (text: string | undefined): string => {
|
||||
if (!text) return '';
|
||||
if (isTranslationKey(text)) {
|
||||
return t(getTranslationKey(text));
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const [categories, setCategories] = useState<SettingCategory[]>([]);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(initialCategoryId || null);
|
||||
const [values, setValues] = useState<Map<string, any>>(new Map());
|
||||
const [errors, setErrors] = useState<Map<string, string>>(new Map());
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
||||
const [expandedMainCategories, setExpandedMainCategories] = useState<Set<string>>(new Set(['general']));
|
||||
|
||||
// 将分类组织成主分类和子分类
|
||||
// Organize categories into main categories and sub-categories
|
||||
const mainCategories = useMemo((): MainCategory[] => {
|
||||
const categoryMap = new Map<string, SettingCategory[]>();
|
||||
|
||||
// 定义主分类映射(使用配置 ID 作为键)
|
||||
// Main category mapping (using config IDs as keys)
|
||||
const mainCategoryMapping: Record<string, string> = {
|
||||
'appearance': 'general',
|
||||
'general': 'general',
|
||||
'project': 'general',
|
||||
'plugins': 'general',
|
||||
'editor': 'general',
|
||||
'physics': 'global',
|
||||
'rendering': 'global',
|
||||
'audio': 'global',
|
||||
'world': 'worldPartition',
|
||||
'local': 'worldPartitionLocal',
|
||||
'performance': 'performance'
|
||||
};
|
||||
|
||||
categories.forEach((cat) => {
|
||||
const mainCatId = mainCategoryMapping[cat.id] || 'other';
|
||||
if (!categoryMap.has(mainCatId)) {
|
||||
categoryMap.set(mainCatId, []);
|
||||
}
|
||||
categoryMap.get(mainCatId)!.push(cat);
|
||||
});
|
||||
|
||||
// 定义固定的主分类顺序(使用配置 ID)
|
||||
// Define fixed main category order (using config IDs)
|
||||
const orderedMainCategories = [
|
||||
'general',
|
||||
'global',
|
||||
'worldPartition',
|
||||
'worldPartitionLocal',
|
||||
'performance',
|
||||
'other'
|
||||
];
|
||||
|
||||
// 主分类 ID 到翻译键的映射
|
||||
// Main category ID to translation key mapping
|
||||
const categoryTranslationKeys: Record<string, string> = {
|
||||
'general': 'settingsWindow.mainCategories.general',
|
||||
'global': 'settingsWindow.mainCategories.global',
|
||||
'worldPartition': 'settingsWindow.mainCategories.worldPartition',
|
||||
'worldPartitionLocal': 'settingsWindow.mainCategories.worldPartitionLocal',
|
||||
'performance': 'settingsWindow.mainCategories.performance',
|
||||
'other': 'settingsWindow.mainCategories.other'
|
||||
};
|
||||
|
||||
return orderedMainCategories
|
||||
.filter((id) => categoryMap.has(id))
|
||||
.map((id) => ({
|
||||
id,
|
||||
title: t(categoryTranslationKeys[id] || 'settingsWindow.mainCategories.other'),
|
||||
subCategories: categoryMap.get(id)!
|
||||
}));
|
||||
}, [categories, t]);
|
||||
|
||||
// 获取显示的子分类标题
|
||||
const subCategoryTitle = useMemo(() => {
|
||||
if (!selectedCategoryId) return '';
|
||||
const cat = categories.find((c) => c.id === selectedCategoryId);
|
||||
return cat?.title || '';
|
||||
}, [categories, selectedCategoryId]);
|
||||
|
||||
// 获取主分类标题
|
||||
const mainCategoryTitle = useMemo(() => {
|
||||
for (const main of mainCategories) {
|
||||
if (main.subCategories.some((sub) => sub.id === selectedCategoryId)) {
|
||||
return main.title;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}, [mainCategories, selectedCategoryId]);
|
||||
|
||||
useEffect(() => {
|
||||
const allCategories = settingsRegistry.getAllCategories();
|
||||
setCategories(allCategories);
|
||||
|
||||
// 默认展开所有section
|
||||
const allSectionIds = new Set<string>();
|
||||
allCategories.forEach((cat) => {
|
||||
cat.sections.forEach((section) => {
|
||||
allSectionIds.add(`${cat.id}-${section.id}`);
|
||||
});
|
||||
});
|
||||
setExpandedSections(allSectionIds);
|
||||
|
||||
if (allCategories.length > 0 && !selectedCategoryId) {
|
||||
if (initialCategoryId && allCategories.some((c) => c.id === initialCategoryId)) {
|
||||
setSelectedCategoryId(initialCategoryId);
|
||||
} else {
|
||||
const firstCategory = allCategories[0];
|
||||
if (firstCategory) {
|
||||
setSelectedCategoryId(firstCategory.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const settings = SettingsService.getInstance();
|
||||
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
|
||||
const allSettings = settingsRegistry.getAllSettings();
|
||||
const initialValues = new Map<string, any>();
|
||||
|
||||
for (const [key, descriptor] of allSettings.entries()) {
|
||||
// 特定的 project 设置需要从 ProjectService 加载
|
||||
// Specific project settings need to load from ProjectService
|
||||
if (key === 'project.uiDesignResolution.width' && projectService) {
|
||||
const resolution = projectService.getUIDesignResolution();
|
||||
initialValues.set(key, resolution.width);
|
||||
} else if (key === 'project.uiDesignResolution.height' && projectService) {
|
||||
const resolution = projectService.getUIDesignResolution();
|
||||
initialValues.set(key, resolution.height);
|
||||
} else if (key === 'project.uiDesignResolution.preset' && projectService) {
|
||||
const resolution = projectService.getUIDesignResolution();
|
||||
initialValues.set(key, `${resolution.width}x${resolution.height}`);
|
||||
} else if (key === 'project.disabledModules' && projectService) {
|
||||
// Load disabled modules from ProjectService
|
||||
initialValues.set(key, projectService.getDisabledModules());
|
||||
} else {
|
||||
// 其他设置(包括 project.dynamicAtlas.*)从 SettingsService 加载
|
||||
// Other settings (including project.dynamicAtlas.*) load from SettingsService
|
||||
const value = settings.get(key, descriptor.defaultValue);
|
||||
initialValues.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
setValues(initialValues);
|
||||
}, [settingsRegistry, initialCategoryId]);
|
||||
|
||||
const handleValueChange = (key: string, value: any, descriptor: SettingDescriptor) => {
|
||||
const newValues = new Map(values);
|
||||
newValues.set(key, value);
|
||||
|
||||
// When preset is selected, also update width and height values
|
||||
// 当选择预设时,同时更新宽度和高度值
|
||||
if (key === 'project.uiDesignResolution.preset' && typeof value === 'string' && value.includes('x')) {
|
||||
const [w, h] = value.split('x').map(Number);
|
||||
if (w && h) {
|
||||
newValues.set('project.uiDesignResolution.width', w);
|
||||
newValues.set('project.uiDesignResolution.height', h);
|
||||
}
|
||||
}
|
||||
|
||||
setValues(newValues);
|
||||
|
||||
const newErrors = new Map(errors);
|
||||
if (!settingsRegistry.validateSetting(descriptor, value)) {
|
||||
newErrors.set(key, descriptor.validator?.errorMessage || t('settingsWindow.invalidValue'));
|
||||
setErrors(newErrors);
|
||||
return; // 验证失败,不保存 | Validation failed, don't save
|
||||
} else {
|
||||
newErrors.delete(key);
|
||||
}
|
||||
setErrors(newErrors);
|
||||
|
||||
// 实时保存设置
|
||||
// Real-time save settings
|
||||
const settings = SettingsService.getInstance();
|
||||
|
||||
// 除了特定的 project 设置需要延迟保存外,其他都实时保存
|
||||
// Save in real-time except for specific project settings that need deferred save
|
||||
const deferredProjectSettings = [
|
||||
'project.uiDesignResolution.',
|
||||
'project.disabledModules'
|
||||
];
|
||||
const shouldDeferSave = deferredProjectSettings.some(prefix => key.startsWith(prefix));
|
||||
|
||||
if (!shouldDeferSave) {
|
||||
settings.set(key, value);
|
||||
|
||||
// 触发设置变更事件
|
||||
// Trigger settings changed event
|
||||
window.dispatchEvent(new CustomEvent('settings:changed', {
|
||||
detail: { [key]: value }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (errors.size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = SettingsService.getInstance();
|
||||
const projectService = Core.services.tryResolve<ProjectService>(ProjectService);
|
||||
const changedSettings: Record<string, any> = {};
|
||||
|
||||
// Get width and height directly from values - these are the actual UI input values
|
||||
// 直接从 values 获取宽高 - 这些是实际的 UI 输入值
|
||||
const widthFromValues = values.get('project.uiDesignResolution.width');
|
||||
const heightFromValues = values.get('project.uiDesignResolution.height');
|
||||
|
||||
// Use the width/height values directly (they are always set from either user input or initial load)
|
||||
// 直接使用 width/height 值(它们总是从用户输入或初始加载设置的)
|
||||
const newWidth = typeof widthFromValues === 'number' ? widthFromValues : 1920;
|
||||
const newHeight = typeof heightFromValues === 'number' ? heightFromValues : 1080;
|
||||
|
||||
// Check if resolution differs from saved config
|
||||
// 检查分辨率是否与保存的配置不同
|
||||
const currentResolution = projectService?.getUIDesignResolution() || { width: 1920, height: 1080 };
|
||||
const uiResolutionChanged = newWidth !== currentResolution.width || newHeight !== currentResolution.height;
|
||||
|
||||
let disabledModulesChanged = false;
|
||||
let newDisabledModules: string[] = [];
|
||||
|
||||
for (const [key, value] of values.entries()) {
|
||||
if (key.startsWith('project.') && projectService) {
|
||||
if (key === 'project.disabledModules') {
|
||||
newDisabledModules = value as string[];
|
||||
disabledModulesChanged = true;
|
||||
}
|
||||
changedSettings[key] = value;
|
||||
} else {
|
||||
settings.set(key, value);
|
||||
changedSettings[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (uiResolutionChanged && projectService) {
|
||||
console.log(`[SettingsWindow] Saving UI resolution: ${newWidth}x${newHeight}`);
|
||||
await projectService.setUIDesignResolution({ width: newWidth, height: newHeight });
|
||||
console.log(`[SettingsWindow] UI resolution saved, verifying: ${JSON.stringify(projectService.getUIDesignResolution())}`);
|
||||
}
|
||||
|
||||
if (disabledModulesChanged && projectService) {
|
||||
await projectService.setDisabledModules(newDisabledModules);
|
||||
}
|
||||
|
||||
console.log('[SettingsWindow] Saving settings, changedSettings:', changedSettings);
|
||||
window.dispatchEvent(new CustomEvent('settings:changed', {
|
||||
detail: changedSettings
|
||||
}));
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleResetToDefault = () => {
|
||||
const allSettings = settingsRegistry.getAllSettings();
|
||||
const defaultValues = new Map<string, any>();
|
||||
for (const [key, descriptor] of allSettings.entries()) {
|
||||
defaultValues.set(key, descriptor.defaultValue);
|
||||
}
|
||||
setValues(defaultValues);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const exportData: Record<string, any> = {};
|
||||
for (const [key, value] of values.entries()) {
|
||||
exportData[key] = value;
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'editor-settings.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const importData = JSON.parse(text);
|
||||
const newValues = new Map(values);
|
||||
for (const [key, value] of Object.entries(importData)) {
|
||||
newValues.set(key, value);
|
||||
}
|
||||
setValues(newValues);
|
||||
} catch (err) {
|
||||
console.error('Failed to import settings:', err);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const toggleSection = (sectionId: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(sectionId)) {
|
||||
newSet.delete(sectionId);
|
||||
} else {
|
||||
newSet.add(sectionId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleMainCategory = (categoryId: string) => {
|
||||
setExpandedMainCategories((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(categoryId)) {
|
||||
newSet.delete(categoryId);
|
||||
} else {
|
||||
newSet.add(categoryId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const renderSettingInput = (setting: SettingDescriptor) => {
|
||||
const value = values.get(setting.key) ?? setting.defaultValue;
|
||||
const error = errors.get(setting.key);
|
||||
|
||||
switch (setting.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-label">
|
||||
{setting.description && (
|
||||
<ChevronRight size={12} className="settings-row-expand" />
|
||||
)}
|
||||
<span>{resolveText(setting.label)}</span>
|
||||
</div>
|
||||
<div className="settings-row-value">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="settings-checkbox"
|
||||
checked={value}
|
||||
onChange={(e) => handleValueChange(setting.key, e.target.checked, setting)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-label">
|
||||
{setting.description && (
|
||||
<ChevronRight size={12} className="settings-row-expand" />
|
||||
)}
|
||||
<span>{resolveText(setting.label)}</span>
|
||||
</div>
|
||||
<div className="settings-row-value">
|
||||
<input
|
||||
type="number"
|
||||
className={`settings-number-input ${error ? 'error' : ''}`}
|
||||
value={value}
|
||||
onChange={(e) => handleValueChange(setting.key, parseInt(e.target.value) || 0, setting)}
|
||||
placeholder={resolveText(setting.placeholder)}
|
||||
min={setting.min}
|
||||
max={setting.max}
|
||||
step={setting.step}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'string':
|
||||
return (
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-label">
|
||||
{setting.description && (
|
||||
<ChevronRight size={12} className="settings-row-expand" />
|
||||
)}
|
||||
<span>{resolveText(setting.label)}</span>
|
||||
</div>
|
||||
<div className="settings-row-value">
|
||||
<input
|
||||
type="text"
|
||||
className={`settings-text-input ${error ? 'error' : ''}`}
|
||||
value={value}
|
||||
onChange={(e) => handleValueChange(setting.key, e.target.value, setting)}
|
||||
placeholder={resolveText(setting.placeholder)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-label">
|
||||
{setting.description && (
|
||||
<ChevronRight size={12} className="settings-row-expand" />
|
||||
)}
|
||||
<span>{resolveText(setting.label)}</span>
|
||||
</div>
|
||||
<div className="settings-row-value">
|
||||
<select
|
||||
className={`settings-select ${error ? 'error' : ''}`}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const option = setting.options?.find((opt) => String(opt.value) === e.target.value);
|
||||
if (option) {
|
||||
handleValueChange(setting.key, option.value, setting);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{setting.options?.map((option) => (
|
||||
<option key={String(option.value)} value={String(option.value)}>
|
||||
{resolveText(option.label)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'range':
|
||||
return (
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-label">
|
||||
{setting.description && (
|
||||
<ChevronRight size={12} className="settings-row-expand" />
|
||||
)}
|
||||
<span>{resolveText(setting.label)}</span>
|
||||
</div>
|
||||
<div className="settings-row-value">
|
||||
<input
|
||||
type="range"
|
||||
className="settings-range"
|
||||
value={value}
|
||||
onChange={(e) => handleValueChange(setting.key, parseFloat(e.target.value), setting)}
|
||||
min={setting.min}
|
||||
max={setting.max}
|
||||
step={setting.step}
|
||||
/>
|
||||
<span className="settings-range-value">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'color':
|
||||
return (
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-label">
|
||||
{setting.description && (
|
||||
<ChevronRight size={12} className="settings-row-expand" />
|
||||
)}
|
||||
<span>{resolveText(setting.label)}</span>
|
||||
</div>
|
||||
<div className="settings-row-value">
|
||||
<div className="settings-color-bar" style={{ backgroundColor: value }}>
|
||||
<input
|
||||
type="color"
|
||||
className="settings-color-input"
|
||||
value={value}
|
||||
onChange={(e) => handleValueChange(setting.key, e.target.value, setting)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'pluginList': {
|
||||
const pluginManager = Core.services.tryResolve<PluginManager>(IPluginManager);
|
||||
if (!pluginManager) {
|
||||
return (
|
||||
<div className="settings-row">
|
||||
<p className="settings-error">{t('settingsWindow.pluginManagerUnavailable')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="settings-plugin-list">
|
||||
<PluginListSetting pluginManager={pluginManager} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'collisionMatrix': {
|
||||
const CustomRenderer = setting.customRenderer as React.ComponentType<any> | undefined;
|
||||
if (CustomRenderer) {
|
||||
return (
|
||||
<div className="settings-custom-renderer">
|
||||
<CustomRenderer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="settings-row">
|
||||
<p className="settings-hint">{t('settingsWindow.collisionMatrixNotConfigured')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'moduleList': {
|
||||
// Get module data from setting's custom props
|
||||
// 从设置的自定义属性获取模块数据
|
||||
const moduleData = setting as SettingDescriptor & {
|
||||
modules?: ModuleManifest[];
|
||||
getModules?: () => ModuleManifest[];
|
||||
useBlacklist?: boolean;
|
||||
validateDisable?: (moduleId: string) => Promise<{ canDisable: boolean; reason?: string }>;
|
||||
};
|
||||
const moduleValue = value as string[] || [];
|
||||
|
||||
return (
|
||||
<div className="settings-module-list">
|
||||
<ModuleListSetting
|
||||
modules={moduleData.modules}
|
||||
getModules={moduleData.getModules}
|
||||
value={moduleValue}
|
||||
onModulesChange={(newValue) => handleValueChange(setting.key, newValue, setting)}
|
||||
useBlacklist={moduleData.useBlacklist}
|
||||
validateDisable={moduleData.validateDisable}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const selectedCategory = categories.find((c) => c.id === selectedCategoryId);
|
||||
|
||||
return (
|
||||
<div className="settings-overlay" onClick={handleSave}>
|
||||
<div className="settings-window-new" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Left Sidebar */}
|
||||
<div className="settings-sidebar-new">
|
||||
<div className="settings-sidebar-header">
|
||||
<SettingsIcon size={16} />
|
||||
<span>{t('settingsWindow.editorPreferences')}</span>
|
||||
<button className="settings-sidebar-close" onClick={handleSave}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-sidebar-search">
|
||||
<span>{t('settingsWindow.allSettings')}</span>
|
||||
</div>
|
||||
|
||||
<div className="settings-sidebar-categories">
|
||||
{mainCategories.map((mainCat) => (
|
||||
<div key={mainCat.id} className="settings-main-category">
|
||||
<div
|
||||
className="settings-main-category-header"
|
||||
onClick={() => toggleMainCategory(mainCat.id)}
|
||||
>
|
||||
{expandedMainCategories.has(mainCat.id) ? (
|
||||
<ChevronDown size={12} />
|
||||
) : (
|
||||
<ChevronRight size={12} />
|
||||
)}
|
||||
<span>{mainCat.title}</span>
|
||||
</div>
|
||||
|
||||
{expandedMainCategories.has(mainCat.id) && (
|
||||
<div className="settings-sub-categories">
|
||||
{mainCat.subCategories.map((subCat) => (
|
||||
<button
|
||||
key={subCat.id}
|
||||
className={`settings-sub-category ${selectedCategoryId === subCat.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedCategoryId(subCat.id)}
|
||||
>
|
||||
{resolveText(subCat.title)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Content */}
|
||||
<div className="settings-content-new">
|
||||
{/* Top Header */}
|
||||
<div className="settings-content-header">
|
||||
<div className="settings-search-bar">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('settingsWindow.search')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-header-actions">
|
||||
<button className="settings-icon-btn" title={t('settingsWindow.settingsBtn')}>
|
||||
<SettingsIcon size={14} />
|
||||
</button>
|
||||
<button className="settings-action-btn" onClick={handleExport}>
|
||||
{t('settingsWindow.export')}
|
||||
</button>
|
||||
<button className="settings-action-btn" onClick={handleImport}>
|
||||
{t('settingsWindow.import')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Title */}
|
||||
<div className="settings-category-title-bar">
|
||||
<div className="settings-category-breadcrumb">
|
||||
<ChevronDown size={14} />
|
||||
<span className="settings-breadcrumb-main">{mainCategoryTitle}</span>
|
||||
<span className="settings-breadcrumb-separator">-</span>
|
||||
<span className="settings-breadcrumb-sub">{subCategoryTitle}</span>
|
||||
</div>
|
||||
{selectedCategory?.description && (
|
||||
<p className="settings-category-desc">{resolveText(selectedCategory.description)}</p>
|
||||
)}
|
||||
<div className="settings-category-actions">
|
||||
<button className="settings-category-action-btn" onClick={handleResetToDefault}>
|
||||
{t('settingsWindow.resetToDefault')}
|
||||
</button>
|
||||
<button className="settings-category-action-btn" onClick={handleExport}>
|
||||
{t('settingsWindow.export')}
|
||||
</button>
|
||||
<button className="settings-category-action-btn" onClick={handleImport}>
|
||||
{t('settingsWindow.import')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Content */}
|
||||
<div className="settings-sections-container">
|
||||
{selectedCategory && selectedCategory.sections.map((section) => {
|
||||
const sectionKey = `${selectedCategory.id}-${section.id}`;
|
||||
const isExpanded = expandedSections.has(sectionKey);
|
||||
|
||||
return (
|
||||
<div key={section.id} className="settings-section-new">
|
||||
<div
|
||||
className="settings-section-header-new"
|
||||
onClick={() => toggleSection(sectionKey)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={12} />
|
||||
) : (
|
||||
<ChevronRight size={12} />
|
||||
)}
|
||||
<span>{resolveText(section.title)}</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="settings-section-content-new">
|
||||
{section.settings.map((setting) => (
|
||||
<div key={setting.key}>
|
||||
{renderSettingInput(setting)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!selectedCategory && (
|
||||
<div className="settings-empty-new">
|
||||
<SettingsIcon size={48} />
|
||||
<p>{t('settingsWindow.selectCategory')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import '../styles/StartupLogo.css';
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
size: number;
|
||||
alpha: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface StartupLogoProps {
|
||||
onAnimationComplete: () => void;
|
||||
}
|
||||
|
||||
// 在组件外部创建粒子数据,确保只初始化一次
|
||||
let particlesCache: Particle[] | null = null;
|
||||
let cacheKey: string | null = null;
|
||||
|
||||
function createParticles(width: number, height: number, text: string, fontSize: number): Particle[] {
|
||||
const key = `${width}-${height}-${fontSize}`;
|
||||
if (particlesCache && cacheKey === key) {
|
||||
// 重置粒子位置
|
||||
for (const p of particlesCache) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const distance = Math.random() * Math.max(width, height);
|
||||
p.x = width / 2 + Math.cos(angle) * distance;
|
||||
p.y = height / 2 + Math.sin(angle) * distance;
|
||||
}
|
||||
return particlesCache;
|
||||
}
|
||||
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
if (!tempCtx) return [];
|
||||
|
||||
tempCtx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`;
|
||||
const textMetrics = tempCtx.measureText(text);
|
||||
const textWidth = textMetrics.width;
|
||||
const textHeight = fontSize;
|
||||
|
||||
tempCanvas.width = textWidth + 40;
|
||||
tempCanvas.height = textHeight + 40;
|
||||
tempCtx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`;
|
||||
tempCtx.textAlign = 'center';
|
||||
tempCtx.textBaseline = 'middle';
|
||||
tempCtx.fillStyle = '#ffffff';
|
||||
tempCtx.fillText(text, tempCanvas.width / 2, tempCanvas.height / 2);
|
||||
|
||||
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
||||
const pixels = imageData.data;
|
||||
const particles: Particle[] = [];
|
||||
const gap = 4;
|
||||
|
||||
const offsetX = (width - tempCanvas.width) / 2;
|
||||
const offsetY = (height - tempCanvas.height) / 2;
|
||||
|
||||
const colors = ['#569CD6', '#4EC9B0', '#9CDCFE', '#C586C0', '#DCDCAA'];
|
||||
|
||||
for (let y = 0; y < tempCanvas.height; y += gap) {
|
||||
for (let x = 0; x < tempCanvas.width; x += gap) {
|
||||
const index = (y * tempCanvas.width + x) * 4;
|
||||
const alpha = pixels[index + 3] ?? 0;
|
||||
|
||||
if (alpha > 128) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const distance = Math.random() * Math.max(width, height);
|
||||
|
||||
particles.push({
|
||||
x: width / 2 + Math.cos(angle) * distance,
|
||||
y: height / 2 + Math.sin(angle) * distance,
|
||||
targetX: offsetX + x,
|
||||
targetY: offsetY + y,
|
||||
size: Math.random() * 2 + 1.5,
|
||||
alpha: Math.random() * 0.5 + 0.5,
|
||||
color: colors[Math.floor(Math.random() * colors.length)] ?? '#569CD6'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
particlesCache = particles;
|
||||
cacheKey = key;
|
||||
return particles;
|
||||
}
|
||||
|
||||
export function StartupLogo({ onAnimationComplete }: StartupLogoProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [fadeOut, setFadeOut] = useState(false);
|
||||
|
||||
const onCompleteRef = useRef(onAnimationComplete);
|
||||
onCompleteRef.current = onAnimationComplete;
|
||||
|
||||
const startAnimation = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return () => {};
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return () => {};
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const text = 'ESEngine';
|
||||
const fontSize = Math.min(width / 6, 120);
|
||||
const particles = createParticles(width, height, text, fontSize);
|
||||
|
||||
const startTime = performance.now();
|
||||
const duration = 2000;
|
||||
const glowDuration = 500; // 发光过渡时长
|
||||
const holdDuration = 800;
|
||||
let animationId: number | null = null;
|
||||
let glowStartTime: number | null = null;
|
||||
let isCancelled = false;
|
||||
let timeoutId1: ReturnType<typeof setTimeout> | null = null;
|
||||
let timeoutId2: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const easeOutQuart = (t: number) => 1 - Math.pow(1 - t, 4);
|
||||
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
if (isCancelled) return;
|
||||
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const easedProgress = easeOutQuart(progress);
|
||||
|
||||
ctx.fillStyle = '#1e1e1e';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// 计算发光进度
|
||||
let glowProgress = 0;
|
||||
if (progress >= 1) {
|
||||
if (glowStartTime === null) {
|
||||
glowStartTime = currentTime;
|
||||
}
|
||||
glowProgress = Math.min((currentTime - glowStartTime) / glowDuration, 1);
|
||||
glowProgress = easeOutCubic(glowProgress);
|
||||
}
|
||||
|
||||
for (const particle of particles) {
|
||||
// 使用线性插值移动
|
||||
const moveProgress = Math.min(easedProgress * 1.2, 1);
|
||||
const currentX = particle.x + (particle.targetX - particle.x) * moveProgress;
|
||||
const currentY = particle.y + (particle.targetY - particle.y) * moveProgress;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(currentX, currentY, particle.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = particle.color;
|
||||
ctx.globalAlpha = particle.alpha * (1 - glowProgress * 0.3); // 粒子逐渐变淡
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// 发光文字渐变显示
|
||||
if (glowProgress > 0) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = '#4EC9B0';
|
||||
ctx.shadowBlur = 20 * glowProgress;
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${glowProgress})`;
|
||||
ctx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, width / 2, height / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 发光完成后开始淡出
|
||||
if (glowProgress >= 1) {
|
||||
if (!timeoutId1) {
|
||||
timeoutId1 = setTimeout(() => {
|
||||
if (isCancelled) return;
|
||||
setFadeOut(true);
|
||||
timeoutId2 = setTimeout(() => {
|
||||
if (isCancelled) return;
|
||||
onCompleteRef.current();
|
||||
}, 500);
|
||||
}, holdDuration);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCancelled && (!timeoutId1 || glowProgress < 1)) {
|
||||
animationId = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
animationId = requestAnimationFrame(animate);
|
||||
|
||||
// 返回 cleanup 函数
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
if (animationId !== null) {
|
||||
cancelAnimationFrame(animationId);
|
||||
}
|
||||
if (timeoutId1 !== null) {
|
||||
clearTimeout(timeoutId1);
|
||||
}
|
||||
if (timeoutId2 !== null) {
|
||||
clearTimeout(timeoutId2);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = startAnimation();
|
||||
return cleanup;
|
||||
}, [startAnimation]);
|
||||
|
||||
return (
|
||||
<div className={`startup-logo-container ${fadeOut ? 'fade-out' : ''}`}>
|
||||
<canvas ref={canvasRef} className="startup-logo-canvas" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { Globe, ChevronDown, Download, X, Loader2, Trash2, CheckCircle, AlertCircle, Terminal } from 'lucide-react';
|
||||
import { checkForUpdatesOnStartup, installUpdate, type UpdateCheckResult } from '../utils/updater';
|
||||
import { StartupLogo } from './StartupLogo';
|
||||
import { TauriAPI, type EnvironmentCheckResult } from '../api/tauri';
|
||||
import { useLocale, type Locale } from '../hooks/useLocale';
|
||||
import '../styles/StartupPage.css';
|
||||
|
||||
interface StartupPageProps {
|
||||
onOpenProject: () => void;
|
||||
onCreateProject: () => void;
|
||||
onOpenRecentProject?: (projectPath: string) => void;
|
||||
onRemoveRecentProject?: (projectPath: string) => void;
|
||||
onDeleteProject?: (projectPath: string) => Promise<void>;
|
||||
onLocaleChange?: (locale: Locale) => void;
|
||||
recentProjects?: string[];
|
||||
}
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'zh', name: '中文' }
|
||||
];
|
||||
|
||||
export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onRemoveRecentProject, onDeleteProject, onLocaleChange, recentProjects = [] }: StartupPageProps) {
|
||||
const { t, locale } = useLocale();
|
||||
const [showLogo, setShowLogo] = useState(true);
|
||||
const [hoveredProject, setHoveredProject] = useState<string | null>(null);
|
||||
const [appVersion, setAppVersion] = useState<string>('');
|
||||
const [showLangMenu, setShowLangMenu] = useState(false);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; project: string } | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null);
|
||||
const [showUpdateBanner, setShowUpdateBanner] = useState(false);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [envCheck, setEnvCheck] = useState<EnvironmentCheckResult | null>(null);
|
||||
const [showEnvStatus, setShowEnvStatus] = useState(false);
|
||||
const [showEsbuildInstall, setShowEsbuildInstall] = useState(false);
|
||||
const [isInstallingEsbuild, setIsInstallingEsbuild] = useState(false);
|
||||
const [installProgress, setInstallProgress] = useState('');
|
||||
const [installError, setInstallError] = useState('');
|
||||
const langMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (langMenuRef.current && !langMenuRef.current.contains(e.target as Node)) {
|
||||
setShowLangMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getVersion().then(setAppVersion).catch(() => setAppVersion('1.0.0'));
|
||||
}, []);
|
||||
|
||||
// 启动时检查更新
|
||||
useEffect(() => {
|
||||
checkForUpdatesOnStartup().then((result) => {
|
||||
if (result.available) {
|
||||
setUpdateInfo(result);
|
||||
setShowUpdateBanner(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 启动时检测开发环境
|
||||
useEffect(() => {
|
||||
TauriAPI.checkEnvironment().then((result) => {
|
||||
setEnvCheck(result);
|
||||
// 如果环境就绪,在控制台显示信息
|
||||
if (result.ready) {
|
||||
console.log('[Environment] Ready ✓');
|
||||
console.log(`[Environment] esbuild: ${result.esbuild.version} (${result.esbuild.source})`);
|
||||
} else {
|
||||
// esbuild 未安装,显示安装对话框
|
||||
console.warn('[Environment] Not ready:', result.esbuild.error);
|
||||
setShowEsbuildInstall(true);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('[Environment] Check failed:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 监听 esbuild 安装进度事件
|
||||
useEffect(() => {
|
||||
let unlisten: UnlistenFn | undefined;
|
||||
|
||||
const setupListeners = async () => {
|
||||
// 监听安装进度
|
||||
unlisten = await listen<string>('esbuild-install:progress', (event) => {
|
||||
setInstallProgress(event.payload);
|
||||
});
|
||||
|
||||
// 监听安装成功
|
||||
const unlistenSuccess = await listen('esbuild-install:success', async () => {
|
||||
// 重新检测环境
|
||||
const result = await TauriAPI.checkEnvironment();
|
||||
setEnvCheck(result);
|
||||
if (result.ready) {
|
||||
setShowEsbuildInstall(false);
|
||||
setIsInstallingEsbuild(false);
|
||||
setInstallProgress('');
|
||||
setInstallError('');
|
||||
}
|
||||
});
|
||||
|
||||
// 监听安装错误
|
||||
const unlistenError = await listen<string>('esbuild-install:error', (event) => {
|
||||
setInstallError(event.payload);
|
||||
setIsInstallingEsbuild(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten?.();
|
||||
unlistenSuccess();
|
||||
unlistenError();
|
||||
};
|
||||
};
|
||||
|
||||
setupListeners();
|
||||
|
||||
return () => {
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 处理 esbuild 安装
|
||||
const handleInstallEsbuild = async () => {
|
||||
setIsInstallingEsbuild(true);
|
||||
setInstallProgress(t('startup.installingEsbuild'));
|
||||
setInstallError('');
|
||||
|
||||
try {
|
||||
await TauriAPI.installEsbuild();
|
||||
// 成功会通过事件处理
|
||||
} catch (error) {
|
||||
console.error('[Environment] Failed to install esbuild:', error);
|
||||
setInstallError(String(error));
|
||||
setIsInstallingEsbuild(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallUpdate = async () => {
|
||||
setIsInstalling(true);
|
||||
const success = await installUpdate();
|
||||
if (!success) {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
// 如果成功,应用会重启,不需要处理
|
||||
};
|
||||
|
||||
const versionText = `${t('startup.version')} ${appVersion}`;
|
||||
|
||||
const handleLogoComplete = () => {
|
||||
setShowLogo(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="startup-page">
|
||||
{showLogo && <StartupLogo onAnimationComplete={handleLogoComplete} />}
|
||||
<div className="startup-header">
|
||||
<h1 className="startup-title">{t('startup.title')}</h1>
|
||||
<p className="startup-subtitle">{t('startup.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="startup-content">
|
||||
<div className="startup-actions">
|
||||
<button className="startup-action-btn primary" onClick={onOpenProject}>
|
||||
<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"/>
|
||||
</svg>
|
||||
<span>{t('startup.openProject')}</span>
|
||||
</button>
|
||||
|
||||
<button className="startup-action-btn" onClick={onCreateProject}>
|
||||
<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('startup.createProject')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="startup-recent">
|
||||
<h2 className="recent-title">{t('startup.recentProjects')}</h2>
|
||||
{recentProjects.length === 0 ? (
|
||||
<p className="recent-empty">{t('startup.noRecentProjects')}</p>
|
||||
) : (
|
||||
<ul className="recent-list">
|
||||
{recentProjects.map((project, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className={`recent-item ${hoveredProject === project ? 'hovered' : ''}`}
|
||||
onMouseEnter={() => setHoveredProject(project)}
|
||||
onMouseLeave={() => setHoveredProject(null)}
|
||||
onClick={() => onOpenRecentProject?.(project)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, 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"/>
|
||||
</svg>
|
||||
<div className="recent-info">
|
||||
<div className="recent-name">{project.split(/[\\/]/).pop()}</div>
|
||||
<div className="recent-path">{project}</div>
|
||||
</div>
|
||||
{onRemoveRecentProject && (
|
||||
<button
|
||||
className="recent-remove-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveRecentProject(project);
|
||||
}}
|
||||
title={t('startup.removeFromList')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 更新提示条 */}
|
||||
{showUpdateBanner && updateInfo?.available && (
|
||||
<div className="startup-update-banner">
|
||||
<div className="update-banner-content">
|
||||
<Download size={16} />
|
||||
<span className="update-banner-text">
|
||||
{t('startup.updateAvailable')}: v{updateInfo.version}
|
||||
</span>
|
||||
<button
|
||||
className="update-banner-btn primary"
|
||||
onClick={handleInstallUpdate}
|
||||
disabled={isInstalling}
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{t('startup.installing')}
|
||||
</>
|
||||
) : (
|
||||
t('startup.updateNow')
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="update-banner-close"
|
||||
onClick={() => setShowUpdateBanner(false)}
|
||||
disabled={isInstalling}
|
||||
title={t('startup.later')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="startup-footer">
|
||||
<span className="startup-version">{versionText}</span>
|
||||
|
||||
{/* 环境状态指示器 | Environment Status Indicator */}
|
||||
{envCheck && (
|
||||
<div
|
||||
className={`startup-env-status ${envCheck.ready ? 'ready' : 'warning'}`}
|
||||
onClick={() => setShowEnvStatus(!showEnvStatus)}
|
||||
title={envCheck.ready ? t('startup.envReady') : t('startup.envNotReady')}
|
||||
>
|
||||
{envCheck.ready ? (
|
||||
<CheckCircle size={14} />
|
||||
) : (
|
||||
<AlertCircle size={14} />
|
||||
)}
|
||||
{showEnvStatus && (
|
||||
<div className="startup-env-tooltip">
|
||||
<div className="env-tooltip-title">
|
||||
{envCheck.ready ? t('startup.envReady') : t('startup.envNotReady')}
|
||||
</div>
|
||||
<div className={`env-tooltip-item ${envCheck.esbuild.available ? 'ok' : 'error'}`}>
|
||||
{envCheck.esbuild.available ? (
|
||||
<>
|
||||
<CheckCircle size={12} />
|
||||
<span>esbuild {envCheck.esbuild.version}</span>
|
||||
<span className="env-source">({envCheck.esbuild.source})</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle size={12} />
|
||||
<span>{t('startup.esbuildMissing')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onLocaleChange && (
|
||||
<div className="startup-locale-dropdown" ref={langMenuRef}>
|
||||
<button
|
||||
className="startup-locale-btn"
|
||||
onClick={() => setShowLangMenu(!showLangMenu)}
|
||||
>
|
||||
<Globe size={14} />
|
||||
<span>{LANGUAGES.find(l => l.code === locale)?.name || 'English'}</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{showLangMenu && (
|
||||
<div className="startup-locale-menu">
|
||||
{LANGUAGES.map(lang => (
|
||||
<button
|
||||
key={lang.code}
|
||||
className={`startup-locale-item ${locale === lang.code ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
onLocaleChange(lang.code as Locale);
|
||||
setShowLangMenu(false);
|
||||
}}
|
||||
>
|
||||
{lang.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右键菜单 | Context Menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="startup-context-menu-overlay"
|
||||
onClick={() => setContextMenu(null)}
|
||||
>
|
||||
<div
|
||||
className="startup-context-menu"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="startup-context-menu-item"
|
||||
onClick={() => {
|
||||
onRemoveRecentProject?.(contextMenu.project);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
<span>{t('startup.removeFromList')}</span>
|
||||
</button>
|
||||
{onDeleteProject && (
|
||||
<button
|
||||
className="startup-context-menu-item danger"
|
||||
onClick={() => {
|
||||
setDeleteConfirm(contextMenu.project);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>{t('startup.deleteProject')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认对话框 | Delete Confirmation Dialog */}
|
||||
{deleteConfirm && (
|
||||
<div className="startup-dialog-overlay">
|
||||
<div className="startup-dialog">
|
||||
<div className="startup-dialog-header">
|
||||
<Trash2 size={20} className="dialog-icon-danger" />
|
||||
<h3>{t('startup.deleteConfirmTitle')}</h3>
|
||||
</div>
|
||||
<div className="startup-dialog-body">
|
||||
<p>{t('startup.deleteConfirmMessage')}</p>
|
||||
<p className="startup-dialog-path">{deleteConfirm}</p>
|
||||
</div>
|
||||
<div className="startup-dialog-footer">
|
||||
<button
|
||||
className="startup-dialog-btn"
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
>
|
||||
{t('startup.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="startup-dialog-btn danger"
|
||||
onClick={async () => {
|
||||
if (deleteConfirm && onDeleteProject) {
|
||||
try {
|
||||
await onDeleteProject(deleteConfirm);
|
||||
} catch (error) {
|
||||
console.error('[StartupPage] Failed to delete project:', error);
|
||||
// Error will be handled by App.tsx error dialog
|
||||
}
|
||||
}
|
||||
setDeleteConfirm(null);
|
||||
}}
|
||||
>
|
||||
{t('startup.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* esbuild 安装对话框 | esbuild Installation Dialog */}
|
||||
{showEsbuildInstall && (
|
||||
<div className="startup-dialog-overlay">
|
||||
<div className="startup-dialog">
|
||||
<div className="startup-dialog-header">
|
||||
<Terminal size={20} className="dialog-icon-info" />
|
||||
<h3>{t('startup.esbuildNotInstalled')}</h3>
|
||||
</div>
|
||||
<div className="startup-dialog-body">
|
||||
<p>{t('startup.esbuildRequired')}</p>
|
||||
<p className="startup-dialog-info">{t('startup.esbuildInstallPrompt')}</p>
|
||||
|
||||
{/* 安装进度 | Installation Progress */}
|
||||
{isInstallingEsbuild && (
|
||||
<div className="startup-dialog-progress">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span>{installProgress}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误信息 | Error Message */}
|
||||
{installError && (
|
||||
<div className="startup-dialog-error">
|
||||
<AlertCircle size={16} />
|
||||
<span>{installError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="startup-dialog-footer">
|
||||
<button
|
||||
className="startup-dialog-btn primary"
|
||||
onClick={handleInstallEsbuild}
|
||||
disabled={isInstallingEsbuild}
|
||||
>
|
||||
{isInstallingEsbuild ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{t('startup.installing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={14} />
|
||||
{t('startup.installNow')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { FolderOpen, FileText, Terminal, ChevronDown, ChevronUp, Activity, Wifi, Save, GitBranch, X, LayoutGrid } from 'lucide-react';
|
||||
import type { MessageHub, LogService } from '@esengine/editor-core';
|
||||
import { ContentBrowser } from './ContentBrowser';
|
||||
import { OutputLogPanel } from './OutputLogPanel';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import '../styles/StatusBar.css';
|
||||
|
||||
interface StatusBarProps {
|
||||
pluginCount?: number;
|
||||
entityCount?: number;
|
||||
messageHub?: MessageHub | null;
|
||||
logService?: LogService | null;
|
||||
locale?: string;
|
||||
projectPath?: string | null;
|
||||
onOpenScene?: (scenePath: string) => void;
|
||||
/** 停靠内容管理器到布局中的回调 | Callback to dock content browser in layout */
|
||||
onDockContentBrowser?: () => void;
|
||||
/** 重置布局回调 | Callback to reset layout */
|
||||
onResetLayout?: () => void;
|
||||
}
|
||||
|
||||
type ActiveTab = 'output' | 'cmd';
|
||||
|
||||
export function StatusBar({
|
||||
pluginCount = 0,
|
||||
entityCount = 0,
|
||||
messageHub,
|
||||
logService,
|
||||
locale = 'en',
|
||||
projectPath,
|
||||
onOpenScene,
|
||||
onDockContentBrowser,
|
||||
onResetLayout
|
||||
}: StatusBarProps) {
|
||||
const { t } = useLocale();
|
||||
const [consoleInput, setConsoleInput] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('output');
|
||||
const [contentDrawerOpen, setContentDrawerOpen] = useState(false);
|
||||
const [outputLogDrawerOpen, setOutputLogDrawerOpen] = useState(false);
|
||||
const [contentDrawerHeight, setContentDrawerHeight] = useState(300);
|
||||
const [outputLogDrawerHeight, setOutputLogDrawerHeight] = useState(300);
|
||||
const [isResizingContent, setIsResizingContent] = useState(false);
|
||||
const [isResizingOutputLog, setIsResizingOutputLog] = useState(false);
|
||||
const [revealPath, setRevealPath] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const startY = useRef(0);
|
||||
const startHeight = useRef(0);
|
||||
|
||||
// Subscribe to asset:reveal event
|
||||
useEffect(() => {
|
||||
if (!messageHub) return;
|
||||
|
||||
const unsubscribe = messageHub.subscribe('asset:reveal', (payload: { path: string }) => {
|
||||
if (payload.path) {
|
||||
// Generate unique key to force re-trigger even with same path
|
||||
setRevealPath(`${payload.path}?t=${Date.now()}`);
|
||||
setContentDrawerOpen(true);
|
||||
setOutputLogDrawerOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [messageHub]);
|
||||
|
||||
// Clear revealPath when drawer closes
|
||||
useEffect(() => {
|
||||
if (!contentDrawerOpen) {
|
||||
setRevealPath(null);
|
||||
}
|
||||
}, [contentDrawerOpen]);
|
||||
|
||||
const handleSelectPanel = useCallback((panelId: string) => {
|
||||
if (messageHub) {
|
||||
messageHub.publish('panel:select', { panelId });
|
||||
}
|
||||
}, [messageHub]);
|
||||
|
||||
const handleContentDrawerClick = useCallback(() => {
|
||||
setContentDrawerOpen(!contentDrawerOpen);
|
||||
if (!contentDrawerOpen) {
|
||||
setOutputLogDrawerOpen(false);
|
||||
}
|
||||
}, [contentDrawerOpen]);
|
||||
|
||||
const handleOutputLogClick = useCallback(() => {
|
||||
setActiveTab('output');
|
||||
setOutputLogDrawerOpen(!outputLogDrawerOpen);
|
||||
if (!outputLogDrawerOpen) {
|
||||
setContentDrawerOpen(false);
|
||||
}
|
||||
}, [outputLogDrawerOpen]);
|
||||
|
||||
const handleCmdClick = useCallback(() => {
|
||||
setActiveTab('cmd');
|
||||
handleSelectPanel('console');
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
}, [handleSelectPanel]);
|
||||
|
||||
const handleConsoleSubmit = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && consoleInput.trim()) {
|
||||
const command = consoleInput.trim();
|
||||
|
||||
console.info(`> ${command}`);
|
||||
|
||||
try {
|
||||
if (command.startsWith('help')) {
|
||||
console.info('Available commands: help, clear, echo <message>');
|
||||
} else if (command === 'clear') {
|
||||
logService?.clear();
|
||||
} else if (command.startsWith('echo ')) {
|
||||
console.info(command.substring(5));
|
||||
} else {
|
||||
console.warn(`Unknown command: ${command}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing command: ${error}`);
|
||||
}
|
||||
|
||||
setConsoleInput('');
|
||||
}
|
||||
}, [consoleInput, logService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'cmd') {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Handle content drawer resize
|
||||
const handleContentResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizingContent(true);
|
||||
startY.current = e.clientY;
|
||||
startHeight.current = contentDrawerHeight;
|
||||
}, [contentDrawerHeight]);
|
||||
|
||||
// Handle output log drawer resize
|
||||
const handleOutputLogResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizingOutputLog(true);
|
||||
startY.current = e.clientY;
|
||||
startHeight.current = outputLogDrawerHeight;
|
||||
}, [outputLogDrawerHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizingContent && !isResizingOutputLog) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = startY.current - e.clientY;
|
||||
const newHeight = Math.max(200, Math.min(startHeight.current + delta, window.innerHeight * 0.7));
|
||||
if (isResizingContent) {
|
||||
setContentDrawerHeight(newHeight);
|
||||
} else if (isResizingOutputLog) {
|
||||
setOutputLogDrawerHeight(newHeight);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizingContent(false);
|
||||
setIsResizingOutputLog(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isResizingContent, isResizingOutputLog]);
|
||||
|
||||
// Close drawer on Escape
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (contentDrawerOpen) {
|
||||
setContentDrawerOpen(false);
|
||||
}
|
||||
if (outputLogDrawerOpen) {
|
||||
setOutputLogDrawerOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [contentDrawerOpen, outputLogDrawerOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Drawer Backdrop */}
|
||||
{(contentDrawerOpen || outputLogDrawerOpen) && (
|
||||
<div
|
||||
className="drawer-backdrop"
|
||||
onClick={() => {
|
||||
setContentDrawerOpen(false);
|
||||
setOutputLogDrawerOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content Drawer Panel */}
|
||||
<div
|
||||
className={`drawer-panel content-drawer-panel ${contentDrawerOpen ? 'open' : ''}`}
|
||||
style={{ height: contentDrawerOpen ? contentDrawerHeight : 0 }}
|
||||
>
|
||||
<div
|
||||
className="drawer-resize-handle"
|
||||
onMouseDown={handleContentResizeStart}
|
||||
/>
|
||||
<div className="drawer-header">
|
||||
<span className="drawer-title">
|
||||
<FolderOpen size={14} />
|
||||
Content Browser
|
||||
</span>
|
||||
<button
|
||||
className="drawer-close"
|
||||
onClick={() => setContentDrawerOpen(false)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="drawer-body">
|
||||
<ContentBrowser
|
||||
projectPath={projectPath ?? null}
|
||||
locale={locale}
|
||||
onOpenScene={onOpenScene}
|
||||
isDrawer={true}
|
||||
revealPath={revealPath}
|
||||
onDockInLayout={() => {
|
||||
// 关闭抽屉并停靠到布局 | Close drawer and dock to layout
|
||||
setContentDrawerOpen(false);
|
||||
onDockContentBrowser?.();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Log Drawer Panel */}
|
||||
<div
|
||||
className={`drawer-panel output-log-drawer-panel ${outputLogDrawerOpen ? 'open' : ''}`}
|
||||
style={{ height: outputLogDrawerOpen ? outputLogDrawerHeight : 0 }}
|
||||
>
|
||||
<div
|
||||
className="drawer-resize-handle"
|
||||
onMouseDown={handleOutputLogResizeStart}
|
||||
/>
|
||||
<div className="drawer-body output-log-body">
|
||||
{logService && (
|
||||
<OutputLogPanel
|
||||
logService={logService}
|
||||
locale={locale}
|
||||
onClose={() => setOutputLogDrawerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="status-bar">
|
||||
<div className="status-bar-left">
|
||||
<button
|
||||
className={`status-bar-btn drawer-toggle-btn ${contentDrawerOpen ? 'active' : ''}`}
|
||||
onClick={handleContentDrawerClick}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
<span>{t('statusBar.contentDrawer')}</span>
|
||||
{contentDrawerOpen ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
|
||||
</button>
|
||||
|
||||
<div className="status-bar-divider" />
|
||||
|
||||
<button
|
||||
className={`status-bar-tab ${outputLogDrawerOpen ? 'active' : ''}`}
|
||||
onClick={handleOutputLogClick}
|
||||
>
|
||||
<FileText size={12} />
|
||||
<span>{t('statusBar.outputLog')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`status-bar-tab ${activeTab === 'cmd' ? 'active' : ''}`}
|
||||
onClick={handleCmdClick}
|
||||
>
|
||||
<Terminal size={12} />
|
||||
<span>Cmd</span>
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
|
||||
<div className="status-bar-console-input">
|
||||
<span className="console-prompt">></span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={t('statusBar.consolePlaceholder')}
|
||||
value={consoleInput}
|
||||
onChange={(e) => setConsoleInput(e.target.value)}
|
||||
onKeyDown={handleConsoleSubmit}
|
||||
onFocus={() => setActiveTab('cmd')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="status-bar-right">
|
||||
<button className="status-bar-indicator">
|
||||
<Activity size={12} />
|
||||
<span>{t('statusBar.trace')}</span>
|
||||
<ChevronDown size={10} />
|
||||
</button>
|
||||
|
||||
<div className="status-bar-divider" />
|
||||
|
||||
<div className="status-bar-icon-group">
|
||||
<button
|
||||
className="status-bar-icon-btn"
|
||||
title={t('statusBar.resetLayout')}
|
||||
onClick={onResetLayout}
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
</button>
|
||||
<button className="status-bar-icon-btn" title={t('statusBar.network')}>
|
||||
<Wifi size={14} />
|
||||
</button>
|
||||
<button className="status-bar-icon-btn" title={t('statusBar.sourceControl')}>
|
||||
<GitBranch size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="status-bar-divider" />
|
||||
|
||||
<div className="status-bar-info">
|
||||
<Save size={12} />
|
||||
<span>{t('statusBar.allSaved')}</span>
|
||||
</div>
|
||||
|
||||
<div className="status-bar-info">
|
||||
<span>{t('statusBar.revisionControl')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { UIRegistry, MessageHub, PluginManager, CommandManager } from '@esengine/editor-core';
|
||||
import type { MenuItem as PluginMenuItem } from '@esengine/editor-core';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import '../styles/TitleBar.css';
|
||||
|
||||
interface MenuItem {
|
||||
label?: string;
|
||||
shortcut?: string;
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
separator?: boolean;
|
||||
submenu?: MenuItem[];
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface TitleBarProps {
|
||||
projectName?: string;
|
||||
uiRegistry?: UIRegistry;
|
||||
messageHub?: MessageHub;
|
||||
pluginManager?: PluginManager;
|
||||
commandManager?: CommandManager;
|
||||
onNewScene?: () => void;
|
||||
onOpenScene?: () => void;
|
||||
onSaveScene?: () => void;
|
||||
onSaveSceneAs?: () => void;
|
||||
onOpenProject?: () => void;
|
||||
onCloseProject?: () => void;
|
||||
onExit?: () => void;
|
||||
onOpenPluginManager?: () => void;
|
||||
onOpenProfiler?: () => void;
|
||||
onOpenPortManager?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
onToggleDevtools?: () => void;
|
||||
onOpenAbout?: () => void;
|
||||
onCreatePlugin?: () => void;
|
||||
onReloadPlugins?: () => void;
|
||||
onOpenBuildSettings?: () => void;
|
||||
onOpenRenderDebug?: () => void;
|
||||
}
|
||||
|
||||
export function TitleBar({
|
||||
projectName = 'Untitled',
|
||||
uiRegistry,
|
||||
messageHub,
|
||||
pluginManager,
|
||||
commandManager,
|
||||
onNewScene,
|
||||
onOpenScene,
|
||||
onSaveScene,
|
||||
onSaveSceneAs,
|
||||
onOpenProject,
|
||||
onCloseProject,
|
||||
onExit,
|
||||
onOpenPluginManager,
|
||||
onOpenProfiler: _onOpenProfiler,
|
||||
onOpenPortManager,
|
||||
onOpenSettings,
|
||||
onToggleDevtools,
|
||||
onOpenAbout,
|
||||
onCreatePlugin,
|
||||
onReloadPlugins,
|
||||
onOpenBuildSettings,
|
||||
onOpenRenderDebug
|
||||
}: TitleBarProps) {
|
||||
const { t } = useLocale();
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
// Update undo/redo state | 更新撤销/重做状态
|
||||
const updateUndoRedoState = useCallback(() => {
|
||||
if (commandManager) {
|
||||
setCanUndo(commandManager.canUndo());
|
||||
setCanRedo(commandManager.canRedo());
|
||||
}
|
||||
}, [commandManager]);
|
||||
|
||||
// Handle undo | 处理撤销
|
||||
const handleUndo = useCallback(() => {
|
||||
if (commandManager && commandManager.canUndo()) {
|
||||
commandManager.undo();
|
||||
updateUndoRedoState();
|
||||
}
|
||||
}, [commandManager, updateUndoRedoState]);
|
||||
|
||||
// Handle redo | 处理重做
|
||||
const handleRedo = useCallback(() => {
|
||||
if (commandManager && commandManager.canRedo()) {
|
||||
commandManager.redo();
|
||||
updateUndoRedoState();
|
||||
}
|
||||
}, [commandManager, updateUndoRedoState]);
|
||||
|
||||
// Update undo/redo state periodically | 定期更新撤销/重做状态
|
||||
useEffect(() => {
|
||||
updateUndoRedoState();
|
||||
const interval = setInterval(updateUndoRedoState, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, [updateUndoRedoState]);
|
||||
|
||||
const updateMenuItems = () => {
|
||||
if (uiRegistry) {
|
||||
const items = uiRegistry.getChildMenus('window');
|
||||
setPluginMenuItems(items);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateMenuItems();
|
||||
}, [uiRegistry, pluginManager]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messageHub) {
|
||||
const unsubscribeInstalled = messageHub.subscribe('plugin:installed', () => {
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
const unsubscribeEnabled = messageHub.subscribe('plugin:enabled', () => {
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
const unsubscribeDisabled = messageHub.subscribe('plugin:disabled', () => {
|
||||
updateMenuItems();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeInstalled();
|
||||
unsubscribeEnabled();
|
||||
unsubscribeDisabled();
|
||||
};
|
||||
}
|
||||
}, [messageHub, uiRegistry, pluginManager]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMaximized = async () => {
|
||||
const maximized = await appWindow.isMaximized();
|
||||
setIsMaximized(maximized);
|
||||
};
|
||||
|
||||
checkMaximized();
|
||||
|
||||
const unlisten = appWindow.onResized(async () => {
|
||||
const maximized = await appWindow.isMaximized();
|
||||
setIsMaximized(maximized);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then(fn => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const menus: Record<string, MenuItem[]> = {
|
||||
file: [
|
||||
{ label: t('menu.file.newScene'), shortcut: 'Ctrl+N', onClick: onNewScene },
|
||||
{ label: t('menu.file.openScene'), shortcut: 'Ctrl+O', onClick: onOpenScene },
|
||||
{ separator: true },
|
||||
{ label: t('menu.file.saveScene'), shortcut: 'Ctrl+S', onClick: onSaveScene },
|
||||
{ label: t('menu.file.saveSceneAs'), shortcut: 'Ctrl+Shift+S', onClick: onSaveSceneAs },
|
||||
{ separator: true },
|
||||
{ label: t('menu.file.buildSettings'), shortcut: 'Ctrl+Shift+B', onClick: onOpenBuildSettings },
|
||||
{ separator: true },
|
||||
{ label: t('menu.file.openProject'), onClick: onOpenProject },
|
||||
{ label: t('menu.file.closeProject'), onClick: onCloseProject },
|
||||
{ separator: true },
|
||||
{ label: t('menu.file.exit'), onClick: onExit }
|
||||
],
|
||||
edit: [
|
||||
{ label: t('menu.edit.undo'), shortcut: 'Ctrl+Z', disabled: !canUndo, onClick: handleUndo },
|
||||
{ label: t('menu.edit.redo'), shortcut: 'Ctrl+Y', disabled: !canRedo, onClick: handleRedo },
|
||||
{ separator: true },
|
||||
{ label: t('menu.edit.cut'), shortcut: 'Ctrl+X', disabled: true },
|
||||
{ label: t('menu.edit.copy'), shortcut: 'Ctrl+C', disabled: true },
|
||||
{ label: t('menu.edit.paste'), shortcut: 'Ctrl+V', disabled: true },
|
||||
{ label: t('menu.edit.delete'), shortcut: 'Delete', disabled: true },
|
||||
{ separator: true },
|
||||
{ label: t('menu.edit.selectAll'), shortcut: 'Ctrl+A', disabled: true }
|
||||
],
|
||||
window: [
|
||||
...pluginMenuItems.map((item) => ({
|
||||
label: item.label || '',
|
||||
icon: item.icon,
|
||||
disabled: item.disabled,
|
||||
onClick: item.onClick
|
||||
})),
|
||||
...(pluginMenuItems.length > 0 ? [{ separator: true } as MenuItem] : []),
|
||||
{ label: t('menu.window.pluginManager'), onClick: onOpenPluginManager },
|
||||
{ separator: true },
|
||||
{ label: t('menu.window.devtools'), onClick: onToggleDevtools }
|
||||
],
|
||||
tools: [
|
||||
{ label: t('menu.tools.createPlugin'), onClick: onCreatePlugin },
|
||||
{ label: t('menu.tools.reloadPlugins'), shortcut: 'Ctrl+R', onClick: onReloadPlugins },
|
||||
{ separator: true },
|
||||
{ label: t('menu.tools.portManager'), onClick: onOpenPortManager },
|
||||
{ label: t('menu.tools.renderDebug'), onClick: onOpenRenderDebug },
|
||||
{ separator: true },
|
||||
{ label: t('menu.tools.settings'), onClick: onOpenSettings }
|
||||
],
|
||||
help: [
|
||||
{ label: t('menu.help.documentation'), disabled: true },
|
||||
{ separator: true },
|
||||
{ label: t('menu.help.about'), onClick: onOpenAbout }
|
||||
]
|
||||
};
|
||||
|
||||
// 菜单键到翻译键的映射 | Map menu keys to translation keys
|
||||
const menuTitleKeys: Record<string, string> = {
|
||||
file: 'menu.file.title',
|
||||
edit: 'menu.edit.title',
|
||||
window: 'menu.window.title',
|
||||
tools: 'menu.tools.title',
|
||||
help: 'menu.help.title'
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpenMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMenuClick = (menuKey: string) => {
|
||||
setOpenMenu(openMenu === menuKey ? null : menuKey);
|
||||
};
|
||||
|
||||
const handleMenuItemClick = (item: MenuItem) => {
|
||||
if (!item.disabled && !item.separator && item.onClick && item.label) {
|
||||
item.onClick();
|
||||
setOpenMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMinimize = async () => {
|
||||
await appWindow.minimize();
|
||||
};
|
||||
|
||||
const handleMaximize = async () => {
|
||||
await appWindow.toggleMaximize();
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
await appWindow.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="titlebar">
|
||||
{/* Left: Logo and Menu */}
|
||||
<div className="titlebar-left">
|
||||
<div className="titlebar-logo">
|
||||
<span className="titlebar-logo-text">ES</span>
|
||||
</div>
|
||||
<div className="titlebar-menus" ref={menuRef}>
|
||||
{Object.keys(menus).map((menuKey) => (
|
||||
<div key={menuKey} className="titlebar-menu-item">
|
||||
<button
|
||||
className={`titlebar-menu-button ${openMenu === menuKey ? 'active' : ''}`}
|
||||
onClick={() => handleMenuClick(menuKey)}
|
||||
>
|
||||
{t(menuTitleKeys[menuKey] || menuKey)}
|
||||
</button>
|
||||
{openMenu === menuKey && menus[menuKey] && (
|
||||
<div className="titlebar-dropdown">
|
||||
{menus[menuKey].map((item, index) => {
|
||||
if (item.separator) {
|
||||
return <div key={index} className="titlebar-dropdown-separator" />;
|
||||
}
|
||||
const IconComponent = item.icon ? (LucideIcons as any)[item.icon] : null;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={`titlebar-dropdown-item ${item.disabled ? 'disabled' : ''}`}
|
||||
onClick={() => handleMenuItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<span className="titlebar-dropdown-item-content">
|
||||
{IconComponent && <IconComponent size={14} />}
|
||||
<span>{item.label || ''}</span>
|
||||
</span>
|
||||
{item.shortcut && <span className="titlebar-dropdown-shortcut">{item.shortcut}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Draggable area */}
|
||||
<div className="titlebar-center" data-tauri-drag-region />
|
||||
|
||||
{/* Right: Project name + Window controls */}
|
||||
<div className="titlebar-right">
|
||||
<span className="titlebar-project-name" data-tauri-drag-region>{projectName}</span>
|
||||
<div className="titlebar-window-controls">
|
||||
<button className="titlebar-button" onClick={handleMinimize} title={t('titleBar.minimize')}>
|
||||
<svg width="10" height="1" viewBox="0 0 10 1">
|
||||
<rect width="10" height="1" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button className="titlebar-button" onClick={handleMaximize} title={isMaximized ? t('titleBar.restore') : t('titleBar.maximize')}>
|
||||
{isMaximized ? (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<path d="M2 0v2H0v8h8V8h2V0H2zm6 8H2V4h6v4z" fill="currentColor"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<rect width="10" height="10" fill="none" stroke="currentColor" strokeWidth="1"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button className="titlebar-button titlebar-button-close" onClick={handleClose} title={t('titleBar.close')}>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react';
|
||||
import '../styles/Toast.css';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (message: string, type?: ToastType, duration?: number) => void;
|
||||
hideToast: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface ToastProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
|
||||
const id = `toast-${Date.now()}-${Math.random()}`;
|
||||
const toast: Toast = { id, message, type, duration };
|
||||
|
||||
setToasts((prev) => [...prev, toast]);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
hideToast(id);
|
||||
}, duration);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hideToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const getIcon = (type: ToastType) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircle size={20} />;
|
||||
case 'error':
|
||||
return <XCircle size={20} />;
|
||||
case 'warning':
|
||||
return <AlertCircle size={20} />;
|
||||
case 'info':
|
||||
return <Info size={20} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast, hideToast }}>
|
||||
{children}
|
||||
<div className="toast-container">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className={`toast toast-${toast.type}`}>
|
||||
<div className="toast-icon">
|
||||
{getIcon(toast.type)}
|
||||
</div>
|
||||
<div className="toast-message">{toast.message}</div>
|
||||
<button
|
||||
className="toast-close"
|
||||
onClick={() => hideToast(toast.id)}
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,817 @@
|
||||
/**
|
||||
* 渲染调试面板样式 (浮动窗口)
|
||||
* Render Debug Panel Styles (Floating Window)
|
||||
*/
|
||||
|
||||
/* ==================== Floating Window ==================== */
|
||||
.render-debug-window {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.render-debug-window.dragging {
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 独立窗口模式 | Standalone mode */
|
||||
.render-debug-window.standalone {
|
||||
position: relative;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.render-debug-window.standalone .window-header {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ==================== Window Header ==================== */
|
||||
.render-debug-window .window-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
cursor: move;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.render-debug-window .window-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.render-debug-window .window-title svg {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.render-debug-window .paused-badge {
|
||||
padding: 2px 6px;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.render-debug-window .window-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.render-debug-window .window-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.render-debug-window .window-btn:hover {
|
||||
background: #3a3a3a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ==================== Toolbar ==================== */
|
||||
.render-debug-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
background: #262626;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-left,
|
||||
.render-debug-toolbar .toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 3px;
|
||||
color: #ccc;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn:hover {
|
||||
background: #4a4a4a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn.active {
|
||||
background: #4a9eff;
|
||||
border-color: #4a9eff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn.icon-only {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn.recording {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn .record-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.render-debug-toolbar .history-badge {
|
||||
padding: 2px 6px;
|
||||
background: #8b5cf6;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.5px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-btn:disabled:hover {
|
||||
background: #3a3a3a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* ==================== Timeline ==================== */
|
||||
.render-debug-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background: #222;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: #333;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #4a9eff;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-slider::-webkit-slider-thumb:active {
|
||||
cursor: grabbing;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #4a9eff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.render-debug-timeline .timeline-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .toolbar-separator {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.render-debug-toolbar .frame-counter {
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
/* ==================== Main Layout ==================== */
|
||||
.render-debug-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ==================== Left Panel (Event List) ==================== */
|
||||
.render-debug-left {
|
||||
width: 260px;
|
||||
min-width: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #222;
|
||||
border-right: 1px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
background: #262626;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.event-list-header .event-count {
|
||||
font-weight: 400;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.event-list::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.event-list::-webkit-scrollbar-track {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.event-list::-webkit-scrollbar-thumb {
|
||||
background: #3a3a3a;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.event-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #555;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Event Items */
|
||||
.event-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 10px;
|
||||
color: #bbb;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.event-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.event-item.selected {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
border-left: 2px solid #4a9eff;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.event-item .expand-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-item .expand-icon:not(.placeholder):hover {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.event-item .expand-icon.placeholder {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.event-item .event-icon {
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.event-item .event-icon.sprite {
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
.event-item .event-icon.particle {
|
||||
color: #ffb74d;
|
||||
}
|
||||
|
||||
.event-item .event-icon.ui {
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
.event-item .event-icon.batch {
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
.event-item .event-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-item .event-draws {
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
padding: 1px 3px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Batch breaker item highlight */
|
||||
.event-item.batch-breaker {
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
|
||||
.event-item.batch-breaker:hover {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
|
||||
.event-item .event-name.batch-breaker {
|
||||
color: #f59e0b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.event-item .event-icon.breaker {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* ==================== Right Panel ==================== */
|
||||
.render-debug-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Preview Section */
|
||||
.render-debug-preview {
|
||||
height: 40%;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
padding: 6px 10px;
|
||||
background: #262626;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.preview-canvas-container {
|
||||
flex: 1;
|
||||
background: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-canvas-container canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Details Section */
|
||||
.render-debug-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.details-header {
|
||||
padding: 6px 10px;
|
||||
background: #262626;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.details-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.details-content::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.details-content::-webkit-scrollbar-track {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.details-content::-webkit-scrollbar-thumb {
|
||||
background: #3a3a3a;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.details-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #555;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Details Grid */
|
||||
.details-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.details-section {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 8px 0 3px 0;
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.details-section:first-child {
|
||||
margin-top: 0;
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 3px 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.detail-row .detail-label {
|
||||
width: 100px;
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-row .detail-value {
|
||||
flex: 1;
|
||||
color: #ccc;
|
||||
font-family: 'Consolas', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-row.highlight .detail-value {
|
||||
color: #4fc3f7;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Batch fix tip */
|
||||
.batch-fix-tip {
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #ffc107;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Batch breaker warning */
|
||||
.batch-breaker-warning {
|
||||
color: #f59e0b !important;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border-radius: 3px;
|
||||
padding: 4px 8px !important;
|
||||
margin: 0 !important;
|
||||
border-top: none !important;
|
||||
}
|
||||
|
||||
/* ==================== Stats Bar ==================== */
|
||||
.render-debug-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 6px 12px;
|
||||
background: #262626;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.render-debug-stats .stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.render-debug-stats .stat-item svg {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
/* ==================== Resize Handle ==================== */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: se-resize;
|
||||
background: linear-gradient(135deg, transparent 50%, #3a3a3a 50%);
|
||||
border-radius: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: linear-gradient(135deg, transparent 50%, #4a9eff 50%);
|
||||
}
|
||||
|
||||
/* ==================== TextureSheet Preview ==================== */
|
||||
.texture-sheet-preview {
|
||||
margin-top: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.texture-sheet-preview canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* ==================== Texture Preview ==================== */
|
||||
.texture-preview-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 3px 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.texture-preview-row .detail-label {
|
||||
width: 100px;
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.texture-preview-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.texture-thumbnail-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.texture-thumbnail {
|
||||
max-width: 100%;
|
||||
max-height: 80px;
|
||||
object-fit: contain;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #333;
|
||||
background: repeating-conic-gradient(#2a2a2a 0% 25%, #1a1a1a 0% 50%) 50% / 8px 8px;
|
||||
}
|
||||
|
||||
.texture-path {
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* ==================== Clickable Stats ==================== */
|
||||
.render-debug-stats .stat-item.clickable {
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.render-debug-stats .stat-item.clickable:hover {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.render-debug-stats .stat-item.atlas-enabled {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.render-debug-stats .stat-item.atlas-disabled {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* ==================== Atlas Preview Modal ==================== */
|
||||
.atlas-preview-modal {
|
||||
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: 2000;
|
||||
}
|
||||
|
||||
.atlas-preview-content {
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 8px;
|
||||
width: 600px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.atlas-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.atlas-page-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background: #252525;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.atlas-page-tab {
|
||||
padding: 4px 10px;
|
||||
background: #333;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
color: #aaa;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.atlas-page-tab:hover {
|
||||
background: #3a3a3a;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.atlas-page-tab.active {
|
||||
background: #4a9eff;
|
||||
border-color: #4a9eff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.atlas-preview-canvas-container {
|
||||
flex: 1;
|
||||
min-height: 350px;
|
||||
padding: 12px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.atlas-preview-canvas-container canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.atlas-preview-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: #252525;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.atlas-preview-info .hint {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.atlas-entry-info {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.atlas-entry-info .label {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.atlas-entry-info .value {
|
||||
color: #4a9eff;
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.atlas-preview-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 8px 14px;
|
||||
background: #2d2d2d;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.atlas-preview-stats .error {
|
||||
color: #ef4444;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 调试组件导出
|
||||
* Debug components export
|
||||
*/
|
||||
|
||||
export { RenderDebugPanel } from './RenderDebugPanel';
|
||||
export type { default as RenderDebugPanelProps } from './RenderDebugPanel';
|
||||
@@ -0,0 +1,352 @@
|
||||
.asset-picker-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.asset-picker-dialog {
|
||||
width: 480px;
|
||||
max-width: 90vw;
|
||||
max-height: 70vh;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.asset-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.asset-picker-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.asset-picker-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.asset-picker-close:hover {
|
||||
background: #333;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.asset-picker-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #252525;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.asset-picker-search svg {
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.asset-picker-search input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.asset-picker-search input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.asset-picker-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 200px;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.asset-picker-loading,
|
||||
.asset-picker-empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.asset-picker-tree {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.asset-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.asset-picker-item:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.asset-picker-item.selected {
|
||||
background: #0d47a1;
|
||||
}
|
||||
|
||||
.asset-picker-item__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.asset-picker-item.selected .asset-picker-item__icon {
|
||||
color: #90caf9;
|
||||
}
|
||||
|
||||
.asset-picker-item__name {
|
||||
font-size: 12px;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-picker-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #333;
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
.asset-picker-selected {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-picker-selected .placeholder {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.asset-picker-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.asset-picker-actions button {
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-cancel {
|
||||
background: #333;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-cancel:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-confirm {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-confirm:hover {
|
||||
background: #1565c0;
|
||||
}
|
||||
|
||||
.asset-picker-actions .btn-confirm:disabled {
|
||||
background: #333;
|
||||
color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Asset Save Dialog specific styles */
|
||||
.asset-save-filename {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #333;
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
.asset-save-filename label {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-save-filename input {
|
||||
flex: 1;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.asset-save-filename input:focus {
|
||||
border-color: #1976d2;
|
||||
}
|
||||
|
||||
.asset-save-extension {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* New folder styles */
|
||||
.asset-save-new-folder-btn {
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.asset-save-new-folder-btn button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: #333;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.asset-save-new-folder-btn button:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.asset-save-new-folder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid #333;
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
.asset-save-new-folder input {
|
||||
flex: 1;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.asset-save-new-folder input:focus {
|
||||
border-color: #1976d2;
|
||||
}
|
||||
|
||||
.asset-save-new-folder button {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.asset-save-new-folder button:first-of-type {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.asset-save-new-folder button:first-of-type:hover {
|
||||
background: #1565c0;
|
||||
}
|
||||
|
||||
.asset-save-new-folder button:first-of-type:disabled {
|
||||
background: #333;
|
||||
color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.asset-save-new-folder button:last-child {
|
||||
background: #333;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.asset-save-new-folder button:last-child:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
/* ==================== Managed Directory Styles ==================== */
|
||||
.asset-picker-item.managed-root .asset-picker-item__icon {
|
||||
color: #4fc1ff;
|
||||
}
|
||||
|
||||
.asset-picker-item.managed-root .managed-icon {
|
||||
color: #4fc1ff;
|
||||
}
|
||||
|
||||
.asset-picker-item .managed-badge {
|
||||
font-size: 9px;
|
||||
padding: 1px 4px;
|
||||
background: #4fc1ff22;
|
||||
color: #4fc1ff;
|
||||
border-radius: 3px;
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Disabled items (no GUID) */
|
||||
.asset-picker-item.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.asset-picker-item.disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.asset-picker-item .no-guid-badge {
|
||||
margin-left: auto;
|
||||
color: #f59e0b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { X, Search, Folder, FolderOpen, File, Image, FileText, Music, Video, Database, AlertTriangle } from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { ProjectService, AssetRegistryService, MANAGED_ASSET_DIRECTORIES } from '@esengine/editor-core';
|
||||
import { TauriFileSystemService } from '../../services/TauriFileSystemService';
|
||||
import './AssetPickerDialog.css';
|
||||
|
||||
interface AssetPickerDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (path: string) => void;
|
||||
title?: string;
|
||||
fileExtensions?: string[]; // e.g., ['.png', '.jpg']
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface FileNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
children?: FileNode[];
|
||||
/** Asset GUID (only for files with registered GUIDs) */
|
||||
guid?: string;
|
||||
/** Whether this is a root managed directory */
|
||||
isRootManaged?: boolean;
|
||||
}
|
||||
|
||||
export function AssetPickerDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
title = 'Select Asset',
|
||||
fileExtensions = [],
|
||||
placeholder = 'Search assets...'
|
||||
}: AssetPickerDialogProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [assets, setAssets] = useState<FileNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Get AssetRegistryService for GUID lookup
|
||||
const assetRegistry = useMemo(() => {
|
||||
return Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
|
||||
}, []);
|
||||
|
||||
// Load project assets - ONLY from managed directories (assets, scripts, scenes)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const loadAssets = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const projectService = Core.services.tryResolve(ProjectService);
|
||||
const fileSystem = new TauriFileSystemService();
|
||||
|
||||
const currentProject = projectService?.getCurrentProject();
|
||||
if (projectService && currentProject) {
|
||||
const projectPath = currentProject.path;
|
||||
const normalizedProjectPath = projectPath.replace(/\\/g, '/');
|
||||
|
||||
// 排除的目录名 | Excluded directory names
|
||||
const excludedDirs = new Set([
|
||||
'node_modules', '.git', '.idea', '.vscode', 'dist', 'build',
|
||||
'temp', 'tmp', '.cache', 'coverage', '__pycache__'
|
||||
]);
|
||||
|
||||
// Helper to get relative path from absolute path
|
||||
const getRelativePath = (absPath: string): string => {
|
||||
const normalizedAbs = absPath.replace(/\\/g, '/');
|
||||
if (normalizedAbs.startsWith(normalizedProjectPath)) {
|
||||
return normalizedAbs.substring(normalizedProjectPath.length + 1);
|
||||
}
|
||||
return absPath;
|
||||
};
|
||||
|
||||
const buildTree = async (dirPath: string): Promise<FileNode[]> => {
|
||||
const entries = await fileSystem.listDirectory(dirPath);
|
||||
const nodes: FileNode[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
// 跳过排除的目录 | Skip excluded directories
|
||||
if (entry.isDirectory && excludedDirs.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过隐藏文件/目录(以.开头,除了当前目录)
|
||||
// Skip hidden files/directories (starting with ., except current dir)
|
||||
if (entry.name.startsWith('.') && entry.name !== '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip .meta files
|
||||
if (entry.name.endsWith('.meta')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const node: FileNode = {
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
isDirectory: entry.isDirectory
|
||||
};
|
||||
|
||||
if (entry.isDirectory) {
|
||||
try {
|
||||
node.children = await buildTree(entry.path);
|
||||
} catch {
|
||||
node.children = [];
|
||||
}
|
||||
} else {
|
||||
// Try to get GUID for the file
|
||||
if (assetRegistry) {
|
||||
const relativePath = getRelativePath(entry.path);
|
||||
const guid = assetRegistry.getGuidByPath(relativePath);
|
||||
if (guid) {
|
||||
node.guid = guid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
// Sort: folders first, then files, alphabetically
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.isDirectory && !b.isDirectory) return -1;
|
||||
if (!a.isDirectory && b.isDirectory) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
};
|
||||
|
||||
// Only load managed directories (assets, scripts, scenes)
|
||||
const sep = projectPath.includes('\\') ? '\\' : '/';
|
||||
const managedNodes: FileNode[] = [];
|
||||
|
||||
for (const dirName of MANAGED_ASSET_DIRECTORIES) {
|
||||
const dirPath = `${projectPath}${sep}${dirName}`;
|
||||
try {
|
||||
const exists = await fileSystem.exists(dirPath);
|
||||
if (exists) {
|
||||
const children = await buildTree(dirPath);
|
||||
managedNodes.push({
|
||||
name: dirName,
|
||||
path: dirPath,
|
||||
isDirectory: true,
|
||||
children,
|
||||
isRootManaged: true
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist, skip
|
||||
}
|
||||
}
|
||||
|
||||
setAssets(managedNodes);
|
||||
|
||||
// Auto-expand managed directories
|
||||
setExpandedFolders(new Set(managedNodes.map(n => n.path)));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load assets:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAssets();
|
||||
setSelectedPath(null);
|
||||
setSearchTerm('');
|
||||
}, [isOpen, assetRegistry]);
|
||||
|
||||
// Filter assets based on search and file extensions
|
||||
const filteredAssets = useMemo(() => {
|
||||
const filterNode = (node: FileNode): FileNode | null => {
|
||||
// Check file extension filter
|
||||
if (!node.isDirectory && fileExtensions.length > 0) {
|
||||
const hasValidExtension = fileExtensions.some((ext) =>
|
||||
node.name.toLowerCase().endsWith(ext.toLowerCase())
|
||||
);
|
||||
if (!hasValidExtension) return null;
|
||||
}
|
||||
|
||||
// Check search term
|
||||
const matchesSearch = !searchTerm ||
|
||||
node.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
if (node.isDirectory && node.children) {
|
||||
const filteredChildren = node.children
|
||||
.map(filterNode)
|
||||
.filter((n): n is FileNode => n !== null);
|
||||
|
||||
if (filteredChildren.length > 0 || matchesSearch) {
|
||||
return { ...node, children: filteredChildren };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return matchesSearch ? node : null;
|
||||
};
|
||||
|
||||
return assets
|
||||
.map(filterNode)
|
||||
.filter((n): n is FileNode => n !== null);
|
||||
}, [assets, searchTerm, fileExtensions]);
|
||||
|
||||
const toggleFolder = useCallback((path: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Track selected node (to check for GUID)
|
||||
const [selectedNode, setSelectedNode] = useState<FileNode | null>(null);
|
||||
|
||||
const handleSelect = useCallback((node: FileNode) => {
|
||||
if (node.isDirectory) {
|
||||
toggleFolder(node.path);
|
||||
} else {
|
||||
// Only allow selecting files with GUID
|
||||
if (node.guid) {
|
||||
setSelectedPath(node.path);
|
||||
setSelectedNode(node);
|
||||
}
|
||||
// Files without GUID cannot be selected
|
||||
}
|
||||
}, [toggleFolder]);
|
||||
|
||||
// Convert absolute path to relative path based on project root
|
||||
const toRelativePath = useCallback((absolutePath: string): string => {
|
||||
const projectService = Core.services.tryResolve(ProjectService);
|
||||
const currentProject = projectService?.getCurrentProject();
|
||||
if (currentProject) {
|
||||
const projectPath = currentProject.path.replace(/\\/g, '/');
|
||||
const normalizedAbsolute = absolutePath.replace(/\\/g, '/');
|
||||
if (normalizedAbsolute.startsWith(projectPath)) {
|
||||
// Return relative path from project root
|
||||
return normalizedAbsolute.substring(projectPath.length + 1);
|
||||
}
|
||||
}
|
||||
return absolutePath;
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (selectedPath) {
|
||||
onSelect(toRelativePath(selectedPath));
|
||||
onClose();
|
||||
}
|
||||
}, [selectedPath, onSelect, onClose, toRelativePath]);
|
||||
|
||||
const handleDoubleClick = useCallback((node: FileNode) => {
|
||||
if (!node.isDirectory && node.guid) {
|
||||
// Double-click on file with GUID selects it
|
||||
onSelect(toRelativePath(node.path));
|
||||
onClose();
|
||||
} else if (node.isDirectory) {
|
||||
// Double-click on folder toggles expansion
|
||||
toggleFolder(node.path);
|
||||
}
|
||||
}, [onSelect, onClose, toRelativePath, toggleFolder]);
|
||||
|
||||
const getFileIcon = (name: string) => {
|
||||
const ext = name.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'png':
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'gif':
|
||||
case 'webp':
|
||||
return <Image size={14} />;
|
||||
case 'mp3':
|
||||
case 'wav':
|
||||
case 'ogg':
|
||||
return <Music size={14} />;
|
||||
case 'mp4':
|
||||
case 'webm':
|
||||
return <Video size={14} />;
|
||||
case 'json':
|
||||
case 'txt':
|
||||
case 'md':
|
||||
return <FileText size={14} />;
|
||||
default:
|
||||
return <File size={14} />;
|
||||
}
|
||||
};
|
||||
|
||||
const renderNode = (node: FileNode, depth: number = 0) => {
|
||||
const isExpanded = expandedFolders.has(node.path);
|
||||
const isSelected = selectedPath === node.path;
|
||||
const hasGuid = node.isDirectory || !!node.guid;
|
||||
const isDisabled = !node.isDirectory && !node.guid;
|
||||
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className={`asset-picker-item ${isSelected ? 'selected' : ''} ${node.isRootManaged ? 'managed-root' : ''} ${isDisabled ? 'disabled' : ''}`}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={() => handleSelect(node)}
|
||||
onDoubleClick={() => handleDoubleClick(node)}
|
||||
title={isDisabled ? 'This file has no GUID and cannot be referenced' : undefined}
|
||||
>
|
||||
<span className="asset-picker-item__icon">
|
||||
{node.isDirectory ? (
|
||||
node.isRootManaged ? (
|
||||
<Database size={14} className="managed-icon" />
|
||||
) : (
|
||||
isExpanded ? <FolderOpen size={14} /> : <Folder size={14} />
|
||||
)
|
||||
) : (
|
||||
getFileIcon(node.name)
|
||||
)}
|
||||
</span>
|
||||
<span className="asset-picker-item__name">{node.name}</span>
|
||||
{node.isRootManaged && (
|
||||
<span className="managed-badge">GUID</span>
|
||||
)}
|
||||
{isDisabled && (
|
||||
<span className="no-guid-badge" title="No GUID - cannot be referenced">
|
||||
<AlertTriangle size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{node.isDirectory && isExpanded && node.children && (
|
||||
<div className="asset-picker-children">
|
||||
{node.children.map((child) => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="asset-picker-overlay" onClick={onClose}>
|
||||
<div className="asset-picker-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="asset-picker-header">
|
||||
<h3>{title}</h3>
|
||||
<button className="asset-picker-close" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="asset-picker-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="asset-picker-content">
|
||||
{loading ? (
|
||||
<div className="asset-picker-loading">Loading assets...</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="asset-picker-empty">No assets found</div>
|
||||
) : (
|
||||
<div className="asset-picker-tree">
|
||||
{filteredAssets.map((node) => renderNode(node))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="asset-picker-footer">
|
||||
<div className="asset-picker-selected">
|
||||
{selectedPath ? (
|
||||
<span title={selectedPath}>
|
||||
{selectedPath.split(/[\\/]/).pop()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="placeholder">No asset selected</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="asset-picker-actions">
|
||||
<button className="btn-cancel" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn-confirm"
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedPath}
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { X, Search, Folder, FolderOpen, FolderPlus } from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { ProjectService, IFileSystemService } from '@esengine/editor-core';
|
||||
import type { IFileSystem } from '@esengine/editor-core';
|
||||
import './AssetPickerDialog.css';
|
||||
|
||||
interface AssetSaveDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (path: string) => void;
|
||||
title?: string;
|
||||
defaultFileName?: string;
|
||||
fileExtension?: string; // e.g., '.tilemap.json'
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface FileNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
children?: FileNode[];
|
||||
}
|
||||
|
||||
export function AssetSaveDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
title = 'Save Asset',
|
||||
defaultFileName = 'new-asset',
|
||||
fileExtension = '',
|
||||
placeholder = 'Search folders...'
|
||||
}: AssetSaveDialogProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState(defaultFileName);
|
||||
const [folders, setFolders] = useState<FileNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [projectPath, setProjectPath] = useState('');
|
||||
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
|
||||
// Load project folders
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const loadFolders = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const projectService = Core.services.tryResolve(ProjectService);
|
||||
const fileSystem = Core.services.tryResolve<IFileSystem>(IFileSystemService);
|
||||
|
||||
const currentProject = projectService?.getCurrentProject();
|
||||
if (projectService && currentProject && fileSystem) {
|
||||
const projPath = currentProject.path;
|
||||
setProjectPath(projPath);
|
||||
const assetsPath = `${projPath}/assets`;
|
||||
|
||||
// Set default selected folder to assets
|
||||
setSelectedFolder(assetsPath);
|
||||
|
||||
const buildTree = async (dirPath: string): Promise<FileNode[]> => {
|
||||
const entries = await fileSystem.listDirectory(dirPath);
|
||||
const nodes: FileNode[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
// Only include directories
|
||||
if (entry.isDirectory) {
|
||||
const node: FileNode = {
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
isDirectory: true
|
||||
};
|
||||
|
||||
try {
|
||||
node.children = await buildTree(entry.path);
|
||||
} catch {
|
||||
node.children = [];
|
||||
}
|
||||
|
||||
nodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort alphabetically
|
||||
return nodes.sort((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
|
||||
const tree = await buildTree(assetsPath);
|
||||
// Add root assets folder
|
||||
const rootNode: FileNode = {
|
||||
name: 'assets',
|
||||
path: assetsPath,
|
||||
isDirectory: true,
|
||||
children: tree
|
||||
};
|
||||
setFolders([rootNode]);
|
||||
setExpandedFolders(new Set([assetsPath]));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load folders:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFolders();
|
||||
setFileName(defaultFileName);
|
||||
setSearchTerm('');
|
||||
}, [isOpen, defaultFileName]);
|
||||
|
||||
// Filter folders based on search
|
||||
const filteredFolders = useMemo(() => {
|
||||
if (!searchTerm) return folders;
|
||||
|
||||
const filterNode = (node: FileNode): FileNode | null => {
|
||||
const matchesSearch = node.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
if (node.children) {
|
||||
const filteredChildren = node.children
|
||||
.map(filterNode)
|
||||
.filter((n): n is FileNode => n !== null);
|
||||
|
||||
if (filteredChildren.length > 0 || matchesSearch) {
|
||||
return { ...node, children: filteredChildren };
|
||||
}
|
||||
}
|
||||
|
||||
return matchesSearch ? node : null;
|
||||
};
|
||||
|
||||
return folders
|
||||
.map(filterNode)
|
||||
.filter((n): n is FileNode => n !== null);
|
||||
}, [folders, searchTerm]);
|
||||
|
||||
const toggleFolder = useCallback((path: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelectFolder = useCallback((node: FileNode) => {
|
||||
setSelectedFolder(node.path);
|
||||
if (!expandedFolders.has(node.path)) {
|
||||
toggleFolder(node.path);
|
||||
}
|
||||
}, [expandedFolders, toggleFolder]);
|
||||
|
||||
// Convert absolute path to relative path based on project root
|
||||
const toRelativePath = useCallback((absolutePath: string): string => {
|
||||
if (projectPath) {
|
||||
const normalizedProject = projectPath.replace(/\\/g, '/');
|
||||
const normalizedAbsolute = absolutePath.replace(/\\/g, '/');
|
||||
if (normalizedAbsolute.startsWith(normalizedProject)) {
|
||||
return normalizedAbsolute.substring(normalizedProject.length + 1);
|
||||
}
|
||||
}
|
||||
return absolutePath;
|
||||
}, [projectPath]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (selectedFolder && fileName) {
|
||||
// Ensure file has correct extension
|
||||
let finalFileName = fileName;
|
||||
if (fileExtension && !finalFileName.endsWith(fileExtension)) {
|
||||
finalFileName += fileExtension;
|
||||
}
|
||||
|
||||
const fullPath = `${selectedFolder}/${finalFileName}`.replace(/\\/g, '/');
|
||||
onSave(toRelativePath(fullPath));
|
||||
onClose();
|
||||
}
|
||||
}, [selectedFolder, fileName, fileExtension, onSave, onClose, toRelativePath]);
|
||||
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
if (!selectedFolder || !newFolderName.trim()) return;
|
||||
|
||||
const fileSystem = Core.services.tryResolve<IFileSystem>(IFileSystemService);
|
||||
if (!fileSystem) return;
|
||||
|
||||
try {
|
||||
const newFolderPath = `${selectedFolder}/${newFolderName.trim()}`.replace(/\\/g, '/');
|
||||
await fileSystem.createDirectory(newFolderPath);
|
||||
|
||||
// Add new folder to tree
|
||||
const addFolderToTree = (nodes: FileNode[]): FileNode[] => {
|
||||
return nodes.map(node => {
|
||||
if (node.path === selectedFolder) {
|
||||
const newNode: FileNode = {
|
||||
name: newFolderName.trim(),
|
||||
path: newFolderPath,
|
||||
isDirectory: true,
|
||||
children: []
|
||||
};
|
||||
return {
|
||||
...node,
|
||||
children: [...(node.children || []), newNode].sort((a, b) => a.name.localeCompare(b.name))
|
||||
};
|
||||
}
|
||||
if (node.children) {
|
||||
return { ...node, children: addFolderToTree(node.children) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
setFolders(addFolderToTree(folders));
|
||||
setSelectedFolder(newFolderPath);
|
||||
setExpandedFolders(prev => new Set([...prev, selectedFolder]));
|
||||
setShowNewFolderInput(false);
|
||||
setNewFolderName('');
|
||||
} catch (error) {
|
||||
console.error('Failed to create folder:', error);
|
||||
}
|
||||
}, [selectedFolder, newFolderName, folders]);
|
||||
|
||||
const renderNode = (node: FileNode, depth: number = 0) => {
|
||||
const isExpanded = expandedFolders.has(node.path);
|
||||
const isSelected = selectedFolder === node.path;
|
||||
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className={`asset-picker-item ${isSelected ? 'selected' : ''}`}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={() => handleSelectFolder(node)}
|
||||
onDoubleClick={() => toggleFolder(node.path)}
|
||||
>
|
||||
<span className="asset-picker-item__icon">
|
||||
{isExpanded ? <FolderOpen size={14} /> : <Folder size={14} />}
|
||||
</span>
|
||||
<span className="asset-picker-item__name">{node.name}</span>
|
||||
</div>
|
||||
{isExpanded && node.children && (
|
||||
<div className="asset-picker-children">
|
||||
{node.children.map((child) => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getDisplayPath = () => {
|
||||
if (!selectedFolder) return '';
|
||||
const relativePath = toRelativePath(selectedFolder);
|
||||
let finalFileName = fileName;
|
||||
if (fileExtension && !finalFileName.endsWith(fileExtension)) {
|
||||
finalFileName += fileExtension;
|
||||
}
|
||||
return `${relativePath}/${finalFileName}`;
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="asset-picker-overlay" onClick={onClose}>
|
||||
<div className="asset-picker-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="asset-picker-header">
|
||||
<h3>{title}</h3>
|
||||
<button className="asset-picker-close" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="asset-picker-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="asset-picker-content">
|
||||
{loading ? (
|
||||
<div className="asset-picker-loading">Loading folders...</div>
|
||||
) : filteredFolders.length === 0 ? (
|
||||
<div className="asset-picker-empty">No folders found</div>
|
||||
) : (
|
||||
<div className="asset-picker-tree">
|
||||
{filteredFolders.map((node) => renderNode(node))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New folder input */}
|
||||
{showNewFolderInput && (
|
||||
<div className="asset-save-new-folder">
|
||||
<input
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder="New folder name"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreateFolder();
|
||||
if (e.key === 'Escape') {
|
||||
setShowNewFolderInput(false);
|
||||
setNewFolderName('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button onClick={handleCreateFolder} disabled={!newFolderName.trim()}>
|
||||
Create
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
setShowNewFolderInput(false);
|
||||
setNewFolderName('');
|
||||
}}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New folder button */}
|
||||
{!showNewFolderInput && selectedFolder && (
|
||||
<div className="asset-save-new-folder-btn">
|
||||
<button onClick={() => setShowNewFolderInput(true)}>
|
||||
<FolderPlus size={14} />
|
||||
New Folder
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="asset-save-filename">
|
||||
<label>File name:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fileName}
|
||||
onChange={(e) => setFileName(e.target.value)}
|
||||
placeholder="Enter file name"
|
||||
autoFocus
|
||||
/>
|
||||
{fileExtension && (
|
||||
<span className="asset-save-extension">{fileExtension}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="asset-picker-footer">
|
||||
<div className="asset-picker-selected">
|
||||
{selectedFolder ? (
|
||||
<span title={getDisplayPath()}>
|
||||
{getDisplayPath()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="placeholder">Select a folder</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="asset-picker-actions">
|
||||
<button className="btn-cancel" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn-confirm"
|
||||
onClick={handleSave}
|
||||
disabled={!selectedFolder || !fileName}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 论坛认证样式 - GitHub Device Flow
|
||||
* Forum auth styles - GitHub Device Flow
|
||||
*/
|
||||
|
||||
.forum-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.forum-auth-card {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 28px;
|
||||
background: #333;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #444;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.forum-auth-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.forum-auth-icon {
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.forum-auth-header h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.forum-auth-header p {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 初始状态 | Idle state */
|
||||
.forum-auth-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.forum-auth-instructions {
|
||||
background: #2a2a2a;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #4a9eff;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.forum-auth-instructions p {
|
||||
margin: 6px 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.forum-auth-instructions p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.forum-auth-instructions p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.forum-auth-github-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: #24292e;
|
||||
border: 1px solid #444;
|
||||
color: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-auth-github-btn:hover {
|
||||
background: #2f363d;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
/* 等待授权 | Pending state */
|
||||
.forum-auth-pending {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.forum-auth-pending-text {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.forum-auth-code-section {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.forum-auth-code-section label {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.forum-auth-code-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #2a2a2a;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.forum-auth-code {
|
||||
flex: 1;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #4a9eff;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.forum-auth-copy-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 4px 8px;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.forum-auth-copy-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.forum-auth-copy-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.forum-auth-link-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #4a9eff;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-auth-link-btn:hover {
|
||||
color: #3a8eef;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 授权成功 | Success state */
|
||||
.forum-auth-success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.forum-auth-success-icon {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.forum-auth-success p {
|
||||
font-size: 14px;
|
||||
color: #4ade80;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 授权失败 | Error state */
|
||||
.forum-auth-error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.forum-auth-error-icon {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.forum-auth-error-state > p {
|
||||
font-size: 14px;
|
||||
color: #f87171;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.forum-auth-error-detail {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
background: #2a2a2a;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
max-width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.forum-auth-retry-btn {
|
||||
padding: 8px 20px;
|
||||
font-size: 12px;
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
color: #ccc;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.forum-auth-retry-btn:hover {
|
||||
background: #444;
|
||||
border-color: #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 加载动画 | Loading animation */
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 论坛登录组件 - 使用 GitHub Device Flow
|
||||
* Forum auth component - using GitHub Device Flow
|
||||
*/
|
||||
import { AlertCircle, CheckCircle, ExternalLink, Github, Loader } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { useLocale } from '../../hooks/useLocale';
|
||||
import { useForumAuth } from '../../hooks/useForum';
|
||||
import './ForumAuth.css';
|
||||
|
||||
type AuthStatus = 'idle' | 'pending' | 'authorized' | 'error';
|
||||
|
||||
export function ForumAuth() {
|
||||
const { t } = useLocale();
|
||||
const { requestDeviceCode, authenticateWithDeviceFlow, signInWithGitHubToken } = useForumAuth();
|
||||
|
||||
const [authStatus, setAuthStatus] = useState<AuthStatus>('idle');
|
||||
const [userCode, setUserCode] = useState('');
|
||||
const [verificationUri, setVerificationUri] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleGitHubLogin = async () => {
|
||||
setAuthStatus('pending');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 请求 Device Code | Request Device Code
|
||||
const deviceCodeResp = await requestDeviceCode();
|
||||
|
||||
setUserCode(deviceCodeResp.user_code);
|
||||
setVerificationUri(deviceCodeResp.verification_uri);
|
||||
|
||||
// 打开浏览器 | Open browser
|
||||
await open(deviceCodeResp.verification_uri);
|
||||
|
||||
// 轮询等待授权 | Poll for authorization
|
||||
const accessToken = await authenticateWithDeviceFlow(
|
||||
deviceCodeResp.device_code,
|
||||
deviceCodeResp.interval,
|
||||
(status) => {
|
||||
if (status === 'authorized') {
|
||||
setAuthStatus('authorized');
|
||||
} else if (status === 'error') {
|
||||
setAuthStatus('error');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 使用 token 登录 Supabase | Sign in to Supabase with token
|
||||
const { error: signInError } = await signInWithGitHubToken(accessToken);
|
||||
|
||||
if (signInError) {
|
||||
throw signInError;
|
||||
}
|
||||
|
||||
setAuthStatus('authorized');
|
||||
} catch (err) {
|
||||
console.error('[ForumAuth] GitHub login failed:', err);
|
||||
setAuthStatus('error');
|
||||
setError(err instanceof Error ? err.message : t('forum.authFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setAuthStatus('idle');
|
||||
setUserCode('');
|
||||
setVerificationUri('');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="forum-auth">
|
||||
<div className="forum-auth-card">
|
||||
<div className="forum-auth-header">
|
||||
<Github size={32} className="forum-auth-icon" />
|
||||
<h2>{t('forum.communityTitle')}</h2>
|
||||
<p>{t('forum.signInWithGitHub')}</p>
|
||||
</div>
|
||||
|
||||
{/* 初始状态 | Idle state */}
|
||||
{authStatus === 'idle' && (
|
||||
<div className="forum-auth-content">
|
||||
<div className="forum-auth-instructions">
|
||||
<p>{t('forum.step1')}</p>
|
||||
<p>{t('forum.step2')}</p>
|
||||
<p>{t('forum.step3')}</p>
|
||||
</div>
|
||||
<button className="forum-auth-github-btn" onClick={handleGitHubLogin}>
|
||||
<Github size={16} />
|
||||
<span>{t('forum.continueWithGitHub')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 等待授权 | Pending state */}
|
||||
{authStatus === 'pending' && (
|
||||
<div className="forum-auth-pending">
|
||||
<Loader size={24} className="spinning" />
|
||||
<p className="forum-auth-pending-text">
|
||||
{t('forum.waitingForAuth')}
|
||||
</p>
|
||||
|
||||
{userCode && (
|
||||
<div className="forum-auth-code-section">
|
||||
<label>{t('forum.enterCodeOnGitHub')}</label>
|
||||
<div className="forum-auth-code-box">
|
||||
<span className="forum-auth-code">{userCode}</span>
|
||||
<button
|
||||
className="forum-auth-copy-btn"
|
||||
onClick={() => copyToClipboard(userCode)}
|
||||
title={t('forum.copyCode')}
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="forum-auth-link-btn"
|
||||
onClick={() => open(verificationUri)}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
<span>{t('forum.openGitHub')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 授权成功 | Success state */}
|
||||
{authStatus === 'authorized' && (
|
||||
<div className="forum-auth-success">
|
||||
<CheckCircle size={32} className="forum-auth-success-icon" />
|
||||
<p>{t('forum.authSuccess')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 授权失败 | Error state */}
|
||||
{authStatus === 'error' && (
|
||||
<div className="forum-auth-error-state">
|
||||
<AlertCircle size={32} className="forum-auth-error-icon" />
|
||||
<p>{t('forum.authFailed')}</p>
|
||||
{error && <p className="forum-auth-error-detail">{error}</p>}
|
||||
<button className="forum-auth-retry-btn" onClick={handleRetry}>
|
||||
{t('forum.tryAgain')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
/**
|
||||
* 论坛创建帖子样式
|
||||
* Forum create post styles
|
||||
*/
|
||||
|
||||
.forum-create-post {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* 容器布局 | Container layout */
|
||||
.forum-create-container {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 主编辑区 | Main editor area */
|
||||
.forum-create-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: #2d2d2d;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #3a3a3a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.forum-create-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.forum-create-header h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.forum-create-selected-category {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
/* 表单 | Form */
|
||||
.forum-create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.forum-create-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.forum-create-field label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* 分类选择 | Category selection */
|
||||
.forum-create-categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.forum-create-category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
min-width: 80px;
|
||||
background: #363636;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-create-category:hover {
|
||||
background: #404040;
|
||||
border-color: #555;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.forum-create-category.selected {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-create-category-emoji {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.forum-create-category-name {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #ccc;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.forum-create-category.selected .forum-create-category-name {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-create-category-desc {
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 标题输入 | Title input */
|
||||
.forum-create-title-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
padding-right: 10px;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-create-title-input:focus-within {
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-create-title-input input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.forum-create-title-input input::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.forum-create-count {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 编辑器字段 | Editor field */
|
||||
.forum-create-editor-field {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
/* 编辑器头部 | Editor header */
|
||||
.forum-editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
background: #363636;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
/* 编辑器选项卡 | Editor tabs */
|
||||
.forum-editor-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.forum-editor-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-editor-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.forum-editor-tab.active {
|
||||
background: #4a9eff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 编辑器工具栏 | Editor toolbar */
|
||||
.forum-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.forum-editor-tool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-editor-tool:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.forum-editor-help {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #666;
|
||||
border-radius: 3px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-editor-help:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
/* 编辑器内容区 | Editor content */
|
||||
.forum-editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.forum-editor-textarea {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||
line-height: 1.6;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.forum-editor-textarea::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* 编辑器内容拖拽状态 | Editor content drag state */
|
||||
.forum-editor-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.forum-editor-content.dragging {
|
||||
border-color: #4a9eff;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
}
|
||||
|
||||
/* 上传覆盖层 | Upload overlay */
|
||||
.forum-editor-upload-overlay,
|
||||
.forum-editor-drag-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: rgba(26, 26, 26, 0.95);
|
||||
z-index: 10;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
.forum-editor-upload-overlay span,
|
||||
.forum-editor-drag-overlay span {
|
||||
font-size: 13px;
|
||||
color: #4a9eff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.forum-editor-upload-overlay svg,
|
||||
.forum-editor-drag-overlay svg {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-editor-drag-overlay {
|
||||
border: 2px dashed #4a9eff;
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 旋转动画 | Spin animation */
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 预览区 | Preview area */
|
||||
.forum-editor-preview {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
color: #ddd;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.forum-editor-preview-empty {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Markdown 渲染样式 | Markdown render styles */
|
||||
.forum-editor-preview h1,
|
||||
.forum-editor-preview h2,
|
||||
.forum-editor-preview h3,
|
||||
.forum-editor-preview h4 {
|
||||
color: #e0e0e0;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.forum-editor-preview h1 { font-size: 20px; }
|
||||
.forum-editor-preview h2 { font-size: 17px; }
|
||||
.forum-editor-preview h3 { font-size: 14px; }
|
||||
|
||||
.forum-editor-preview p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.forum-editor-preview a {
|
||||
color: #4a9eff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.forum-editor-preview a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.forum-editor-preview code {
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||
background: #2a2a2a;
|
||||
border-radius: 3px;
|
||||
color: #f8d97c;
|
||||
}
|
||||
|
||||
.forum-editor-preview pre {
|
||||
margin: 12px 0;
|
||||
padding: 12px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.forum-editor-preview pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.forum-editor-preview blockquote {
|
||||
margin: 12px 0;
|
||||
padding: 8px 12px;
|
||||
border-left: 3px solid #4a9eff;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.forum-editor-preview ul,
|
||||
.forum-editor-preview ol {
|
||||
margin: 8px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.forum-editor-preview li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.forum-editor-preview img {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.forum-editor-preview hr {
|
||||
border: none;
|
||||
border-top: 1px solid #3a3a3a;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.forum-editor-preview table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.forum-editor-preview th,
|
||||
.forum-editor-preview td {
|
||||
padding: 8px;
|
||||
border: 1px solid #3a3a3a;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.forum-editor-preview th {
|
||||
background: #2a2a2a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 错误提示 | Error message */
|
||||
.forum-create-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 11px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* 操作按钮 | Action buttons */
|
||||
.forum-create-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.forum-btn-submit {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
/* 侧边栏 | Sidebar */
|
||||
.forum-create-sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.forum-create-tips,
|
||||
.forum-create-markdown-guide {
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.forum-create-tips h3,
|
||||
.forum-create-markdown-guide h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.forum-create-tips ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.forum-create-tips li {
|
||||
position: relative;
|
||||
padding: 4px 0 4px 14px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.forum-create-tips li::before {
|
||||
content: '•';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
/* Markdown 指南 | Markdown guide */
|
||||
.forum-create-markdown-examples {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.markdown-example {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.markdown-example code {
|
||||
padding: 2px 6px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 3px;
|
||||
color: #f8d97c;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.markdown-example code.inline {
|
||||
background: #2a2a2a;
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.markdown-example span {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.markdown-example strong {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.markdown-example em {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.markdown-example a {
|
||||
color: #4a9eff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 响应式 | Responsive */
|
||||
@media (max-width: 800px) {
|
||||
.forum-create-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.forum-create-sidebar {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.forum-create-tips,
|
||||
.forum-create-markdown-guide {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* 论坛创建帖子组件 - GitHub Discussions
|
||||
* Forum create post component - GitHub Discussions
|
||||
*/
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
ArrowLeft, Send, AlertCircle, Eye, Edit3,
|
||||
Bold, Italic, Code, Link, List, Image, Quote, HelpCircle,
|
||||
Upload, Loader2
|
||||
} from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useLocale } from '../../hooks/useLocale';
|
||||
import { getForumService } from '../../services/forum';
|
||||
import type { Category } from '../../services/forum';
|
||||
import { parseEmoji } from './utils';
|
||||
import './ForumCreatePost.css';
|
||||
|
||||
interface ForumCreatePostProps {
|
||||
categories: Category[];
|
||||
onBack: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
type EditorTab = 'write' | 'preview';
|
||||
|
||||
export function ForumCreatePost({ categories, onBack, onCreated }: ForumCreatePostProps) {
|
||||
const { t } = useLocale();
|
||||
const [title, setTitle] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [categoryId, setCategoryId] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<EditorTab>('write');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const forumService = getForumService();
|
||||
|
||||
/**
|
||||
* 处理图片上传
|
||||
* Handle image upload
|
||||
*/
|
||||
const handleImageUpload = useCallback(async (file: File) => {
|
||||
if (uploading) return;
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const imageUrl = await forumService.uploadImage(file, (progress) => {
|
||||
setUploadProgress(progress);
|
||||
});
|
||||
|
||||
// 插入 Markdown 图片语法 | Insert Markdown image syntax
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const imageMarkdown = ``;
|
||||
const newBody = body.substring(0, start) + imageMarkdown + body.substring(end);
|
||||
setBody(newBody);
|
||||
|
||||
// 恢复光标位置 | Restore cursor position
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
const newPos = start + imageMarkdown.length;
|
||||
textarea.setSelectionRange(newPos, newPos);
|
||||
}, 0);
|
||||
} else {
|
||||
// 如果没有 textarea,直接追加到末尾 | Append to end if no textarea
|
||||
setBody(prev => prev + `\n`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ForumCreatePost] Upload failed:', err);
|
||||
setError(err instanceof Error ? err.message : t('forum.failedToUploadImage'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
}, [body, forumService, t, uploading]);
|
||||
|
||||
/**
|
||||
* 处理拖拽事件
|
||||
* Handle drag events
|
||||
*/
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const imageFile = files.find(f => f.type.startsWith('image/'));
|
||||
if (imageFile) {
|
||||
handleImageUpload(imageFile);
|
||||
}
|
||||
}, [handleImageUpload]);
|
||||
|
||||
/**
|
||||
* 处理粘贴事件
|
||||
* Handle paste event
|
||||
*/
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const items = Array.from(e.clipboardData.items);
|
||||
const imageItem = items.find(item => item.type.startsWith('image/'));
|
||||
|
||||
if (imageItem) {
|
||||
e.preventDefault();
|
||||
const file = imageItem.getAsFile();
|
||||
if (file) {
|
||||
handleImageUpload(file);
|
||||
}
|
||||
}
|
||||
}, [handleImageUpload]);
|
||||
|
||||
/**
|
||||
* 处理文件选择
|
||||
* Handle file selection
|
||||
*/
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleImageUpload(file);
|
||||
}
|
||||
// 清空 input 以便重复选择同一文件 | Clear input to allow selecting same file again
|
||||
e.target.value = '';
|
||||
}, [handleImageUpload]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// 验证 | Validation
|
||||
if (!title.trim()) {
|
||||
setError(t('forum.enterTitle'));
|
||||
return;
|
||||
}
|
||||
if (!body.trim()) {
|
||||
setError(t('forum.enterContent'));
|
||||
return;
|
||||
}
|
||||
if (!categoryId) {
|
||||
setError(t('forum.selectCategoryError'));
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const post = await forumService.createPost({
|
||||
title: title.trim(),
|
||||
body: body.trim(),
|
||||
categoryId
|
||||
});
|
||||
|
||||
if (post) {
|
||||
onCreated();
|
||||
} else {
|
||||
setError(t('forum.failedToCreateDiscussion'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ForumCreatePost] Error:', err);
|
||||
setError(err instanceof Error ? err.message : t('forum.anErrorOccurred'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 插入 Markdown 语法 | Insert Markdown syntax
|
||||
const insertMarkdown = (prefix: string, suffix: string = '', placeholder: string = '') => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selectedText = body.substring(start, end) || placeholder;
|
||||
|
||||
const newBody = body.substring(0, start) + prefix + selectedText + suffix + body.substring(end);
|
||||
setBody(newBody);
|
||||
|
||||
// 恢复光标位置 | Restore cursor position
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
const newCursorPos = start + prefix.length + selectedText.length;
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const toolbarButtons = [
|
||||
{ icon: <Bold size={14} />, action: () => insertMarkdown('**', '**', 'bold'), title: t('forum.bold') },
|
||||
{ icon: <Italic size={14} />, action: () => insertMarkdown('*', '*', 'italic'), title: t('forum.italic') },
|
||||
{ icon: <Code size={14} />, action: () => insertMarkdown('`', '`', 'code'), title: t('forum.inlineCode') },
|
||||
{ icon: <Link size={14} />, action: () => insertMarkdown('[', '](url)', 'link text'), title: t('forum.link') },
|
||||
{ icon: <List size={14} />, action: () => insertMarkdown('\n- ', '', 'list item'), title: t('forum.list') },
|
||||
{ icon: <Quote size={14} />, action: () => insertMarkdown('\n> ', '', 'quote'), title: t('forum.quote') },
|
||||
{ icon: <Upload size={14} />, action: () => fileInputRef.current?.click(), title: t('forum.uploadImage') },
|
||||
];
|
||||
|
||||
const selectedCategory = categories.find(c => c.id === categoryId);
|
||||
|
||||
return (
|
||||
<div className="forum-create-post">
|
||||
{/* 返回按钮 | Back button */}
|
||||
<button className="forum-back-btn" onClick={onBack}>
|
||||
<ArrowLeft size={18} />
|
||||
<span>{t('forum.backToList')}</span>
|
||||
</button>
|
||||
|
||||
<div className="forum-create-container">
|
||||
{/* 左侧:编辑区 | Left: Editor */}
|
||||
<div className="forum-create-main">
|
||||
<div className="forum-create-header">
|
||||
<h2>{t('forum.startDiscussion')}</h2>
|
||||
{selectedCategory && (
|
||||
<span className="forum-create-selected-category">
|
||||
{parseEmoji(selectedCategory.emoji)} {selectedCategory.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="forum-create-form" onSubmit={handleSubmit}>
|
||||
{/* 分类选择 | Category selection */}
|
||||
<div className="forum-create-field">
|
||||
<label>{t('forum.selectCategory')}</label>
|
||||
<div className="forum-create-categories">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
className={`forum-create-category ${categoryId === cat.id ? 'selected' : ''}`}
|
||||
onClick={() => setCategoryId(cat.id)}
|
||||
>
|
||||
<span className="forum-create-category-emoji">{parseEmoji(cat.emoji)}</span>
|
||||
<span className="forum-create-category-name">{cat.name}</span>
|
||||
{cat.description && (
|
||||
<span className="forum-create-category-desc">{cat.description}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标题 | Title */}
|
||||
<div className="forum-create-field">
|
||||
<label>{t('forum.title')}</label>
|
||||
<div className="forum-create-title-input">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t('forum.enterDescriptiveTitle')}
|
||||
maxLength={200}
|
||||
/>
|
||||
<span className="forum-create-count">{title.length}/200</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编辑器 | Editor */}
|
||||
<div className="forum-create-field forum-create-editor-field">
|
||||
<div className="forum-editor-header">
|
||||
<div className="forum-editor-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`forum-editor-tab ${activeTab === 'write' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('write')}
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
<span>{t('forum.write')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`forum-editor-tab ${activeTab === 'preview' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('preview')}
|
||||
>
|
||||
<Eye size={14} />
|
||||
<span>{t('forum.preview')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'write' && (
|
||||
<div className="forum-editor-toolbar">
|
||||
{toolbarButtons.map((btn, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
className="forum-editor-tool"
|
||||
onClick={btn.action}
|
||||
title={btn.title}
|
||||
>
|
||||
{btn.icon}
|
||||
</button>
|
||||
))}
|
||||
<a
|
||||
href="https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="forum-editor-help"
|
||||
title={t('forum.markdownHelp')}
|
||||
>
|
||||
<HelpCircle size={14} />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`forum-editor-content ${isDragging ? 'dragging' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* 隐藏的文件输入 | Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
{/* 上传进度提示 | Upload progress indicator */}
|
||||
{uploading && (
|
||||
<div className="forum-editor-upload-overlay">
|
||||
<Loader2 size={24} className="spin" />
|
||||
<span>{t('forum.uploading')} {uploadProgress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 拖拽提示 | Drag hint */}
|
||||
{isDragging && !uploading && (
|
||||
<div className="forum-editor-drag-overlay">
|
||||
<Upload size={32} />
|
||||
<span>{t('forum.dropImageHere')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'write' ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="forum-editor-textarea"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
placeholder={t('forum.editorPlaceholder')}
|
||||
/>
|
||||
) : (
|
||||
<div className="forum-editor-preview">
|
||||
{body ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{body}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<p className="forum-editor-preview-empty">
|
||||
{t('forum.nothingToPreview')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 | Error message */}
|
||||
{error && (
|
||||
<div className="forum-create-error">
|
||||
<AlertCircle size={16} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 | Submit button */}
|
||||
<div className="forum-create-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="forum-btn"
|
||||
onClick={onBack}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t('forum.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="forum-btn forum-btn-primary forum-btn-submit"
|
||||
disabled={submitting || !title.trim() || !body.trim() || !categoryId}
|
||||
>
|
||||
<Send size={16} />
|
||||
<span>
|
||||
{submitting ? t('forum.creating') : t('forum.createDiscussion')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 右侧:提示 | Right: Tips */}
|
||||
<div className="forum-create-sidebar">
|
||||
<div className="forum-create-tips">
|
||||
<h3>{t('forum.tips')}</h3>
|
||||
<ul>
|
||||
<li>{t('forum.tip1')}</li>
|
||||
<li>{t('forum.tip2')}</li>
|
||||
<li>{t('forum.tip3')}</li>
|
||||
<li>{t('forum.tip4')}</li>
|
||||
<li>{t('forum.tip5')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="forum-create-markdown-guide">
|
||||
<h3>{t('forum.markdownGuide')}</h3>
|
||||
<div className="forum-create-markdown-examples">
|
||||
<div className="markdown-example">
|
||||
<code>**bold**</code>
|
||||
<span>→</span>
|
||||
<strong>bold</strong>
|
||||
</div>
|
||||
<div className="markdown-example">
|
||||
<code>*italic*</code>
|
||||
<span>→</span>
|
||||
<em>italic</em>
|
||||
</div>
|
||||
<div className="markdown-example">
|
||||
<code>`code`</code>
|
||||
<span>→</span>
|
||||
<code className="inline">code</code>
|
||||
</div>
|
||||
<div className="markdown-example">
|
||||
<code>[link](url)</code>
|
||||
<span>→</span>
|
||||
<a href="#">link</a>
|
||||
</div>
|
||||
<div className="markdown-example">
|
||||
<code>- item</code>
|
||||
<span>→</span>
|
||||
<span>• item</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 论坛面板样式
|
||||
* Forum panel styles
|
||||
*/
|
||||
|
||||
.forum-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #2a2a2a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.forum-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.forum-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.forum-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.forum-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.forum-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-user:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.forum-user-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.forum-user-avatar-placeholder {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #4a9eff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.forum-user-name {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 个人资料下拉面板 | Profile dropdown panel */
|
||||
.forum-profile-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 8px;
|
||||
z-index: 1000;
|
||||
min-width: 280px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.forum-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.forum-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* 通用按钮样式 | Common button styles */
|
||||
.forum-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
color: #ccc;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-btn:hover:not(:disabled) {
|
||||
background: #444;
|
||||
border-color: #555;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.forum-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.forum-btn-primary {
|
||||
background: #4a9eff;
|
||||
border-color: #4a9eff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.forum-btn-primary:hover:not(:disabled) {
|
||||
background: #3a8eef;
|
||||
border-color: #3a8eef;
|
||||
}
|
||||
|
||||
/* 返回按钮 | Back button */
|
||||
.forum-back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
margin: 10px 12px;
|
||||
font-size: 11px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-back-btn:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* 旋转动画 | Spin animation */
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 论坛面板主组件 - GitHub Discussions
|
||||
* Forum panel main component - GitHub Discussions
|
||||
*/
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { MessageSquare, RefreshCw } from 'lucide-react';
|
||||
import { useLocale } from '../../hooks/useLocale';
|
||||
import { useForumAuth, useCategories, usePosts } from '../../hooks/useForum';
|
||||
import { ForumAuth } from './ForumAuth';
|
||||
import { ForumPostList } from './ForumPostList';
|
||||
import { ForumPostDetail } from './ForumPostDetail';
|
||||
import { ForumCreatePost } from './ForumCreatePost';
|
||||
import { ForumProfile } from './ForumProfile';
|
||||
import type { PostListParams, ForumUser } from '../../services/forum';
|
||||
import './ForumPanel.css';
|
||||
|
||||
type ForumView = 'list' | 'detail' | 'create';
|
||||
|
||||
/**
|
||||
* 认证后的论坛内容组件 | Authenticated forum content component
|
||||
* 只有在用户认证后才会渲染,确保 hooks 能正常工作
|
||||
*/
|
||||
function ForumContent({ user }: { user: ForumUser }) {
|
||||
const { t } = useLocale();
|
||||
const { categories, refetch: refetchCategories } = useCategories();
|
||||
const [view, setView] = useState<ForumView>('list');
|
||||
const [selectedPostNumber, setSelectedPostNumber] = useState<number | null>(null);
|
||||
const [listParams, setListParams] = useState<PostListParams>({ first: 20 });
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
const profileRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: posts, loading, totalCount, pageInfo, refetch, loadMore } = usePosts(listParams);
|
||||
|
||||
// 点击外部关闭个人资料面板 | Close profile panel when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
|
||||
setShowProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showProfile) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showProfile]);
|
||||
|
||||
const handleViewPost = useCallback((postNumber: number) => {
|
||||
setSelectedPostNumber(postNumber);
|
||||
setView('detail');
|
||||
}, []);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setView('list');
|
||||
setSelectedPostNumber(null);
|
||||
}, []);
|
||||
|
||||
const handleCreatePost = useCallback(() => {
|
||||
setView('create');
|
||||
}, []);
|
||||
|
||||
const handlePostCreated = useCallback(() => {
|
||||
setView('list');
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const handleCategoryChange = useCallback((categoryId: string | undefined) => {
|
||||
setListParams(prev => ({ ...prev, categoryId }));
|
||||
}, []);
|
||||
|
||||
const handleSearch = useCallback((search: string) => {
|
||||
setListParams(prev => ({ ...prev, search }));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 顶部栏 | Header */}
|
||||
<div className="forum-header">
|
||||
<div className="forum-header-left">
|
||||
<MessageSquare size={18} />
|
||||
<span className="forum-title">
|
||||
{t('forum.community')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="forum-header-right">
|
||||
<div
|
||||
className="forum-user"
|
||||
onClick={() => setShowProfile(!showProfile)}
|
||||
title={t('forum.clickToViewProfile')}
|
||||
>
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.login}
|
||||
className="forum-user-avatar"
|
||||
/>
|
||||
<span className="forum-user-name">
|
||||
{user.login}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 个人资料下拉面板 | Profile dropdown panel */}
|
||||
{showProfile && (
|
||||
<div className="forum-profile-dropdown" ref={profileRef}>
|
||||
<ForumProfile onClose={() => setShowProfile(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区 | Content */}
|
||||
<div className="forum-content">
|
||||
{view === 'list' && (
|
||||
<ForumPostList
|
||||
posts={posts}
|
||||
categories={categories}
|
||||
loading={loading}
|
||||
totalCount={totalCount}
|
||||
hasNextPage={pageInfo.hasNextPage}
|
||||
params={listParams}
|
||||
onViewPost={handleViewPost}
|
||||
onCreatePost={handleCreatePost}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
onSearch={handleSearch}
|
||||
onRefresh={refetch}
|
||||
onLoadMore={loadMore}
|
||||
/>
|
||||
)}
|
||||
{view === 'detail' && selectedPostNumber && (
|
||||
<ForumPostDetail
|
||||
postNumber={selectedPostNumber}
|
||||
currentUserId={user.id}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
)}
|
||||
{view === 'create' && (
|
||||
<ForumCreatePost
|
||||
categories={categories}
|
||||
onBack={handleBack}
|
||||
onCreated={handlePostCreated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForumPanel() {
|
||||
const { t } = useLocale();
|
||||
const { authState } = useForumAuth();
|
||||
|
||||
// 加载状态 | Loading state
|
||||
if (authState.status === 'loading') {
|
||||
return (
|
||||
<div className="forum-panel">
|
||||
<div className="forum-loading">
|
||||
<RefreshCw className="spin" size={24} />
|
||||
<span>{t('forum.loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 未登录状态 | Unauthenticated state
|
||||
if (authState.status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="forum-panel">
|
||||
<ForumAuth />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 已登录状态 - 渲染内容组件 | Authenticated state - render content component
|
||||
return (
|
||||
<div className="forum-panel">
|
||||
<ForumContent user={authState.user} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* 论坛帖子详情样式
|
||||
* Forum post detail styles
|
||||
*/
|
||||
|
||||
.forum-post-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.forum-detail-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* 文章区 | Article section */
|
||||
.forum-detail-article {
|
||||
padding: 0 12px 20px;
|
||||
}
|
||||
|
||||
.forum-detail-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.forum-detail-category-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.forum-detail-category {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-detail-answered {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.forum-detail-external {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
color: #888;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.forum-detail-external:hover {
|
||||
background: #444;
|
||||
color: #e0e0e0;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.forum-detail-title {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.forum-detail-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.forum-detail-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.forum-detail-author img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.forum-detail-author-placeholder {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #4a9eff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.forum-detail-author span {
|
||||
font-size: 12px;
|
||||
color: #e0e0e0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.forum-detail-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 内容区 | Content section */
|
||||
.forum-detail-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.forum-detail-content p {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.forum-detail-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Markdown 样式 | Markdown styles */
|
||||
.forum-detail-content h1,
|
||||
.forum-detail-content h2,
|
||||
.forum-detail-content h3,
|
||||
.forum-detail-content h4 {
|
||||
color: #e0e0e0;
|
||||
margin: 20px 0 10px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.forum-detail-content h1 { font-size: 20px; }
|
||||
.forum-detail-content h2 { font-size: 17px; }
|
||||
.forum-detail-content h3 { font-size: 15px; }
|
||||
.forum-detail-content h4 { font-size: 14px; }
|
||||
|
||||
.forum-detail-content a {
|
||||
color: #4a9eff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.forum-detail-content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.forum-detail-content code {
|
||||
background: #1a1a1a;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
.forum-detail-content pre {
|
||||
background: #1a1a1a;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 12px 0;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.forum-detail-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: #abb2bf;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.forum-detail-content blockquote {
|
||||
margin: 12px 0;
|
||||
padding: 10px 16px;
|
||||
border-left: 3px solid #4a9eff;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.forum-detail-content ul,
|
||||
.forum-detail-content ol {
|
||||
margin: 12px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.forum-detail-content li {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.forum-detail-content img {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.forum-detail-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid #3a3a3a;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.forum-detail-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.forum-detail-content th,
|
||||
.forum-detail-content td {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #3a3a3a;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.forum-detail-content th {
|
||||
background: #333;
|
||||
color: #e0e0e0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.forum-detail-content tr:nth-child(even) {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
/* 底部统计 | Footer stats */
|
||||
.forum-detail-footer {
|
||||
margin-top: 20px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.forum-detail-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.forum-detail-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.forum-detail-stat.interactive {
|
||||
padding: 5px 10px;
|
||||
margin: -5px -10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.forum-detail-stat.interactive:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.forum-detail-stat.interactive.liked {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.forum-detail-stat.interactive.liked svg {
|
||||
fill: #ef4444;
|
||||
}
|
||||
|
||||
/* 回复区 | Replies section */
|
||||
.forum-replies-section {
|
||||
padding: 0 12px 20px;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.forum-replies-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 16px 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* 回复表单 | Reply form */
|
||||
.forum-reply-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.forum-reply-form.nested {
|
||||
margin-top: 10px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.forum-reply-form textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 3px;
|
||||
color: #ddd;
|
||||
resize: vertical;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.forum-reply-form textarea:hover {
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.forum-reply-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-reply-form textarea::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.forum-reply-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 回复列表 | Reply list */
|
||||
.forum-replies-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.forum-replies-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.forum-replies-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.forum-replies-empty p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 单条回复 | Single reply */
|
||||
.forum-reply {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.forum-reply:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.forum-reply-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.forum-reply-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.forum-reply-author img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.forum-reply-author-placeholder {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #4a9eff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.forum-reply-author-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.forum-reply-answer-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.forum-reply-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.forum-reply-content {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* 回复内容 Markdown 样式 | Reply content Markdown styles */
|
||||
.forum-reply-content p {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.forum-reply-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.forum-reply-content a {
|
||||
color: #4a9eff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.forum-reply-content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.forum-reply-content code {
|
||||
background: #1a1a1a;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 11px;
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
.forum-reply-content pre {
|
||||
background: #1a1a1a;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.forum-reply-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: #abb2bf;
|
||||
}
|
||||
|
||||
.forum-reply-content blockquote {
|
||||
margin: 8px 0;
|
||||
padding: 8px 12px;
|
||||
border-left: 2px solid #4a9eff;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.forum-reply-content ul,
|
||||
.forum-reply-content ol {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.forum-reply-content li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.forum-reply-content img {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.forum-reply-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.forum-reply-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-reply-action:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.forum-reply-action.liked {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.forum-reply-action.liked svg {
|
||||
fill: #ef4444;
|
||||
}
|
||||
|
||||
.forum-reply-action.delete:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 论坛帖子详情组件 - GitHub Discussions
|
||||
* Forum post detail component - GitHub Discussions
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ArrowLeft, ThumbsUp, MessageCircle, Clock,
|
||||
Send, RefreshCw, CornerDownRight, ExternalLink, CheckCircle
|
||||
} from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { useLocale } from '../../hooks/useLocale';
|
||||
import { usePost, useReplies } from '../../hooks/useForum';
|
||||
import { getForumService } from '../../services/forum';
|
||||
import type { Reply } from '../../services/forum';
|
||||
import { parseEmoji } from './utils';
|
||||
import './ForumPostDetail.css';
|
||||
|
||||
interface ForumPostDetailProps {
|
||||
postNumber: number;
|
||||
currentUserId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function ForumPostDetail({ postNumber, currentUserId, onBack }: ForumPostDetailProps) {
|
||||
const { t } = useLocale();
|
||||
const { post, loading: postLoading, toggleUpvote, refetch: refetchPost } = usePost(postNumber);
|
||||
const { replies, loading: repliesLoading, createReply, refetch: refetchReplies } = useReplies(postNumber);
|
||||
const [replyContent, setReplyContent] = useState('');
|
||||
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const forumService = getForumService();
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const handleSubmitReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!replyContent.trim() || submitting || !post) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createReply(post.id, replyContent, replyingTo || undefined);
|
||||
setReplyContent('');
|
||||
setReplyingTo(null);
|
||||
refetchPost();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleReplyUpvote = async (replyId: string, hasUpvoted: boolean) => {
|
||||
await forumService.toggleReplyUpvote(replyId, hasUpvoted);
|
||||
refetchReplies();
|
||||
};
|
||||
|
||||
const openInGitHub = async (url: string) => {
|
||||
await open(url);
|
||||
};
|
||||
|
||||
const renderReply = (reply: Reply, depth: number = 0) => {
|
||||
return (
|
||||
<div key={reply.id} className="forum-reply" style={{ marginLeft: depth * 24 }}>
|
||||
<div className="forum-reply-header">
|
||||
<div className="forum-reply-author">
|
||||
<img src={reply.author.avatarUrl} alt={reply.author.login} />
|
||||
<span className="forum-reply-author-name">
|
||||
@{reply.author.login}
|
||||
</span>
|
||||
{reply.isAnswer && (
|
||||
<span className="forum-reply-answer-badge">
|
||||
<CheckCircle size={12} />
|
||||
{t('forum.answer')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="forum-reply-time">
|
||||
<Clock size={12} />
|
||||
{formatDate(reply.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="forum-reply-content"
|
||||
dangerouslySetInnerHTML={{ __html: reply.bodyHTML }}
|
||||
/>
|
||||
|
||||
<div className="forum-reply-actions">
|
||||
<button
|
||||
className={`forum-reply-action ${reply.viewerHasUpvoted ? 'liked' : ''}`}
|
||||
onClick={() => handleToggleReplyUpvote(reply.id, reply.viewerHasUpvoted)}
|
||||
>
|
||||
<ThumbsUp size={14} />
|
||||
<span>{reply.upvoteCount}</span>
|
||||
</button>
|
||||
<button
|
||||
className="forum-reply-action"
|
||||
onClick={() => setReplyingTo(replyingTo === reply.id ? null : reply.id)}
|
||||
>
|
||||
<CornerDownRight size={14} />
|
||||
<span>{t('forum.reply')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{replyingTo === reply.id && post && (
|
||||
<form className="forum-reply-form nested" onSubmit={handleSubmitReply}>
|
||||
<textarea
|
||||
value={replyContent}
|
||||
onChange={(e) => setReplyContent(e.target.value)}
|
||||
placeholder={t('forum.replyTo', { login: reply.author.login })}
|
||||
rows={2}
|
||||
/>
|
||||
<div className="forum-reply-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="forum-btn"
|
||||
onClick={() => { setReplyingTo(null); setReplyContent(''); }}
|
||||
>
|
||||
{t('forum.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="forum-btn forum-btn-primary"
|
||||
disabled={!replyContent.trim() || submitting}
|
||||
>
|
||||
<Send size={14} />
|
||||
<span>{t('forum.reply')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 嵌套回复 | Nested replies */}
|
||||
{reply.replies?.nodes.map(child => renderReply(child, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (postLoading || !post) {
|
||||
return (
|
||||
<div className="forum-post-detail">
|
||||
<div className="forum-detail-loading">
|
||||
<RefreshCw className="spin" size={24} />
|
||||
<span>{t('forum.loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="forum-post-detail">
|
||||
{/* 返回按钮 | Back button */}
|
||||
<button className="forum-back-btn" onClick={onBack}>
|
||||
<ArrowLeft size={18} />
|
||||
<span>{t('forum.backToList')}</span>
|
||||
</button>
|
||||
|
||||
{/* 帖子内容 | Post content */}
|
||||
<article className="forum-detail-article">
|
||||
<header className="forum-detail-header">
|
||||
<div className="forum-detail-category-row">
|
||||
<span className="forum-detail-category">
|
||||
{parseEmoji(post.category.emoji)} {post.category.name}
|
||||
</span>
|
||||
{post.answerChosenAt && (
|
||||
<span className="forum-detail-answered">
|
||||
<CheckCircle size={14} />
|
||||
{t('forum.answered')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="forum-detail-external"
|
||||
onClick={() => openInGitHub(post.url)}
|
||||
title={t('forum.openInGitHub')}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
<span>GitHub</span>
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="forum-detail-title">{post.title}</h1>
|
||||
|
||||
<div className="forum-detail-meta">
|
||||
<div className="forum-detail-author">
|
||||
<img src={post.author.avatarUrl} alt={post.author.login} />
|
||||
<span>@{post.author.login}</span>
|
||||
</div>
|
||||
<span className="forum-detail-time">
|
||||
<Clock size={14} />
|
||||
{formatDate(post.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="forum-detail-content"
|
||||
dangerouslySetInnerHTML={{ __html: post.bodyHTML }}
|
||||
/>
|
||||
|
||||
<footer className="forum-detail-footer">
|
||||
<div className="forum-detail-stats">
|
||||
<button
|
||||
className={`forum-detail-stat interactive ${post.viewerHasUpvoted ? 'liked' : ''}`}
|
||||
onClick={toggleUpvote}
|
||||
>
|
||||
<ThumbsUp size={16} />
|
||||
<span>{post.upvoteCount}</span>
|
||||
</button>
|
||||
<div className="forum-detail-stat">
|
||||
<MessageCircle size={16} />
|
||||
<span>{post.comments.totalCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
{/* 回复区 | Replies section */}
|
||||
<section className="forum-replies-section">
|
||||
<h2 className="forum-replies-title">
|
||||
<MessageCircle size={18} />
|
||||
<span>
|
||||
{t('forum.comments')}
|
||||
{post.comments.totalCount > 0 && ` (${post.comments.totalCount})`}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
{/* 回复输入框 | Reply input */}
|
||||
{replyingTo === null && (
|
||||
<form className="forum-reply-form" onSubmit={handleSubmitReply}>
|
||||
<textarea
|
||||
value={replyContent}
|
||||
onChange={(e) => setReplyContent(e.target.value)}
|
||||
placeholder={t('forum.writeComment')}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="forum-reply-form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="forum-btn forum-btn-primary"
|
||||
disabled={!replyContent.trim() || submitting}
|
||||
>
|
||||
<Send size={14} />
|
||||
<span>{submitting ? t('forum.posting') : t('forum.postComment')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 回复列表 | Reply list */}
|
||||
<div className="forum-replies-list">
|
||||
{repliesLoading ? (
|
||||
<div className="forum-replies-loading">
|
||||
<RefreshCw className="spin" size={20} />
|
||||
</div>
|
||||
) : replies.length === 0 ? (
|
||||
<div className="forum-replies-empty">
|
||||
<p>{t('forum.noCommentsYet')}</p>
|
||||
</div>
|
||||
) : (
|
||||
replies.map(reply => renderReply(reply))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
/**
|
||||
* 论坛帖子列表样式
|
||||
* Forum post list styles
|
||||
*/
|
||||
|
||||
.forum-post-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 欢迎横幅 | Welcome banner */
|
||||
.forum-welcome-banner {
|
||||
background: linear-gradient(135deg, #1a365d 0%, #2d3748 100%);
|
||||
border-bottom: 1px solid #3a4a5a;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.forum-welcome-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.forum-welcome-text h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.forum-welcome-text p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.forum-welcome-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.forum-btn-github {
|
||||
background: #24292e;
|
||||
border-color: #444;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.forum-btn-github:hover:not(:disabled) {
|
||||
background: #2f363d;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
/* 分类卡片 | Category cards */
|
||||
.forum-category-cards {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.forum-category-cards::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.forum-category-cards::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.forum-category-cards::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.forum-category-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 16px;
|
||||
min-width: 80px;
|
||||
background: #363636;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-category-card:hover {
|
||||
background: #404040;
|
||||
border-color: #4a9eff;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.forum-category-card-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
border-radius: 50%;
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-category-card-emoji {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.forum-category-card-name {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 工具栏 | Toolbar */
|
||||
.forum-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.forum-toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.forum-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 3px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.forum-search svg {
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.forum-search input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 11px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.forum-search input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 过滤器 | Filters */
|
||||
.forum-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.forum-filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.forum-filter-group svg {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.forum-filter-group select {
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 2px;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.forum-filter-group select:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-post-count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 帖子统计栏 | Stats bar */
|
||||
.forum-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #2a2a2a;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.forum-stats-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.forum-stats-left svg {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-stats-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 11px;
|
||||
color: #4a9eff;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-stats-clear:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 下拉选择框 | Select dropdown */
|
||||
.forum-select {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 3px;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.forum-select:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
/* 帖子列表 | Post list */
|
||||
.forum-posts {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.forum-posts.loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.forum-posts.loading > *:not(.forum-posts-overlay) {
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
/* 加载覆盖层 | Loading overlay */
|
||||
.forum-posts-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(42, 42, 42, 0.7);
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.forum-posts-overlay svg {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-posts-loading,
|
||||
.forum-posts-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
gap: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.forum-posts-empty svg {
|
||||
opacity: 0.3;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.forum-posts-empty p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 帖子项 | Post item */
|
||||
.forum-post-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px 12px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.forum-post-item:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.forum-post-item.hot {
|
||||
background: linear-gradient(90deg, rgba(239, 68, 68, 0.05) 0%, transparent 50%);
|
||||
border-left: 2px solid #ef4444;
|
||||
}
|
||||
|
||||
.forum-post-item.hot:hover {
|
||||
background: linear-gradient(90deg, rgba(239, 68, 68, 0.08) 0%, rgba(255, 255, 255, 0.03) 50%);
|
||||
}
|
||||
|
||||
.forum-post-avatar {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.forum-post-avatar img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.forum-post-avatar-badge {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.forum-post-avatar-badge.hot {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.forum-post-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.forum-post-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.forum-post-badges {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.forum-post-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.forum-post-badge.new {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-post-badge.hot {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.forum-post-badge.pinned {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.forum-post-badge.locked {
|
||||
background: rgba(107, 114, 128, 0.15);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.forum-post-external {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.forum-post-external:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-post-category {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-post-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.forum-post-item:hover .forum-post-title {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-post-excerpt {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.forum-post-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.forum-post-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.forum-post-author-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.forum-post-author-placeholder {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #4a9eff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.forum-post-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 帖子统计 | Post stats */
|
||||
.forum-post-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.forum-post-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.forum-post-stat.active {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.forum-post-stat.active svg {
|
||||
fill: #ef4444;
|
||||
}
|
||||
|
||||
.forum-post-answered {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: #10b981;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 加载更多 | Load more */
|
||||
.forum-load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.forum-load-more .forum-btn {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* 分页 | Pagination */
|
||||
.forum-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid #1a1a1a;
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.forum-pagination-info {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 帖子列表组件 - GitHub Discussions
|
||||
* Post list component - GitHub Discussions
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Plus, RefreshCw, Search, MessageCircle, ThumbsUp,
|
||||
ExternalLink, CheckCircle, Flame, Clock, TrendingUp,
|
||||
Lightbulb, HelpCircle, Megaphone, BarChart3, Github
|
||||
} from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { useLocale } from '../../hooks/useLocale';
|
||||
import type { Post, Category, PostListParams } from '../../services/forum';
|
||||
import { parseEmoji } from './utils';
|
||||
import './ForumPostList.css';
|
||||
|
||||
interface ForumPostListProps {
|
||||
posts: Post[];
|
||||
categories: Category[];
|
||||
loading: boolean;
|
||||
totalCount: number;
|
||||
hasNextPage: boolean;
|
||||
params: PostListParams;
|
||||
onViewPost: (postNumber: number) => void;
|
||||
onCreatePost: () => void;
|
||||
onCategoryChange: (categoryId: string | undefined) => void;
|
||||
onSearch: (search: string) => void;
|
||||
onRefresh: () => void;
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分类图标 | Get category icon
|
||||
*/
|
||||
function getCategoryIcon(name: string) {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (lowerName.includes('idea') || lowerName.includes('建议')) return <Lightbulb size={14} />;
|
||||
if (lowerName.includes('q&a') || lowerName.includes('问答')) return <HelpCircle size={14} />;
|
||||
if (lowerName.includes('show') || lowerName.includes('展示')) return <Megaphone size={14} />;
|
||||
if (lowerName.includes('poll') || lowerName.includes('投票')) return <BarChart3 size={14} />;
|
||||
return <MessageCircle size={14} />;
|
||||
}
|
||||
|
||||
export function ForumPostList({
|
||||
posts,
|
||||
categories,
|
||||
loading,
|
||||
totalCount,
|
||||
hasNextPage,
|
||||
params,
|
||||
onViewPost,
|
||||
onCreatePost,
|
||||
onCategoryChange,
|
||||
onSearch,
|
||||
onRefresh,
|
||||
onLoadMore
|
||||
}: ForumPostListProps) {
|
||||
const { t } = useLocale();
|
||||
const [searchInput, setSearchInput] = useState(params.search || '');
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSearch(searchInput);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
if (hours === 0) {
|
||||
const mins = Math.floor(diff / (1000 * 60));
|
||||
if (mins < 1) return t('forum.justNow');
|
||||
return t('forum.minutesAgo', { count: mins });
|
||||
}
|
||||
return t('forum.hoursAgo', { count: hours });
|
||||
}
|
||||
if (days === 1) return t('forum.yesterday');
|
||||
if (days < 7) return t('forum.daysAgo', { count: days });
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const openInGitHub = async (url: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await open(url);
|
||||
};
|
||||
|
||||
// 检查帖子是否是热门(高点赞或评论)| Check if post is hot
|
||||
const isHotPost = (post: Post) => post.upvoteCount >= 5 || post.comments.totalCount >= 3;
|
||||
|
||||
// 检查帖子是否是新帖(24小时内)| Check if post is recent
|
||||
const isRecentPost = (post: Post) => {
|
||||
const diff = Date.now() - new Date(post.createdAt).getTime();
|
||||
return diff < 24 * 60 * 60 * 1000;
|
||||
};
|
||||
|
||||
const openGitHubDiscussions = async () => {
|
||||
await open('https://github.com/esengine/esengine/discussions');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="forum-post-list">
|
||||
{/* 欢迎横幅 | Welcome banner */}
|
||||
{!params.categoryId && !params.search && (
|
||||
<div className="forum-welcome-banner">
|
||||
<div className="forum-welcome-content">
|
||||
<div className="forum-welcome-text">
|
||||
<h2>{t('forum.communityTitle')}</h2>
|
||||
<p>{t('forum.askQuestionsShareIdeas')}</p>
|
||||
</div>
|
||||
<div className="forum-welcome-actions">
|
||||
<button className="forum-btn forum-btn-primary" onClick={onCreatePost}>
|
||||
<Plus size={14} />
|
||||
<span>{t('forum.newDiscussion')}</span>
|
||||
</button>
|
||||
<button className="forum-btn forum-btn-github" onClick={openGitHubDiscussions}>
|
||||
<Github size={14} />
|
||||
<span>{t('forum.viewOnGitHub')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分类卡片 | Category cards */}
|
||||
{!params.categoryId && !params.search && categories.length > 0 && (
|
||||
<div className="forum-category-cards">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className="forum-category-card"
|
||||
onClick={() => onCategoryChange(cat.id)}
|
||||
>
|
||||
<span className="forum-category-card-icon">
|
||||
{getCategoryIcon(cat.name)}
|
||||
</span>
|
||||
<span className="forum-category-card-emoji">{parseEmoji(cat.emoji)}</span>
|
||||
<span className="forum-category-card-name">{cat.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 工具栏 | Toolbar */}
|
||||
<div className="forum-toolbar">
|
||||
<div className="forum-toolbar-left">
|
||||
<select
|
||||
className="forum-select"
|
||||
value={params.categoryId || ''}
|
||||
onChange={(e) => onCategoryChange(e.target.value || undefined)}
|
||||
>
|
||||
<option value="">{t('forum.allCategories')}</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{parseEmoji(cat.emoji)} {cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<form onSubmit={handleSearchSubmit} className="forum-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('forum.searchDiscussions')}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="forum-toolbar-right">
|
||||
<button
|
||||
className="forum-btn"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
title={t('forum.refresh')}
|
||||
>
|
||||
<RefreshCw size={14} className={loading ? 'spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
className="forum-btn forum-btn-primary"
|
||||
onClick={onCreatePost}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>{t('forum.new')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 帖子统计 | Post stats */}
|
||||
<div className="forum-stats">
|
||||
<div className="forum-stats-left">
|
||||
<TrendingUp size={14} />
|
||||
<span>{totalCount} {t('forum.discussions')}</span>
|
||||
</div>
|
||||
{params.categoryId && (
|
||||
<button
|
||||
className="forum-stats-clear"
|
||||
onClick={() => onCategoryChange(undefined)}
|
||||
>
|
||||
{t('forum.clearFilter')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 帖子列表 | Post list */}
|
||||
<div className={`forum-posts ${loading ? 'loading' : ''}`}>
|
||||
{/* 加载覆盖层 | Loading overlay */}
|
||||
{loading && posts.length > 0 && (
|
||||
<div className="forum-posts-overlay">
|
||||
<RefreshCw size={20} className="spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && posts.length === 0 ? (
|
||||
<div className="forum-posts-loading">
|
||||
<RefreshCw size={16} className="spin" />
|
||||
<span>{t('forum.loading')}</span>
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="forum-posts-empty">
|
||||
<MessageCircle size={32} />
|
||||
<p>{t('forum.noDiscussionsYet')}</p>
|
||||
<button className="forum-btn forum-btn-primary" onClick={onCreatePost}>
|
||||
<Plus size={14} />
|
||||
<span>{t('forum.startADiscussion')}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{posts.map(post => (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`forum-post-item ${isHotPost(post) ? 'hot' : ''}`}
|
||||
onClick={() => onViewPost(post.number)}
|
||||
>
|
||||
<div className="forum-post-avatar">
|
||||
<img
|
||||
src={post.author.avatarUrl}
|
||||
alt={post.author.login}
|
||||
/>
|
||||
{isHotPost(post) && (
|
||||
<span className="forum-post-avatar-badge hot">
|
||||
<Flame size={10} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="forum-post-content">
|
||||
<div className="forum-post-header">
|
||||
<div className="forum-post-badges">
|
||||
{isRecentPost(post) && (
|
||||
<span className="forum-post-badge new">
|
||||
<Clock size={10} />
|
||||
{t('forum.newBadge')}
|
||||
</span>
|
||||
)}
|
||||
{isHotPost(post) && (
|
||||
<span className="forum-post-badge hot">
|
||||
<Flame size={10} />
|
||||
{t('forum.hotBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="forum-post-title">{post.title}</h3>
|
||||
<button
|
||||
className="forum-post-external"
|
||||
onClick={(e) => openInGitHub(post.url, e)}
|
||||
title={t('forum.openInGitHub')}
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="forum-post-meta">
|
||||
<span className="forum-post-category">
|
||||
{parseEmoji(post.category.emoji)} {post.category.name}
|
||||
</span>
|
||||
<span className="forum-post-author">
|
||||
<img
|
||||
src={post.author.avatarUrl}
|
||||
alt={post.author.login}
|
||||
className="forum-post-author-avatar"
|
||||
/>
|
||||
@{post.author.login}
|
||||
</span>
|
||||
<span className="forum-post-time">
|
||||
<Clock size={11} />
|
||||
{formatDate(post.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="forum-post-stats">
|
||||
<span className={`forum-post-stat ${post.viewerHasUpvoted ? 'active' : ''}`}>
|
||||
<ThumbsUp size={12} />
|
||||
{post.upvoteCount}
|
||||
</span>
|
||||
<span className="forum-post-stat">
|
||||
<MessageCircle size={12} />
|
||||
{post.comments.totalCount}
|
||||
</span>
|
||||
{post.answerChosenAt && (
|
||||
<span className="forum-post-answered">
|
||||
<CheckCircle size={12} />
|
||||
{t('forum.answered')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 加载更多 | Load more */}
|
||||
{hasNextPage && (
|
||||
<div className="forum-load-more">
|
||||
<button
|
||||
className="forum-btn"
|
||||
onClick={onLoadMore}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<RefreshCw size={14} className="spin" />
|
||||
<span>{t('forum.loading')}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{t('forum.loadMore')}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 用户资料样式 - GitHub
|
||||
* User profile styles - GitHub
|
||||
*/
|
||||
|
||||
.forum-profile {
|
||||
padding: 16px;
|
||||
background: #2a2a2a;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.forum-profile-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.forum-profile-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.forum-profile-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.forum-profile-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.forum-profile-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.forum-profile-github-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.forum-profile-github-link:hover {
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.forum-profile-divider {
|
||||
height: 1px;
|
||||
background: #3a3a3a;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.forum-profile-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.forum-profile-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.forum-profile-btn.logout {
|
||||
background: transparent;
|
||||
border-color: #4a4a4a;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.forum-profile-btn.logout:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: #f87171;
|
||||
color: #f87171;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 用户资料组件 - GitHub
|
||||
* User profile component - GitHub
|
||||
*/
|
||||
import { Github, LogOut, ExternalLink } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { useLocale } from '../../hooks/useLocale';
|
||||
import { useForumAuth } from '../../hooks/useForum';
|
||||
import './ForumProfile.css';
|
||||
|
||||
interface ForumProfileProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function ForumProfile({ onClose }: ForumProfileProps) {
|
||||
const { t } = useLocale();
|
||||
const { authState, signOut } = useForumAuth();
|
||||
|
||||
const user = authState.status === 'authenticated' ? authState.user : null;
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const openGitHubProfile = async () => {
|
||||
if (user) {
|
||||
await open(`https://github.com/${user.login}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="forum-profile">
|
||||
<div className="forum-profile-header">
|
||||
<div className="forum-profile-avatar">
|
||||
<img src={user.avatarUrl} alt={user.login} />
|
||||
</div>
|
||||
<div className="forum-profile-info">
|
||||
<h3 className="forum-profile-name">@{user.login}</h3>
|
||||
<button
|
||||
className="forum-profile-github-link"
|
||||
onClick={openGitHubProfile}
|
||||
>
|
||||
<Github size={12} />
|
||||
<span>{t('forum.viewGitHubProfile')}</span>
|
||||
<ExternalLink size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="forum-profile-divider" />
|
||||
|
||||
<div className="forum-profile-actions">
|
||||
<button className="forum-profile-btn logout" onClick={handleSignOut}>
|
||||
<LogOut size={14} />
|
||||
<span>{t('forum.signOut')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 论坛组件导出
|
||||
* Forum components exports
|
||||
*/
|
||||
export { ForumPanel } from './ForumPanel';
|
||||
export { ForumAuth } from './ForumAuth';
|
||||
export { ForumPostList } from './ForumPostList';
|
||||
export { ForumPostDetail } from './ForumPostDetail';
|
||||
export { ForumCreatePost } from './ForumCreatePost';
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 论坛工具函数 | Forum utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* GitHub emoji 短代码映射表 | GitHub emoji shortcode mapping
|
||||
*/
|
||||
const EMOJI_MAP: Record<string, string> = {
|
||||
':speech_balloon:': '💬',
|
||||
':bulb:': '💡',
|
||||
':pray:': '🙏',
|
||||
':raised_hands:': '🙌',
|
||||
':ballot_box:': '🗳️',
|
||||
':rocket:': '🚀',
|
||||
':bug:': '🐛',
|
||||
':sparkles:': '✨',
|
||||
':memo:': '📝',
|
||||
':question:': '❓',
|
||||
':fire:': '🔥',
|
||||
':star:': '⭐',
|
||||
':heart:': '❤️',
|
||||
':thumbsup:': '👍',
|
||||
':warning:': '⚠️',
|
||||
':book:': '📖',
|
||||
':wrench:': '🔧',
|
||||
':gear:': '⚙️',
|
||||
':zap:': '⚡',
|
||||
':art:': '🎨',
|
||||
':package:': '📦',
|
||||
':lock:': '🔒',
|
||||
':tada:': '🎉',
|
||||
':wave:': '👋',
|
||||
':eyes:': '👀',
|
||||
':thinking:': '🤔',
|
||||
':100:': '💯',
|
||||
':clap:': '👏',
|
||||
':hammer_and_wrench:': '🛠️',
|
||||
':world_map:': '🗺️',
|
||||
':video_game:': '🎮',
|
||||
':computer:': '💻',
|
||||
':pencil:': '✏️',
|
||||
':pencil2:': '✏️',
|
||||
':notebook:': '📓',
|
||||
':clipboard:': '📋',
|
||||
':pushpin:': '📌',
|
||||
':loudspeaker:': '📢',
|
||||
':mega:': '📣',
|
||||
':bell:': '🔔',
|
||||
':email:': '📧',
|
||||
':mailbox:': '📫',
|
||||
':inbox_tray:': '📥',
|
||||
':outbox_tray:': '📤',
|
||||
':file_folder:': '📁',
|
||||
':open_file_folder:': '📂',
|
||||
':card_index:': '📇',
|
||||
':chart_with_upwards_trend:': '📈',
|
||||
':chart_with_downwards_trend:': '📉',
|
||||
':bar_chart:': '📊',
|
||||
':date:': '📅',
|
||||
':calendar:': '📆',
|
||||
':card_index_dividers:': '🗂️',
|
||||
':triangular_ruler:': '📐',
|
||||
':straight_ruler:': '📏',
|
||||
':scissors:': '✂️',
|
||||
':link:': '🔗',
|
||||
':paperclip:': '📎',
|
||||
':hourglass:': '⌛',
|
||||
':watch:': '⌚',
|
||||
':alarm_clock:': '⏰',
|
||||
':stopwatch:': '⏱️',
|
||||
':timer_clock:': '⏲️',
|
||||
':telephone:': '☎️',
|
||||
':telephone_receiver:': '📞',
|
||||
':pager:': '📟',
|
||||
':fax:': '📠',
|
||||
':battery:': '🔋',
|
||||
':electric_plug:': '🔌',
|
||||
':desktop_computer:': '🖥️',
|
||||
':printer:': '🖨️',
|
||||
':keyboard:': '⌨️',
|
||||
':computer_mouse:': '🖱️',
|
||||
':trackball:': '🖲️',
|
||||
':minidisc:': '💽',
|
||||
':floppy_disk:': '💾',
|
||||
':cd:': '💿',
|
||||
':dvd:': '📀',
|
||||
':abacus:': '🧮',
|
||||
':movie_camera:': '🎥',
|
||||
':film_strip:': '🎞️',
|
||||
':film_projector:': '📽️',
|
||||
':clapper:': '🎬',
|
||||
':tv:': '📺',
|
||||
':camera:': '📷',
|
||||
':camera_flash:': '📸',
|
||||
':video_camera:': '📹',
|
||||
':mag:': '🔍',
|
||||
':mag_right:': '🔎',
|
||||
':candle:': '🕯️',
|
||||
':bulb_lightbulb:': '💡',
|
||||
':flashlight:': '🔦',
|
||||
':izakaya_lantern:': '🏮',
|
||||
':diya_lamp:': '🪔',
|
||||
':notebook_with_decorative_cover:': '📔',
|
||||
':closed_book:': '📕',
|
||||
':green_book:': '📗',
|
||||
':blue_book:': '📘',
|
||||
':orange_book:': '📙',
|
||||
':books:': '📚',
|
||||
':ledger:': '📒',
|
||||
':page_with_curl:': '📃',
|
||||
':scroll:': '📜',
|
||||
':page_facing_up:': '📄',
|
||||
':newspaper:': '📰',
|
||||
':rolled_up_newspaper:': '🗞️',
|
||||
':bookmark_tabs:': '📑',
|
||||
':bookmark:': '🔖',
|
||||
':label:': '🏷️',
|
||||
':moneybag:': '💰',
|
||||
':coin:': '🪙',
|
||||
':yen:': '💴',
|
||||
':dollar:': '💵',
|
||||
':euro:': '💶',
|
||||
':pound:': '💷',
|
||||
':money_with_wings:': '💸',
|
||||
':credit_card:': '💳',
|
||||
':receipt:': '🧾',
|
||||
':chart:': '💹',
|
||||
};
|
||||
|
||||
/**
|
||||
* 转换 GitHub emoji 短代码为 Unicode | Convert GitHub emoji shortcode to Unicode
|
||||
*/
|
||||
export function parseEmoji(emojiCode: string | undefined | null): string {
|
||||
if (!emojiCode) return '💬';
|
||||
// 如果已经是 emoji,直接返回 | If already emoji, return directly
|
||||
if (!emojiCode.startsWith(':')) return emojiCode;
|
||||
return EMOJI_MAP[emojiCode] || emojiCode.replace(/:/g, '');
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* ComponentPropertyEditor - 组件属性编辑器
|
||||
* ComponentPropertyEditor - Component property editor
|
||||
*
|
||||
* 使用新控件渲染组件属性
|
||||
* Renders component properties using new controls
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Component, Core, Entity, getComponentInstanceTypeName, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import { PropertyMetadataService, MessageHub, PrefabService, FileActionRegistry, AssetRegistryService } from '@esengine/editor-core';
|
||||
import { Lock } from 'lucide-react';
|
||||
import {
|
||||
PropertyRow,
|
||||
NumberInput,
|
||||
StringInput,
|
||||
BooleanInput,
|
||||
VectorInput,
|
||||
EnumInput,
|
||||
ColorInput,
|
||||
AssetInput,
|
||||
EntityRefInput,
|
||||
ArrayInput
|
||||
} from './controls';
|
||||
|
||||
// ==================== 类型定义 | Type Definitions ====================
|
||||
|
||||
interface PropertyMetadata {
|
||||
type: string;
|
||||
label?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
readOnly?: boolean;
|
||||
placeholder?: string;
|
||||
options?: Array<{ label: string; value: string | number } | string | number>;
|
||||
controls?: Array<{ component: string; property: string }>;
|
||||
category?: string;
|
||||
assetType?: string;
|
||||
extensions?: string[];
|
||||
itemType?: { type: string; extensions?: string[]; assetType?: string };
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
reorderable?: boolean;
|
||||
actions?: Array<{ id: string; label: string; icon?: string; tooltip?: string }>;
|
||||
}
|
||||
|
||||
export interface ComponentPropertyEditorProps {
|
||||
/** 组件实例 | Component instance */
|
||||
component: Component;
|
||||
/** 所属实体 | Owner entity */
|
||||
entity?: Entity;
|
||||
/** 版本号 | Version number */
|
||||
version?: number;
|
||||
/** 属性变更回调 | Property change callback */
|
||||
onChange?: (propertyName: string, value: any) => void;
|
||||
/** 动作回调 | Action callback */
|
||||
onAction?: (actionId: string, propertyName: string, component: Component) => void;
|
||||
}
|
||||
|
||||
// ==================== 主组件 | Main Component ====================
|
||||
|
||||
export const ComponentPropertyEditor: React.FC<ComponentPropertyEditorProps> = ({
|
||||
component,
|
||||
entity,
|
||||
version,
|
||||
onChange,
|
||||
onAction
|
||||
}) => {
|
||||
const [properties, setProperties] = useState<Record<string, PropertyMetadata>>({});
|
||||
const [controlledFields, setControlledFields] = useState<Map<string, string>>(new Map());
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; propertyName: string } | null>(null);
|
||||
|
||||
// 服务 | Services
|
||||
const prefabService = useMemo(() => Core.services.tryResolve(PrefabService) as PrefabService | null, []);
|
||||
const componentTypeName = useMemo(() => getComponentInstanceTypeName(component), [component]);
|
||||
|
||||
// 预制体实例组件 | Prefab instance component
|
||||
const prefabInstanceComp = useMemo(() => {
|
||||
return entity?.getComponent(PrefabInstanceComponent) ?? null;
|
||||
}, [entity, version]);
|
||||
|
||||
// 检查属性是否被覆盖 | Check if property is overridden
|
||||
const isPropertyOverridden = useCallback((propertyName: string): boolean => {
|
||||
if (!prefabInstanceComp) return false;
|
||||
return prefabInstanceComp.isPropertyModified(componentTypeName, propertyName);
|
||||
}, [prefabInstanceComp, componentTypeName]);
|
||||
|
||||
// 加载属性元数据 | Load property metadata
|
||||
useEffect(() => {
|
||||
const propertyMetadataService = Core.services.resolve(PropertyMetadataService);
|
||||
if (!propertyMetadataService) return;
|
||||
|
||||
const metadata = propertyMetadataService.getEditableProperties(component);
|
||||
setProperties(metadata as Record<string, PropertyMetadata>);
|
||||
}, [component]);
|
||||
|
||||
// 扫描控制字段 | Scan controlled fields
|
||||
useEffect(() => {
|
||||
if (!entity) return;
|
||||
|
||||
const propertyMetadataService = Core.services.resolve(PropertyMetadataService);
|
||||
if (!propertyMetadataService) return;
|
||||
|
||||
const componentName = getComponentInstanceTypeName(component);
|
||||
const controlled = new Map<string, string>();
|
||||
|
||||
for (const otherComponent of entity.components) {
|
||||
if (otherComponent === component) continue;
|
||||
|
||||
const otherMetadata = propertyMetadataService.getEditableProperties(otherComponent) as Record<string, PropertyMetadata>;
|
||||
const otherComponentName = getComponentInstanceTypeName(otherComponent);
|
||||
|
||||
for (const [, propMeta] of Object.entries(otherMetadata)) {
|
||||
if (propMeta.controls) {
|
||||
for (const control of propMeta.controls) {
|
||||
if (control.component === componentName ||
|
||||
control.component === componentName.replace('Component', '')) {
|
||||
controlled.set(control.property, otherComponentName.replace('Component', ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setControlledFields(controlled);
|
||||
}, [component, entity, version]);
|
||||
|
||||
// 关闭右键菜单 | Close context menu
|
||||
useEffect(() => {
|
||||
const handleClick = () => setContextMenu(null);
|
||||
document.addEventListener('click', handleClick);
|
||||
return () => document.removeEventListener('click', handleClick);
|
||||
}, []);
|
||||
|
||||
// 获取属性值 | Get property value
|
||||
const getValue = useCallback((propertyName: string) => {
|
||||
return (component as any)[propertyName];
|
||||
}, [component, version]);
|
||||
|
||||
// 处理属性变更 | Handle property change
|
||||
const handleChange = useCallback((propertyName: string, value: any) => {
|
||||
(component as any)[propertyName] = value;
|
||||
|
||||
if (onChange) {
|
||||
onChange(propertyName, value);
|
||||
}
|
||||
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('scene:modified', {});
|
||||
}
|
||||
}, [component, onChange]);
|
||||
|
||||
// 处理动作 | Handle action
|
||||
const handleAction = useCallback((actionId: string, propertyName: string) => {
|
||||
if (onAction) {
|
||||
onAction(actionId, propertyName, component);
|
||||
}
|
||||
}, [onAction, component]);
|
||||
|
||||
// 还原属性 | Revert property
|
||||
const handleRevertProperty = useCallback(async () => {
|
||||
if (!contextMenu || !prefabService || !entity) return;
|
||||
await prefabService.revertProperty(entity, componentTypeName, contextMenu.propertyName);
|
||||
setContextMenu(null);
|
||||
}, [contextMenu, prefabService, entity, componentTypeName]);
|
||||
|
||||
// 处理右键菜单 | Handle context menu
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, propertyName: string) => {
|
||||
if (!isPropertyOverridden(propertyName)) return;
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, propertyName });
|
||||
}, [isPropertyOverridden]);
|
||||
|
||||
// 获取控制者 | Get controlled by
|
||||
const getControlledBy = (propertyName: string): string | undefined => {
|
||||
return controlledFields.get(propertyName);
|
||||
};
|
||||
|
||||
// ==================== 渲染属性 | Render Property ====================
|
||||
|
||||
const renderProperty = (propertyName: string, metadata: PropertyMetadata) => {
|
||||
const value = getValue(propertyName);
|
||||
const label = metadata.label || propertyName;
|
||||
const readonly = metadata.readOnly || !!getControlledBy(propertyName);
|
||||
const controlledBy = getControlledBy(propertyName);
|
||||
|
||||
// 标签后缀(如果被控制)| Label suffix (if controlled)
|
||||
const labelElement = controlledBy ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
{label}
|
||||
<span title={`Controlled by ${controlledBy}`}>
|
||||
<Lock size={10} style={{ color: 'var(--inspector-text-secondary)' }} />
|
||||
</span>
|
||||
</span>
|
||||
) : label;
|
||||
const labelTitle = label;
|
||||
|
||||
switch (metadata.type) {
|
||||
case 'number':
|
||||
case 'integer':
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle} draggable>
|
||||
<NumberInput
|
||||
value={value ?? 0}
|
||||
onChange={(v) => handleChange(propertyName, v)}
|
||||
readonly={readonly}
|
||||
min={metadata.min}
|
||||
max={metadata.max}
|
||||
step={metadata.step ?? (metadata.type === 'integer' ? 1 : 0.1)}
|
||||
integer={metadata.type === 'integer'}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
case 'string':
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<StringInput
|
||||
value={value ?? ''}
|
||||
onChange={(v) => handleChange(propertyName, v)}
|
||||
readonly={readonly}
|
||||
placeholder={metadata.placeholder}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<BooleanInput
|
||||
value={value ?? false}
|
||||
onChange={(v) => handleChange(propertyName, v)}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
case 'color': {
|
||||
let colorValue = value ?? '#ffffff';
|
||||
const wasNumber = typeof colorValue === 'number';
|
||||
if (wasNumber) {
|
||||
colorValue = '#' + colorValue.toString(16).padStart(6, '0');
|
||||
}
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<ColorInput
|
||||
value={colorValue}
|
||||
onChange={(v) => {
|
||||
if (wasNumber && typeof v === 'string') {
|
||||
handleChange(propertyName, parseInt(v.slice(1), 16));
|
||||
} else {
|
||||
handleChange(propertyName, v);
|
||||
}
|
||||
}}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
}
|
||||
|
||||
case 'vector2':
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<VectorInput
|
||||
value={value ?? { x: 0, y: 0 }}
|
||||
onChange={(v) => handleChange(propertyName, v)}
|
||||
readonly={readonly}
|
||||
dimensions={2}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
case 'vector3':
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<VectorInput
|
||||
value={value ?? { x: 0, y: 0, z: 0 }}
|
||||
onChange={(v) => handleChange(propertyName, v)}
|
||||
readonly={readonly}
|
||||
dimensions={3}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
case 'enum': {
|
||||
const options = (metadata.options || []).map(opt =>
|
||||
typeof opt === 'object' ? opt : { label: String(opt), value: opt }
|
||||
);
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<EnumInput
|
||||
value={value}
|
||||
onChange={(v) => handleChange(propertyName, v)}
|
||||
readonly={readonly}
|
||||
options={options}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
}
|
||||
|
||||
case 'asset': {
|
||||
const handleNavigate = (path: string) => {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('asset:reveal', { path });
|
||||
}
|
||||
};
|
||||
|
||||
const fileActionRegistry = Core.services.tryResolve(FileActionRegistry);
|
||||
const getCreationMapping = () => {
|
||||
if (!fileActionRegistry || !metadata.extensions) return null;
|
||||
for (const ext of metadata.extensions) {
|
||||
const mapping = (fileActionRegistry as any).getAssetCreationMapping?.(ext);
|
||||
if (mapping) return mapping;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const creationMapping = getCreationMapping();
|
||||
|
||||
// 解析资产值 | Resolve asset value
|
||||
// 检查值是否为 GUID(UUID 格式)并尝试解析为路径
|
||||
// Check if value is a GUID (UUID format) and try to resolve to path
|
||||
const resolveAssetValue = () => {
|
||||
if (!value) return null;
|
||||
const strValue = String(value);
|
||||
|
||||
// GUID 格式检查:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
// UUID format check
|
||||
const isGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(strValue);
|
||||
|
||||
if (isGuid) {
|
||||
// 尝试从 AssetRegistryService 获取路径
|
||||
// Try to get path from AssetRegistryService
|
||||
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
|
||||
if (assetRegistry) {
|
||||
const assetMeta = assetRegistry.getAsset(strValue);
|
||||
if (assetMeta) {
|
||||
return {
|
||||
id: strValue,
|
||||
path: assetMeta.path,
|
||||
type: assetMeta.type
|
||||
};
|
||||
}
|
||||
}
|
||||
// 如果无法解析,仍然显示 GUID
|
||||
// If cannot resolve, still show GUID
|
||||
return { id: strValue, path: strValue };
|
||||
}
|
||||
|
||||
// 不是 GUID,假设是路径
|
||||
// Not a GUID, assume it's a path
|
||||
return { id: strValue, path: strValue };
|
||||
};
|
||||
|
||||
const assetValue = resolveAssetValue();
|
||||
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<AssetInput
|
||||
value={assetValue}
|
||||
onChange={(v) => {
|
||||
if (v === null) {
|
||||
handleChange(propertyName, '');
|
||||
} else if (typeof v === 'string') {
|
||||
handleChange(propertyName, v);
|
||||
} else {
|
||||
// 存储路径而不是 GUID
|
||||
// Store path instead of GUID
|
||||
handleChange(propertyName, v.path || v.id || '');
|
||||
}
|
||||
}}
|
||||
readonly={readonly}
|
||||
extensions={metadata.extensions}
|
||||
onPickAsset={() => {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('asset:pick', {
|
||||
extensions: metadata.extensions,
|
||||
onSelect: (path: string) => handleChange(propertyName, path)
|
||||
});
|
||||
}
|
||||
}}
|
||||
onOpenAsset={(asset) => {
|
||||
if (asset.path) handleNavigate(asset.path);
|
||||
}}
|
||||
onLocateAsset={(asset) => {
|
||||
if (asset.path) handleNavigate(asset.path);
|
||||
}}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
}
|
||||
|
||||
case 'entityRef':
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<EntityRefInput
|
||||
value={value ?? null}
|
||||
onChange={(v) => {
|
||||
const id = typeof v === 'object' && v !== null ? v.id : v;
|
||||
handleChange(propertyName, id);
|
||||
}}
|
||||
readonly={readonly}
|
||||
resolveEntityName={(id) => {
|
||||
if (!entity) return undefined;
|
||||
const scene = entity.scene;
|
||||
if (!scene) return undefined;
|
||||
const targetEntity = (scene as any).getEntityById?.(Number(id));
|
||||
return targetEntity?.name;
|
||||
}}
|
||||
onLocateEntity={(id) => {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('hierarchy:select', { entityId: Number(id) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
|
||||
case 'array': {
|
||||
return (
|
||||
<PropertyRow key={propertyName} label={labelElement} labelTitle={labelTitle}>
|
||||
<ArrayInput
|
||||
value={value ?? []}
|
||||
onChange={(v) => handleChange(propertyName, v)}
|
||||
readonly={readonly}
|
||||
minItems={metadata.minLength}
|
||||
maxItems={metadata.maxLength}
|
||||
sortable={metadata.reorderable ?? true}
|
||||
/>
|
||||
</PropertyRow>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 渲染 | Render ====================
|
||||
|
||||
return (
|
||||
<div className="component-property-editor">
|
||||
{Object.entries(properties).map(([propertyName, metadata]) => {
|
||||
const overridden = isPropertyOverridden(propertyName);
|
||||
return (
|
||||
<div
|
||||
key={propertyName}
|
||||
className={overridden ? 'property-overridden' : ''}
|
||||
onContextMenu={(e) => handleContextMenu(e, propertyName)}
|
||||
style={overridden ? { borderLeft: '2px solid var(--inspector-accent)' } : undefined}
|
||||
>
|
||||
{renderProperty(propertyName, metadata)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
background: 'var(--inspector-bg-section)',
|
||||
border: '1px solid var(--inspector-border-light)',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)',
|
||||
zIndex: 1000,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleRevertProperty}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
width: '100%',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--inspector-text-primary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--inspector-bg-hover)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<span>↩</span>
|
||||
<span>Revert to Prefab</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,695 @@
|
||||
/**
|
||||
* EntityInspectorPanel - 实体检视器面板
|
||||
* EntityInspectorPanel - Entity inspector panel
|
||||
*
|
||||
* 使用新 Inspector 架构的实体检视器
|
||||
* Entity inspector using new Inspector architecture
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
X,
|
||||
Box,
|
||||
Search,
|
||||
Lock,
|
||||
Unlock,
|
||||
Settings
|
||||
} from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import {
|
||||
Entity,
|
||||
Component,
|
||||
Core,
|
||||
getComponentDependencies,
|
||||
getComponentTypeName,
|
||||
getComponentInstanceTypeName,
|
||||
isComponentInstanceHiddenInInspector,
|
||||
PrefabInstanceComponent
|
||||
} from '@esengine/ecs-framework';
|
||||
import {
|
||||
MessageHub,
|
||||
CommandManager,
|
||||
EditorComponentRegistry,
|
||||
ComponentActionRegistry,
|
||||
ComponentInspectorRegistry,
|
||||
PrefabService,
|
||||
PropertyMetadataService
|
||||
} from '@esengine/editor-core';
|
||||
import { NotificationService } from '../../services/NotificationService';
|
||||
import {
|
||||
RemoveComponentCommand,
|
||||
UpdateComponentCommand,
|
||||
AddComponentCommand
|
||||
} from '../../application/commands/component';
|
||||
import { PropertySearch, CategoryTabs } from './header';
|
||||
import { PropertySection } from './sections';
|
||||
import { ComponentPropertyEditor } from './ComponentPropertyEditor';
|
||||
import { CategoryConfig } from './types';
|
||||
import './styles/inspector.css';
|
||||
|
||||
// ==================== 类型定义 | Type Definitions ====================
|
||||
|
||||
type CategoryFilter = 'all' | 'general' | 'transform' | 'rendering' | 'physics' | 'audio' | 'other';
|
||||
|
||||
interface ComponentInfo {
|
||||
name: string;
|
||||
type?: new () => Component;
|
||||
category?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface EntityInspectorPanelProps {
|
||||
/** 目标实体 | Target entity */
|
||||
entity: Entity;
|
||||
/** 消息中心 | Message hub */
|
||||
messageHub: MessageHub;
|
||||
/** 命令管理器 | Command manager */
|
||||
commandManager: CommandManager;
|
||||
/** 组件版本号 | Component version */
|
||||
componentVersion: number;
|
||||
/** 是否锁定 | Is locked */
|
||||
isLocked?: boolean;
|
||||
/** 锁定变更回调 | Lock change callback */
|
||||
onLockChange?: (locked: boolean) => void;
|
||||
}
|
||||
|
||||
// ==================== 常量 | Constants ====================
|
||||
|
||||
const CATEGORY_MAP: Record<string, CategoryFilter> = {
|
||||
'components.category.core': 'general',
|
||||
'components.category.rendering': 'rendering',
|
||||
'components.category.physics': 'physics',
|
||||
'components.category.audio': 'audio',
|
||||
'components.category.ui': 'rendering',
|
||||
'components.category.ui.core': 'rendering',
|
||||
'components.category.ui.widgets': 'rendering',
|
||||
'components.category.other': 'other',
|
||||
};
|
||||
|
||||
const CATEGORY_TABS: CategoryConfig[] = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'transform', label: 'Transform' },
|
||||
{ id: 'rendering', label: 'Rendering' },
|
||||
{ id: 'physics', label: 'Physics' },
|
||||
{ id: 'audio', label: 'Audio' },
|
||||
{ id: 'other', label: 'Other' },
|
||||
{ id: 'all', label: 'All' }
|
||||
];
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
'components.category.core': '核心',
|
||||
'components.category.rendering': '渲染',
|
||||
'components.category.physics': '物理',
|
||||
'components.category.audio': '音频',
|
||||
'components.category.ui': 'UI',
|
||||
'components.category.ui.core': 'UI 核心',
|
||||
'components.category.ui.widgets': 'UI 控件',
|
||||
'components.category.other': '其他',
|
||||
};
|
||||
|
||||
// ==================== 主组件 | Main Component ====================
|
||||
|
||||
export const EntityInspectorPanel: React.FC<EntityInspectorPanelProps> = ({
|
||||
entity,
|
||||
messageHub,
|
||||
commandManager,
|
||||
componentVersion,
|
||||
isLocked = false,
|
||||
onLockChange
|
||||
}) => {
|
||||
// ==================== 状态 | State ====================
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
|
||||
const [localVersion, setLocalVersion] = useState(0);
|
||||
|
||||
// 折叠状态(持久化)| Collapsed state (persisted)
|
||||
const [collapsedComponents, setCollapsedComponents] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('inspector-collapsed-components');
|
||||
return saved ? new Set(JSON.parse(saved)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
|
||||
// 组件添加菜单 | Component add menu
|
||||
const [showAddMenu, setShowAddMenu] = useState(false);
|
||||
const [addMenuSearch, setAddMenuSearch] = useState('');
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const addButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ==================== 服务 | Services ====================
|
||||
|
||||
const componentRegistry = Core.services.resolve(EditorComponentRegistry);
|
||||
const componentActionRegistry = Core.services.resolve(ComponentActionRegistry);
|
||||
const componentInspectorRegistry = Core.services.resolve(ComponentInspectorRegistry);
|
||||
const prefabService = Core.services.tryResolve(PrefabService) as PrefabService | null;
|
||||
const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[];
|
||||
|
||||
// ==================== 计算属性 | Computed Properties ====================
|
||||
|
||||
const isPrefabInstance = useMemo(() => {
|
||||
return entity.hasComponent(PrefabInstanceComponent);
|
||||
}, [entity, componentVersion]);
|
||||
|
||||
const getComponentCategory = useCallback((componentName: string): CategoryFilter => {
|
||||
const componentInfo = componentRegistry?.getComponent(componentName);
|
||||
if (componentInfo?.category) {
|
||||
return CATEGORY_MAP[componentInfo.category] || 'general';
|
||||
}
|
||||
return 'general';
|
||||
}, [componentRegistry]);
|
||||
|
||||
// 计算当前实体拥有的分类 | Compute categories present in current entity
|
||||
const availableCategories = useMemo((): CategoryConfig[] => {
|
||||
const categorySet = new Set<CategoryFilter>();
|
||||
|
||||
entity.components.forEach((component: Component) => {
|
||||
if (isComponentInstanceHiddenInInspector(component)) return;
|
||||
const componentName = getComponentInstanceTypeName(component);
|
||||
const category = getComponentCategory(componentName);
|
||||
categorySet.add(category);
|
||||
});
|
||||
|
||||
// 只显示实际存在的分类 + All | Only show categories that exist + All
|
||||
const categories: CategoryConfig[] = [];
|
||||
|
||||
// 按固定顺序添加存在的分类 | Add existing categories in fixed order
|
||||
const orderedCategories: { id: CategoryFilter; label: string }[] = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'transform', label: 'Transform' },
|
||||
{ id: 'rendering', label: 'Rendering' },
|
||||
{ id: 'physics', label: 'Physics' },
|
||||
{ id: 'audio', label: 'Audio' },
|
||||
{ id: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
for (const cat of orderedCategories) {
|
||||
if (categorySet.has(cat.id)) {
|
||||
categories.push(cat);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有多个分类,添加 All 选项 | If multiple categories, add All option
|
||||
if (categories.length > 1) {
|
||||
categories.push({ id: 'all', label: 'All' });
|
||||
}
|
||||
|
||||
return categories;
|
||||
}, [entity.components, getComponentCategory, componentVersion]);
|
||||
|
||||
// 过滤组件列表 | Filter component list
|
||||
const filteredComponents = useMemo(() => {
|
||||
return entity.components.filter((component: Component) => {
|
||||
if (isComponentInstanceHiddenInInspector(component)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const componentName = getComponentInstanceTypeName(component);
|
||||
|
||||
if (categoryFilter !== 'all') {
|
||||
const category = getComponentCategory(componentName);
|
||||
if (category !== categoryFilter) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
if (!componentName.toLowerCase().includes(query)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [entity.components, categoryFilter, searchQuery, getComponentCategory, componentVersion]);
|
||||
|
||||
// 添加菜单组件分组 | Add menu component grouping
|
||||
const groupedComponents = useMemo(() => {
|
||||
const query = addMenuSearch.toLowerCase().trim();
|
||||
const filtered = query
|
||||
? availableComponents.filter(c =>
|
||||
c.name.toLowerCase().includes(query) ||
|
||||
(c.description && c.description.toLowerCase().includes(query))
|
||||
)
|
||||
: availableComponents;
|
||||
|
||||
const grouped = new Map<string, ComponentInfo[]>();
|
||||
filtered.forEach((info) => {
|
||||
const cat = info.category || 'components.category.other';
|
||||
if (!grouped.has(cat)) grouped.set(cat, []);
|
||||
grouped.get(cat)!.push(info);
|
||||
});
|
||||
return grouped;
|
||||
}, [availableComponents, addMenuSearch]);
|
||||
|
||||
// 扁平化列表(用于键盘导航)| Flat list (for keyboard navigation)
|
||||
const flatComponents = useMemo(() => {
|
||||
const result: ComponentInfo[] = [];
|
||||
for (const [category, components] of groupedComponents.entries()) {
|
||||
const isCollapsed = collapsedCategories.has(category) && !addMenuSearch;
|
||||
if (!isCollapsed) {
|
||||
result.push(...components);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [groupedComponents, collapsedCategories, addMenuSearch]);
|
||||
|
||||
// ==================== 副作用 | Effects ====================
|
||||
|
||||
// 保存折叠状态 | Save collapsed state
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'inspector-collapsed-components',
|
||||
JSON.stringify([...collapsedComponents])
|
||||
);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}, [collapsedComponents]);
|
||||
|
||||
// 打开添加菜单时聚焦搜索 | Focus search when opening add menu
|
||||
useEffect(() => {
|
||||
if (showAddMenu) {
|
||||
setAddMenuSearch('');
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [showAddMenu]);
|
||||
|
||||
// 重置选中索引 | Reset selected index
|
||||
useEffect(() => {
|
||||
setSelectedIndex(addMenuSearch ? 0 : -1);
|
||||
}, [addMenuSearch]);
|
||||
|
||||
// 当前分类不可用时重置 | Reset when current category is not available
|
||||
useEffect(() => {
|
||||
if (availableCategories.length <= 1) {
|
||||
// 只有一个或没有分类时,使用 all
|
||||
setCategoryFilter('all');
|
||||
} else if (categoryFilter !== 'all' && !availableCategories.some(c => c.id === categoryFilter)) {
|
||||
// 当前分类不在可用列表中,重置为 all
|
||||
setCategoryFilter('all');
|
||||
}
|
||||
}, [availableCategories, categoryFilter]);
|
||||
|
||||
// ==================== 事件处理 | Event Handlers ====================
|
||||
|
||||
const toggleComponentExpanded = useCallback((componentName: string) => {
|
||||
setCollapsedComponents(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(componentName)) {
|
||||
newSet.delete(componentName);
|
||||
} else {
|
||||
newSet.add(componentName);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAddComponent = useCallback((ComponentClass: new () => Component) => {
|
||||
const command = new AddComponentCommand(messageHub, entity, ComponentClass);
|
||||
commandManager.execute(command);
|
||||
setShowAddMenu(false);
|
||||
}, [messageHub, entity, commandManager]);
|
||||
|
||||
const handleRemoveComponent = useCallback((component: Component) => {
|
||||
const componentName = getComponentTypeName(component.constructor as any);
|
||||
|
||||
// 检查依赖 | Check dependencies
|
||||
const dependentComponents: string[] = [];
|
||||
for (const otherComponent of entity.components) {
|
||||
if (otherComponent === component) continue;
|
||||
|
||||
const dependencies = getComponentDependencies(otherComponent.constructor as any);
|
||||
const otherName = getComponentTypeName(otherComponent.constructor as any);
|
||||
if (dependencies && dependencies.includes(componentName)) {
|
||||
dependentComponents.push(otherName);
|
||||
}
|
||||
}
|
||||
|
||||
if (dependentComponents.length > 0) {
|
||||
const notificationService = Core.services.tryResolve(NotificationService) as NotificationService | null;
|
||||
if (notificationService) {
|
||||
notificationService.warning(
|
||||
'无法删除组件',
|
||||
`${componentName} 被以下组件依赖: ${dependentComponents.join(', ')}。请先删除这些组件。`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const command = new RemoveComponentCommand(messageHub, entity, component);
|
||||
commandManager.execute(command);
|
||||
}, [messageHub, entity, commandManager]);
|
||||
|
||||
const handlePropertyChange = useCallback((component: Component, propertyName: string, value: unknown) => {
|
||||
const command = new UpdateComponentCommand(
|
||||
messageHub,
|
||||
entity,
|
||||
component,
|
||||
propertyName,
|
||||
value
|
||||
);
|
||||
commandManager.execute(command);
|
||||
}, [messageHub, entity, commandManager]);
|
||||
|
||||
const handlePropertyAction = useCallback(async (actionId: string, _propertyName: string, component: Component) => {
|
||||
if (actionId === 'nativeSize' && component.constructor.name === 'SpriteComponent') {
|
||||
const sprite = component as unknown as { texture: string; width: number; height: number };
|
||||
if (!sprite.texture) return;
|
||||
|
||||
try {
|
||||
const { convertFileSrc } = await import('@tauri-apps/api/core');
|
||||
const assetUrl = convertFileSrc(sprite.texture);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
handlePropertyChange(component, 'width', img.naturalWidth);
|
||||
handlePropertyChange(component, 'height', img.naturalHeight);
|
||||
setLocalVersion(v => v + 1);
|
||||
};
|
||||
img.src = assetUrl;
|
||||
} catch (error) {
|
||||
console.error('Error getting texture size:', error);
|
||||
}
|
||||
}
|
||||
}, [handlePropertyChange]);
|
||||
|
||||
const handleAddMenuKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(prev + 1, flatComponents.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter' && selectedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
const selected = flatComponents[selectedIndex];
|
||||
if (selected?.type) {
|
||||
handleAddComponent(selected.type);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowAddMenu(false);
|
||||
}
|
||||
}, [flatComponents, selectedIndex, handleAddComponent]);
|
||||
|
||||
const toggleCategory = useCallback((category: string) => {
|
||||
setCollapsedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) next.delete(category);
|
||||
else next.add(category);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ==================== 渲染 | Render ====================
|
||||
|
||||
return (
|
||||
<div className="inspector-panel">
|
||||
{/* Header */}
|
||||
<div className="inspector-header">
|
||||
<div className="inspector-header-info">
|
||||
<button
|
||||
className={`inspector-lock-btn ${isLocked ? 'locked' : ''}`}
|
||||
onClick={() => onLockChange?.(!isLocked)}
|
||||
title={isLocked ? '解锁检视器' : '锁定检视器'}
|
||||
>
|
||||
{isLocked ? <Lock size={14} /> : <Unlock size={14} />}
|
||||
</button>
|
||||
<span className="inspector-header-icon">
|
||||
<Settings size={14} />
|
||||
</span>
|
||||
<span className="inspector-header-name">
|
||||
{entity.name || `Entity #${entity.id}`}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: 'var(--inspector-text-secondary)' }}>
|
||||
1 object
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<PropertySearch
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search components..."
|
||||
/>
|
||||
|
||||
{/* Category Tabs - 只有多个分类时显示 | Only show when multiple categories */}
|
||||
{availableCategories.length > 1 && (
|
||||
<CategoryTabs
|
||||
categories={availableCategories}
|
||||
current={categoryFilter}
|
||||
onChange={(cat) => setCategoryFilter(cat as CategoryFilter)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="inspector-panel-content">
|
||||
{/* Add Component Section Header */}
|
||||
<div className="inspector-section">
|
||||
<div
|
||||
className="inspector-section-header"
|
||||
style={{ justifyContent: 'space-between' }}
|
||||
>
|
||||
<span className="inspector-section-title">组件</span>
|
||||
<button
|
||||
ref={addButtonRef}
|
||||
className="inspector-header-add-btn"
|
||||
onClick={() => setShowAddMenu(!showAddMenu)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Component List */}
|
||||
{filteredComponents.length === 0 ? (
|
||||
<div className="inspector-empty">
|
||||
{entity.components.length === 0 ? '暂无组件' : '没有匹配的组件'}
|
||||
</div>
|
||||
) : (
|
||||
filteredComponents.map((component: Component) => {
|
||||
const componentName = getComponentInstanceTypeName(component);
|
||||
const isExpanded = !collapsedComponents.has(componentName);
|
||||
const componentInfo = componentRegistry?.getComponent(componentName);
|
||||
const iconName = (componentInfo as { icon?: string } | undefined)?.icon;
|
||||
const IconComponent = iconName && (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[iconName];
|
||||
|
||||
return (
|
||||
<div key={`${componentName}-${entity.components.indexOf(component)}`} className="inspector-section">
|
||||
<div
|
||||
className="inspector-section-header"
|
||||
onClick={() => toggleComponentExpanded(componentName)}
|
||||
>
|
||||
<span className={`inspector-section-arrow ${isExpanded ? 'expanded' : ''}`}>
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
<span style={{ marginRight: '6px', color: 'var(--inspector-text-secondary)' }}>
|
||||
{IconComponent ? <IconComponent size={14} /> : <Box size={14} />}
|
||||
</span>
|
||||
<span className="inspector-section-title">{componentName}</span>
|
||||
<button
|
||||
className="inspector-section-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveComponent(component);
|
||||
}}
|
||||
title="移除组件"
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--inspector-text-secondary)',
|
||||
cursor: 'pointer',
|
||||
padding: '2px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="inspector-section-content expanded">
|
||||
{componentInspectorRegistry?.hasInspector(component) ? (
|
||||
componentInspectorRegistry.render({
|
||||
component,
|
||||
entity,
|
||||
version: componentVersion + localVersion,
|
||||
onChange: (propName: string, value: unknown) =>
|
||||
handlePropertyChange(component, propName, value),
|
||||
onAction: handlePropertyAction
|
||||
})
|
||||
) : (
|
||||
<ComponentPropertyEditor
|
||||
component={component}
|
||||
entity={entity}
|
||||
version={componentVersion + localVersion}
|
||||
onChange={(propName, value) =>
|
||||
handlePropertyChange(component, propName, value)
|
||||
}
|
||||
onAction={handlePropertyAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Append inspectors */}
|
||||
{componentInspectorRegistry?.renderAppendInspectors({
|
||||
component,
|
||||
entity,
|
||||
version: componentVersion + localVersion,
|
||||
onChange: (propName: string, value: unknown) =>
|
||||
handlePropertyChange(component, propName, value),
|
||||
onAction: handlePropertyAction
|
||||
})}
|
||||
|
||||
{/* Component actions */}
|
||||
{componentActionRegistry?.getActionsForComponent(componentName).map((action) => {
|
||||
const ActionIcon = typeof action.icon === 'string'
|
||||
? (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[action.icon]
|
||||
: null;
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
className="inspector-header-add-btn"
|
||||
style={{ width: '100%', marginTop: '8px', justifyContent: 'center' }}
|
||||
onClick={() => action.execute(component, entity)}
|
||||
>
|
||||
{ActionIcon ? <ActionIcon size={14} /> : action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Component Menu */}
|
||||
{showAddMenu && (
|
||||
<>
|
||||
<div
|
||||
className="inspector-dropdown-overlay"
|
||||
onClick={() => setShowAddMenu(false)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 99
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="inspector-dropdown-menu"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: addButtonRef.current?.getBoundingClientRect().bottom ?? 0 + 4,
|
||||
right: window.innerWidth - (addButtonRef.current?.getBoundingClientRect().right ?? 0),
|
||||
width: '280px',
|
||||
maxHeight: '400px',
|
||||
zIndex: 100
|
||||
}}
|
||||
>
|
||||
{/* Search */}
|
||||
<div className="inspector-search" style={{ borderBottom: '1px solid var(--inspector-border)' }}>
|
||||
<Search size={14} className="inspector-search-icon" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="inspector-search-input"
|
||||
placeholder="搜索组件..."
|
||||
value={addMenuSearch}
|
||||
onChange={(e) => setAddMenuSearch(e.target.value)}
|
||||
onKeyDown={handleAddMenuKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Component List */}
|
||||
<div style={{ overflowY: 'auto', maxHeight: '350px' }}>
|
||||
{groupedComponents.size === 0 ? (
|
||||
<div className="inspector-empty">
|
||||
{addMenuSearch ? '未找到匹配的组件' : '没有可用组件'}
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
let globalIndex = 0;
|
||||
return Array.from(groupedComponents.entries()).map(([category, components]) => {
|
||||
const isCollapsed = collapsedCategories.has(category) && !addMenuSearch;
|
||||
const label = CATEGORY_LABELS[category] || category;
|
||||
const startIndex = globalIndex;
|
||||
if (!isCollapsed) {
|
||||
globalIndex += components.length;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={category}>
|
||||
<div
|
||||
className="inspector-dropdown-item"
|
||||
onClick={() => toggleCategory(category)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontWeight: 500,
|
||||
background: 'var(--inspector-bg-section)'
|
||||
}}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
<span>{label}</span>
|
||||
<span style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: '10px',
|
||||
color: 'var(--inspector-text-secondary)'
|
||||
}}>
|
||||
{components.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && components.map((info, idx) => {
|
||||
const IconComp = info.icon && (LucideIcons as any)[info.icon];
|
||||
const itemIndex = startIndex + idx;
|
||||
const isSelected = itemIndex === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={info.name}
|
||||
className={`inspector-dropdown-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => info.type && handleAddComponent(info.type)}
|
||||
onMouseEnter={() => setSelectedIndex(itemIndex)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
paddingLeft: '24px'
|
||||
}}
|
||||
>
|
||||
{IconComp ? <IconComp size={14} /> : <Box size={14} />}
|
||||
<span>{info.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* InspectorPanel - 属性面板主组件
|
||||
* InspectorPanel - Property panel main component
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { PropertySection } from './sections';
|
||||
import {
|
||||
PropertyRow,
|
||||
NumberInput,
|
||||
StringInput,
|
||||
BooleanInput,
|
||||
VectorInput,
|
||||
EnumInput,
|
||||
ColorInput,
|
||||
AssetInput,
|
||||
EntityRefInput,
|
||||
ArrayInput
|
||||
} from './controls';
|
||||
import {
|
||||
InspectorHeader,
|
||||
PropertySearch,
|
||||
CategoryTabs
|
||||
} from './header';
|
||||
import {
|
||||
InspectorPanelProps,
|
||||
SectionConfig,
|
||||
PropertyConfig,
|
||||
PropertyType,
|
||||
CategoryConfig
|
||||
} from './types';
|
||||
import './styles/inspector.css';
|
||||
|
||||
/**
|
||||
* 渲染属性控件
|
||||
* Render property control
|
||||
*/
|
||||
const renderControl = (
|
||||
type: PropertyType,
|
||||
value: any,
|
||||
onChange: (value: any) => void,
|
||||
readonly: boolean,
|
||||
metadata?: Record<string, any>
|
||||
): React.ReactNode => {
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return (
|
||||
<NumberInput
|
||||
value={value ?? 0}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
min={metadata?.min}
|
||||
max={metadata?.max}
|
||||
step={metadata?.step}
|
||||
integer={metadata?.integer}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'string':
|
||||
return (
|
||||
<StringInput
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
placeholder={metadata?.placeholder}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<BooleanInput
|
||||
value={value ?? false}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'vector2':
|
||||
return (
|
||||
<VectorInput
|
||||
value={value ?? { x: 0, y: 0 }}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
dimensions={2}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'vector3':
|
||||
return (
|
||||
<VectorInput
|
||||
value={value ?? { x: 0, y: 0, z: 0 }}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
dimensions={3}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'vector4':
|
||||
return (
|
||||
<VectorInput
|
||||
value={value ?? { x: 0, y: 0, z: 0, w: 0 }}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
dimensions={4}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'enum':
|
||||
return (
|
||||
<EnumInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
options={metadata?.options ?? []}
|
||||
placeholder={metadata?.placeholder}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'color':
|
||||
return (
|
||||
<ColorInput
|
||||
value={value ?? { r: 0, g: 0, b: 0, a: 1 }}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
showAlpha={metadata?.showAlpha}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'asset':
|
||||
return (
|
||||
<AssetInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
assetTypes={metadata?.assetTypes}
|
||||
extensions={metadata?.extensions}
|
||||
onPickAsset={metadata?.onPickAsset}
|
||||
onOpenAsset={metadata?.onOpenAsset}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'entityRef':
|
||||
return (
|
||||
<EntityRefInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
resolveEntityName={metadata?.resolveEntityName}
|
||||
onSelectEntity={metadata?.onSelectEntity}
|
||||
onLocateEntity={metadata?.onLocateEntity}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'array':
|
||||
return (
|
||||
<ArrayInput
|
||||
value={value ?? []}
|
||||
onChange={onChange}
|
||||
readonly={readonly}
|
||||
renderElement={metadata?.renderElement}
|
||||
createNewElement={metadata?.createNewElement}
|
||||
minItems={metadata?.minItems}
|
||||
maxItems={metadata?.maxItems}
|
||||
sortable={metadata?.sortable}
|
||||
collapsedTitle={metadata?.collapsedTitle}
|
||||
/>
|
||||
);
|
||||
|
||||
// TODO: 后续实现 | To be implemented
|
||||
case 'object':
|
||||
return <span style={{ color: '#666', fontSize: '10px' }}>[{type}]</span>;
|
||||
|
||||
default:
|
||||
return <span style={{ color: '#666', fontSize: '10px' }}>[unknown]</span>;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认分类配置
|
||||
* Default category configuration
|
||||
*/
|
||||
const DEFAULT_CATEGORIES: CategoryConfig[] = [
|
||||
{ id: 'all', label: 'All' }
|
||||
];
|
||||
|
||||
export const InspectorPanel: React.FC<InspectorPanelProps> = ({
|
||||
targetName,
|
||||
sections,
|
||||
categories,
|
||||
currentCategory: controlledCategory,
|
||||
onCategoryChange,
|
||||
getValue,
|
||||
onChange,
|
||||
readonly = false,
|
||||
searchQuery: controlledSearch,
|
||||
onSearchChange
|
||||
}) => {
|
||||
// 内部状态(非受控模式)| Internal state (uncontrolled mode)
|
||||
const [internalSearch, setInternalSearch] = useState('');
|
||||
const [internalCategory, setInternalCategory] = useState('all');
|
||||
|
||||
// 支持受控/非受控模式 | Support controlled/uncontrolled mode
|
||||
const searchQuery = controlledSearch ?? internalSearch;
|
||||
const currentCategory = controlledCategory ?? internalCategory;
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
if (onSearchChange) {
|
||||
onSearchChange(value);
|
||||
} else {
|
||||
setInternalSearch(value);
|
||||
}
|
||||
}, [onSearchChange]);
|
||||
|
||||
const handleCategoryChange = useCallback((category: string) => {
|
||||
if (onCategoryChange) {
|
||||
onCategoryChange(category);
|
||||
} else {
|
||||
setInternalCategory(category);
|
||||
}
|
||||
}, [onCategoryChange]);
|
||||
|
||||
// 使用提供的分类或默认分类 | Use provided categories or default
|
||||
const effectiveCategories = useMemo(() => {
|
||||
if (categories && categories.length > 0) {
|
||||
return categories;
|
||||
}
|
||||
return DEFAULT_CATEGORIES;
|
||||
}, [categories]);
|
||||
|
||||
// 是否显示分类标签 | Whether to show category tabs
|
||||
const showCategoryTabs = effectiveCategories.length > 1;
|
||||
|
||||
/**
|
||||
* 过滤属性(搜索 + 分类)
|
||||
* Filter properties (search + category)
|
||||
*/
|
||||
const filterProperty = useCallback((prop: PropertyConfig): boolean => {
|
||||
// 分类过滤 | Category filter
|
||||
if (currentCategory !== 'all' && prop.category && prop.category !== currentCategory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 搜索过滤 | Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
prop.name.toLowerCase().includes(query) ||
|
||||
prop.label.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [searchQuery, currentCategory]);
|
||||
|
||||
/**
|
||||
* 过滤后的 sections
|
||||
* Filtered sections
|
||||
*/
|
||||
const filteredSections = useMemo(() => {
|
||||
return sections
|
||||
.map(section => ({
|
||||
...section,
|
||||
properties: section.properties.filter(filterProperty)
|
||||
}))
|
||||
.filter(section => section.properties.length > 0);
|
||||
}, [sections, filterProperty]);
|
||||
|
||||
/**
|
||||
* 渲染 Section
|
||||
* Render section
|
||||
*/
|
||||
const renderSection = useCallback((section: SectionConfig, depth: number = 0) => {
|
||||
return (
|
||||
<PropertySection
|
||||
key={section.id}
|
||||
title={section.title}
|
||||
defaultExpanded={section.defaultExpanded ?? true}
|
||||
depth={depth}
|
||||
>
|
||||
{/* 属性列表 | Property list */}
|
||||
{section.properties.map(prop => (
|
||||
<PropertyRow
|
||||
key={prop.name}
|
||||
label={prop.label}
|
||||
depth={depth}
|
||||
draggable={prop.type === 'number'}
|
||||
>
|
||||
{renderControl(
|
||||
prop.type,
|
||||
getValue(prop.name),
|
||||
(value) => onChange(prop.name, value),
|
||||
readonly,
|
||||
prop.metadata
|
||||
)}
|
||||
</PropertyRow>
|
||||
))}
|
||||
|
||||
{/* 子 Section | Sub sections */}
|
||||
{section.subsections?.map(sub => renderSection(sub, depth + 1))}
|
||||
</PropertySection>
|
||||
);
|
||||
}, [getValue, onChange, readonly]);
|
||||
|
||||
return (
|
||||
<div className="inspector-panel">
|
||||
{/* 头部 | Header */}
|
||||
{targetName && (
|
||||
<InspectorHeader name={targetName} />
|
||||
)}
|
||||
|
||||
{/* 搜索栏 | Search bar */}
|
||||
<PropertySearch
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Search properties..."
|
||||
/>
|
||||
|
||||
{/* 分类标签 | Category tabs */}
|
||||
{showCategoryTabs && (
|
||||
<CategoryTabs
|
||||
categories={effectiveCategories}
|
||||
current={currentCategory}
|
||||
onChange={handleCategoryChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 属性内容 | Property content */}
|
||||
<div className="inspector-panel-content">
|
||||
{filteredSections.length > 0 ? (
|
||||
filteredSections.map(section => renderSection(section))
|
||||
) : (
|
||||
<div className="inspector-empty">
|
||||
{searchQuery ? 'No matching properties' : 'No properties'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* ArrayInput - 数组编辑控件
|
||||
* ArrayInput - Array editor control
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Plus, Trash2, ChevronRight, ChevronDown, GripVertical } from 'lucide-react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface ArrayInputProps<T = any> extends PropertyControlProps<T[]> {
|
||||
/** 元素渲染器 | Element renderer */
|
||||
renderElement?: (
|
||||
element: T,
|
||||
index: number,
|
||||
onChange: (value: T) => void,
|
||||
onRemove: () => void
|
||||
) => React.ReactNode;
|
||||
/** 创建新元素 | Create new element */
|
||||
createNewElement?: () => T;
|
||||
/** 最小元素数 | Minimum element count */
|
||||
minItems?: number;
|
||||
/** 最大元素数 | Maximum element count */
|
||||
maxItems?: number;
|
||||
/** 是否可排序 | Sortable */
|
||||
sortable?: boolean;
|
||||
/** 折叠标题 | Collapsed title */
|
||||
collapsedTitle?: (items: T[]) => string;
|
||||
}
|
||||
|
||||
export function ArrayInput<T = any>({
|
||||
value = [],
|
||||
onChange,
|
||||
readonly = false,
|
||||
renderElement,
|
||||
createNewElement,
|
||||
minItems = 0,
|
||||
maxItems,
|
||||
sortable = false,
|
||||
collapsedTitle
|
||||
}: ArrayInputProps<T>): React.ReactElement {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const items = value ?? [];
|
||||
const canAdd = !maxItems || items.length < maxItems;
|
||||
const canRemove = items.length > minItems;
|
||||
|
||||
// 展开/折叠 | Expand/Collapse
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setExpanded(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// 添加元素 | Add element
|
||||
const handleAdd = useCallback(() => {
|
||||
if (!canAdd || readonly) return;
|
||||
|
||||
const newElement = createNewElement ? createNewElement() : (null as T);
|
||||
onChange([...items, newElement]);
|
||||
}, [items, onChange, canAdd, readonly, createNewElement]);
|
||||
|
||||
// 移除元素 | Remove element
|
||||
const handleRemove = useCallback((index: number) => {
|
||||
if (!canRemove || readonly) return;
|
||||
|
||||
const newItems = [...items];
|
||||
newItems.splice(index, 1);
|
||||
onChange(newItems);
|
||||
}, [items, onChange, canRemove, readonly]);
|
||||
|
||||
// 更新元素 | Update element
|
||||
const handleElementChange = useCallback((index: number, newValue: T) => {
|
||||
if (readonly) return;
|
||||
|
||||
const newItems = [...items];
|
||||
newItems[index] = newValue;
|
||||
onChange(newItems);
|
||||
}, [items, onChange, readonly]);
|
||||
|
||||
// ========== 拖拽排序 | Drag Sort ==========
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
|
||||
if (!sortable || readonly) return;
|
||||
|
||||
setDragIndex(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', String(index));
|
||||
}, [sortable, readonly]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
|
||||
if (!sortable || readonly || dragIndex === null) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOverIndex(index);
|
||||
}, [sortable, readonly, dragIndex]);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragOverIndex(null);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, targetIndex: number) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!sortable || readonly || dragIndex === null || dragIndex === targetIndex) {
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const newItems = [...items];
|
||||
const [removed] = newItems.splice(dragIndex, 1);
|
||||
if (removed !== undefined) {
|
||||
newItems.splice(targetIndex, 0, removed);
|
||||
}
|
||||
|
||||
onChange(newItems);
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
}, [items, onChange, sortable, readonly, dragIndex]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
}, []);
|
||||
|
||||
// 获取折叠标题 | Get collapsed title
|
||||
const getTitle = (): string => {
|
||||
if (collapsedTitle) {
|
||||
return collapsedTitle(items);
|
||||
}
|
||||
return `${items.length} item${items.length !== 1 ? 's' : ''}`;
|
||||
};
|
||||
|
||||
// 默认元素渲染 | Default element renderer
|
||||
const defaultRenderElement = (element: T, index: number) => (
|
||||
<div className="inspector-array-element-default">
|
||||
{String(element)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="inspector-array-input">
|
||||
{/* 头部 | Header */}
|
||||
<div className="inspector-array-header" onClick={toggleExpanded}>
|
||||
<span className="inspector-array-arrow">
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
<span className="inspector-array-title">{getTitle()}</span>
|
||||
|
||||
{/* 添加按钮 | Add button */}
|
||||
{canAdd && !readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-array-add"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAdd();
|
||||
}}
|
||||
title="Add element"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 元素列表 | Element list */}
|
||||
{expanded && (
|
||||
<div className="inspector-array-elements">
|
||||
{items.map((element, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`inspector-array-element ${dragOverIndex === index ? 'drag-over' : ''} ${dragIndex === index ? 'dragging' : ''}`}
|
||||
draggable={sortable && !readonly}
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* 拖拽手柄 | Drag handle */}
|
||||
{sortable && !readonly && (
|
||||
<div className="inspector-array-handle">
|
||||
<GripVertical size={12} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 索引 | Index */}
|
||||
<span className="inspector-array-index">{index}</span>
|
||||
|
||||
{/* 内容 | Content */}
|
||||
<div className="inspector-array-content">
|
||||
{renderElement
|
||||
? renderElement(
|
||||
element,
|
||||
index,
|
||||
(val) => handleElementChange(index, val),
|
||||
() => handleRemove(index)
|
||||
)
|
||||
: defaultRenderElement(element, index)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 | Remove button */}
|
||||
{canRemove && !readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-array-remove"
|
||||
onClick={() => handleRemove(index)}
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 空状态 | Empty state */}
|
||||
{items.length === 0 && (
|
||||
<div className="inspector-array-empty">
|
||||
No items
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* AssetInput - 资产引用选择控件
|
||||
* AssetInput - Asset reference picker control
|
||||
*
|
||||
* 功能 | Features:
|
||||
* - 缩略图预览 | Thumbnail preview
|
||||
* - 下拉选择 | Dropdown selection
|
||||
* - 拖放支持 | Drag and drop support
|
||||
* - 操作按钮 | Action buttons (browse, copy, locate, clear)
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDown, FolderOpen, Copy, Navigation, X, FileImage, Image, Music, Film, FileText, Box } from 'lucide-react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface AssetReference {
|
||||
/** 资产 ID | Asset ID */
|
||||
id: string;
|
||||
/** 资产路径 | Asset path */
|
||||
path?: string;
|
||||
/** 资产类型 | Asset type */
|
||||
type?: string;
|
||||
/** 缩略图 URL | Thumbnail URL */
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
export interface AssetInputProps extends PropertyControlProps<AssetReference | string | null> {
|
||||
/** 允许的资产类型 | Allowed asset types */
|
||||
assetTypes?: string[];
|
||||
/** 允许的文件扩展名 | Allowed file extensions */
|
||||
extensions?: string[];
|
||||
/** 打开资产选择器回调 | Open asset picker callback */
|
||||
onPickAsset?: () => void;
|
||||
/** 打开资产回调 | Open asset callback */
|
||||
onOpenAsset?: (asset: AssetReference) => void;
|
||||
/** 定位资产回调 | Locate asset callback */
|
||||
onLocateAsset?: (asset: AssetReference) => void;
|
||||
/** 复制路径回调 | Copy path callback */
|
||||
onCopyPath?: (path: string) => void;
|
||||
/** 获取缩略图 URL | Get thumbnail URL */
|
||||
getThumbnail?: (asset: AssetReference) => string | undefined;
|
||||
/** 最近使用的资产 | Recently used assets */
|
||||
recentAssets?: AssetReference[];
|
||||
/** 显示缩略图 | Show thumbnail */
|
||||
showThumbnail?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资产显示名称
|
||||
* Get asset display name
|
||||
*/
|
||||
const getAssetDisplayName = (value: AssetReference | string | null): string => {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') {
|
||||
// 从路径中提取文件名 | Extract filename from path
|
||||
const parts = value.split('/');
|
||||
return parts[parts.length - 1] ?? value;
|
||||
}
|
||||
if (value.path) {
|
||||
const parts = value.path.split('/');
|
||||
return parts[parts.length - 1] ?? value.id;
|
||||
}
|
||||
return value.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取资产路径
|
||||
* Get asset path
|
||||
*/
|
||||
const getAssetPath = (value: AssetReference | string | null): string => {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
return value.path || value.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据扩展名获取图标
|
||||
* Get icon by extension
|
||||
*/
|
||||
const getAssetIcon = (value: AssetReference | string | null) => {
|
||||
const path = getAssetPath(value).toLowerCase();
|
||||
if (path.match(/\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)$/)) return Image;
|
||||
if (path.match(/\.(mp3|wav|ogg|flac|aac)$/)) return Music;
|
||||
if (path.match(/\.(mp4|webm|avi|mov|mkv)$/)) return Film;
|
||||
if (path.match(/\.(txt|json|xml|yaml|yml|md)$/)) return FileText;
|
||||
if (path.match(/\.(fbx|obj|gltf|glb|dae)$/)) return Box;
|
||||
return FileImage;
|
||||
};
|
||||
|
||||
export const AssetInput: React.FC<AssetInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
extensions,
|
||||
onPickAsset,
|
||||
onOpenAsset,
|
||||
onLocateAsset,
|
||||
onCopyPath,
|
||||
getThumbnail,
|
||||
recentAssets = [],
|
||||
showThumbnail = true
|
||||
}) => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const displayName = getAssetDisplayName(value);
|
||||
const assetPath = getAssetPath(value);
|
||||
const hasValue = !!value;
|
||||
const IconComponent = getAssetIcon(value);
|
||||
|
||||
// 获取缩略图 | Get thumbnail
|
||||
const thumbnailUrl = value && getThumbnail
|
||||
? getThumbnail(typeof value === 'string' ? { id: value, path: value } : value)
|
||||
: undefined;
|
||||
|
||||
// 关闭下拉菜单 | Close dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
if (showDropdown) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showDropdown]);
|
||||
|
||||
// 清除值 | Clear value
|
||||
const handleClear = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!readonly) {
|
||||
onChange(null);
|
||||
}
|
||||
}, [onChange, readonly]);
|
||||
|
||||
// 打开选择器 | Open picker
|
||||
const handleBrowse = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!readonly && onPickAsset) {
|
||||
onPickAsset();
|
||||
}
|
||||
setShowDropdown(false);
|
||||
}, [readonly, onPickAsset]);
|
||||
|
||||
// 定位资产 | Locate asset
|
||||
const handleLocate = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (value && onLocateAsset) {
|
||||
const asset: AssetReference = typeof value === 'string'
|
||||
? { id: value, path: value }
|
||||
: value;
|
||||
onLocateAsset(asset);
|
||||
}
|
||||
}, [value, onLocateAsset]);
|
||||
|
||||
// 复制路径 | Copy path
|
||||
const handleCopy = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (assetPath) {
|
||||
if (onCopyPath) {
|
||||
onCopyPath(assetPath);
|
||||
} else {
|
||||
navigator.clipboard.writeText(assetPath);
|
||||
}
|
||||
}
|
||||
}, [assetPath, onCopyPath]);
|
||||
|
||||
// 双击打开资产 | Double click to open asset
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
if (value && onOpenAsset) {
|
||||
const asset: AssetReference = typeof value === 'string'
|
||||
? { id: value, path: value }
|
||||
: value;
|
||||
onOpenAsset(asset);
|
||||
}
|
||||
}, [value, onOpenAsset]);
|
||||
|
||||
// 切换下拉菜单 | Toggle dropdown
|
||||
const handleToggleDropdown = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!readonly) {
|
||||
setShowDropdown(!showDropdown);
|
||||
}
|
||||
}, [readonly, showDropdown]);
|
||||
|
||||
// 选择资产 | Select asset
|
||||
const handleSelectAsset = useCallback((asset: AssetReference) => {
|
||||
onChange(asset);
|
||||
setShowDropdown(false);
|
||||
}, [onChange]);
|
||||
|
||||
// 拖放处理 | Drag and drop handling
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!readonly) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
}, [readonly]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (readonly) return;
|
||||
|
||||
const assetId = e.dataTransfer.getData('asset-id');
|
||||
const assetPath = e.dataTransfer.getData('asset-path');
|
||||
const assetType = e.dataTransfer.getData('asset-type');
|
||||
|
||||
if (assetId || assetPath) {
|
||||
// 检查扩展名匹配 | Check extension match
|
||||
if (extensions && assetPath) {
|
||||
const ext = assetPath.split('.').pop()?.toLowerCase();
|
||||
if (ext && !extensions.some(e => e.toLowerCase() === ext || e.toLowerCase() === `.${ext}`)) {
|
||||
console.warn(`Extension "${ext}" not allowed. Allowed: ${extensions.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onChange({
|
||||
id: assetId || assetPath,
|
||||
path: assetPath || undefined,
|
||||
type: assetType || undefined
|
||||
});
|
||||
}
|
||||
}, [onChange, readonly, extensions]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`inspector-asset-input ${isDragOver ? 'drag-over' : ''} ${hasValue ? 'has-value' : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* 缩略图 | Thumbnail */}
|
||||
{showThumbnail && (
|
||||
<div className="inspector-asset-thumbnail" onDoubleClick={handleDoubleClick}>
|
||||
{thumbnailUrl ? (
|
||||
<img src={thumbnailUrl} alt="" />
|
||||
) : (
|
||||
<IconComponent size={16} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 值显示和下拉按钮 | Value display and dropdown button */}
|
||||
<div className="inspector-asset-main" onClick={handleToggleDropdown}>
|
||||
<div
|
||||
className="inspector-asset-value"
|
||||
onDoubleClick={handleDoubleClick}
|
||||
title={assetPath || 'None'}
|
||||
>
|
||||
{displayName || <span className="inspector-asset-placeholder">None</span>}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<ChevronDown size={12} className={`inspector-asset-arrow ${showDropdown ? 'open' : ''}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 | Action buttons */}
|
||||
<div className="inspector-asset-actions">
|
||||
{/* 定位按钮 | Locate button */}
|
||||
{hasValue && onLocateAsset && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-asset-btn"
|
||||
onClick={handleLocate}
|
||||
title="Locate in Content Browser"
|
||||
>
|
||||
<Navigation size={11} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 复制按钮 | Copy button */}
|
||||
{hasValue && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-asset-btn"
|
||||
onClick={handleCopy}
|
||||
title="Copy Path"
|
||||
>
|
||||
<Copy size={11} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 浏览按钮 | Browse button */}
|
||||
{onPickAsset && !readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-asset-btn"
|
||||
onClick={handleBrowse}
|
||||
title="Browse"
|
||||
>
|
||||
<FolderOpen size={11} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 清除按钮 | Clear button */}
|
||||
{hasValue && !readonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="inspector-asset-btn inspector-asset-clear"
|
||||
onClick={handleClear}
|
||||
title="Clear"
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 下拉菜单 | Dropdown menu */}
|
||||
{showDropdown && (
|
||||
<div ref={dropdownRef} className="inspector-asset-dropdown">
|
||||
{/* 浏览选项 | Browse option */}
|
||||
{onPickAsset && (
|
||||
<div className="inspector-asset-dropdown-item" onClick={handleBrowse}>
|
||||
<FolderOpen size={14} />
|
||||
<span>Browse...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 清除选项 | Clear option */}
|
||||
{hasValue && (
|
||||
<div className="inspector-asset-dropdown-item" onClick={handleClear}>
|
||||
<X size={14} />
|
||||
<span>Clear</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分割线 | Divider */}
|
||||
{recentAssets.length > 0 && (
|
||||
<>
|
||||
<div className="inspector-asset-dropdown-divider" />
|
||||
<div className="inspector-asset-dropdown-label">Recent</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 最近使用 | Recent assets */}
|
||||
{recentAssets.map((asset, index) => (
|
||||
<div
|
||||
key={asset.id || index}
|
||||
className="inspector-asset-dropdown-item"
|
||||
onClick={() => handleSelectAsset(asset)}
|
||||
>
|
||||
{asset.thumbnail ? (
|
||||
<img src={asset.thumbnail} alt="" className="inspector-asset-dropdown-thumb" />
|
||||
) : (
|
||||
<FileImage size={14} />
|
||||
)}
|
||||
<span>{getAssetDisplayName(asset)}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 空状态 | Empty state */}
|
||||
{!onPickAsset && !hasValue && recentAssets.length === 0 && (
|
||||
<div className="inspector-asset-dropdown-empty">No assets available</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* BooleanInput - 复选框控件
|
||||
* BooleanInput - Checkbox control
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { PropertyControlProps } from '../types';
|
||||
|
||||
export interface BooleanInputProps extends PropertyControlProps<boolean> {}
|
||||
|
||||
export const BooleanInput: React.FC<BooleanInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false
|
||||
}) => {
|
||||
const handleClick = useCallback(() => {
|
||||
if (!readonly) {
|
||||
onChange(!value);
|
||||
}
|
||||
}, [value, onChange, readonly]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!readonly && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onChange(!value);
|
||||
}
|
||||
}, [value, onChange, readonly]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inspector-checkbox ${value ? 'checked' : ''}`}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={readonly ? -1 : 0}
|
||||
role="checkbox"
|
||||
aria-checked={value}
|
||||
aria-disabled={readonly}
|
||||
>
|
||||
<Check size={12} className="inspector-checkbox-icon" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user