refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 (#216)

* refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构

* feat(editor): 添加插件市场功能

* feat(editor): 重构插件市场以支持版本管理和ZIP打包

* feat(editor): 重构插件发布流程并修复React渲染警告

* fix(plugin): 修复插件发布和市场的路径不一致问题

* feat: 重构插件发布流程并添加插件删除功能

* fix(editor): 完善插件删除功能并修复多个关键问题

* fix(auth): 修复自动登录与手动登录的竞态条件问题

* feat(editor): 重构插件管理流程

* feat(editor): 支持 ZIP 文件直接发布插件

- 新增 PluginSourceParser 解析插件源
- 重构发布流程支持文件夹和 ZIP 两种方式
- 优化发布向导 UI

* feat(editor): 插件市场支持多版本安装

- 插件解压到项目 plugins 目录
- 新增 Tauri 后端安装/卸载命令
- 支持选择任意版本安装
- 修复打包逻辑,保留完整 dist 目录结构

* feat(editor): 个人中心支持多版本管理

- 合并同一插件的不同版本
- 添加版本历史展开/折叠功能
- 禁止有待审核 PR 时更新插件

* fix(editor): 修复 InspectorRegistry 服务注册

- InspectorRegistry 实现 IService 接口
- 注册到 Core.services 供插件使用

* feat(behavior-tree-editor): 完善插件注册和文件操作

- 添加文件创建模板和操作处理器
- 实现右键菜单创建行为树功能
- 修复文件读取权限问题(使用 Tauri 命令)
- 添加 BehaviorTreeEditorPanel 组件
- 修复 rollup 配置支持动态导入

* feat(plugin): 完善插件构建和发布流程

* fix(behavior-tree-editor): 完整恢复编辑器并修复 Toast 集成

* fix(behavior-tree-editor): 修复节点选中、连线跟随和文件加载问题并优化性能

* fix(behavior-tree-editor): 修复端口连接失败问题并优化连线样式

* refactor(behavior-tree-editor): 移除调试面板功能简化代码结构

* refactor(behavior-tree-editor): 清理冗余代码合并重复逻辑

* feat(behavior-tree-editor): 完善编辑器核心功能增强扩展性

* fix(lint): 修复ESLint错误确保CI通过

* refactor(behavior-tree-editor): 优化编辑器工具栏和编译器功能

* refactor(behavior-tree-editor): 清理技术债务,优化代码质量

* fix(editor-app): 修复字符串替换安全问题
This commit is contained in:
YHH
2025-11-18 14:46:51 +08:00
committed by GitHub
parent eac660b1a0
commit bce3a6e253
251 changed files with 26144 additions and 8844 deletions

View File

@@ -0,0 +1,25 @@
export interface KeyBinding {
key: string;
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
meta?: boolean;
}
export interface IUICommand {
readonly id: string;
readonly label: string;
readonly icon?: string;
readonly keybinding?: KeyBinding;
readonly when?: () => boolean;
execute(context?: unknown): void | Promise<void>;
}
export interface ICommandRegistry {
register(command: IUICommand): void;
unregister(commandId: string): void;
execute(commandId: string, context?: unknown): Promise<void>;
getCommand(commandId: string): IUICommand | undefined;
getCommands(): IUICommand[];
getKeybindings(): Array<{ command: IUICommand; keybinding: KeyBinding }>;
}

View File

@@ -0,0 +1,12 @@
import type { IModuleContext } from './IModuleContext';
export interface IEditorModule<TEvents = Record<string, unknown>> {
readonly id: string;
readonly name: string;
readonly version?: string;
readonly dependencies?: string[];
load(context: IModuleContext<TEvents>): Promise<void>;
unload?(): Promise<void>;
reload?(): Promise<void>;
}

View File

@@ -0,0 +1,16 @@
import type { Observable } from 'rxjs';
export type Unsubscribe = () => void;
export interface IEventBus<TEvents = Record<string, unknown>> {
publish<K extends keyof TEvents>(topic: K, data: TEvents[K]): Promise<void>;
subscribe<K extends keyof TEvents>(
topic: K,
handler: (data: TEvents[K]) => void | Promise<void>
): Unsubscribe;
observe<K extends keyof TEvents>(topic: K): Observable<TEvents[K]>;
dispose(): void;
}

View File

@@ -0,0 +1,19 @@
import type { DependencyContainer } from 'tsyringe';
import type { IEventBus } from './IEventBus';
import type { ICommandRegistry } from './ICommandRegistry';
import type { IPanelRegistry } from './IPanelRegistry';
import type { IFileSystem } from '../Services/IFileSystem';
import type { IDialog } from '../Services/IDialog';
import type { INotification } from '../Services/INotification';
import type { InspectorRegistry } from '../Services/InspectorRegistry';
export interface IModuleContext<TEvents = Record<string, unknown>> {
readonly container: DependencyContainer;
readonly eventBus: IEventBus<TEvents>;
readonly commands: ICommandRegistry;
readonly panels: IPanelRegistry;
readonly fileSystem: IFileSystem;
readonly dialog: IDialog;
readonly notification: INotification;
readonly inspectorRegistry: InspectorRegistry;
}

View File

@@ -0,0 +1,8 @@
import type { PanelDescriptor } from '../Types/UITypes';
export interface IPanelRegistry {
register(panel: PanelDescriptor): void;
unregister(panelId: string): void;
getPanel(panelId: string): PanelDescriptor | undefined;
getPanels(category?: string): PanelDescriptor[];
}

View File

@@ -95,9 +95,7 @@ export class EditorPluginManager extends PluginManager {
if (plugin.registerFileActionHandlers && this.fileActionRegistry) {
const handlers = plugin.registerFileActionHandlers();
console.log(`[EditorPluginManager] Registering ${handlers.length} file action handlers for ${plugin.name}`);
for (const handler of handlers) {
console.log(`[EditorPluginManager] Handler for extensions:`, handler.extensions);
this.fileActionRegistry.registerActionHandler(handler);
}
logger.debug(`Registered ${handlers.length} file action handlers for ${plugin.name}`);
@@ -161,6 +159,20 @@ export class EditorPluginManager extends PluginManager {
}
}
if (plugin.registerFileActionHandlers && this.fileActionRegistry) {
const handlers = plugin.registerFileActionHandlers();
for (const handler of handlers) {
this.fileActionRegistry.unregisterActionHandler(handler);
}
}
if (plugin.registerFileCreationTemplates && this.fileActionRegistry) {
const templates = plugin.registerFileCreationTemplates();
for (const template of templates) {
this.fileActionRegistry.unregisterCreationTemplate(template);
}
}
this.serializerRegistry?.unregisterAll(name);
await super.uninstall(name);

View File

@@ -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} 不支持合并操作`);
}
}

View File

@@ -0,0 +1,203 @@
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);
}
}
/**
* 批量命令
* 将多个命令组合为一个命令
*/
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('批量命令不支持合并');
}
}

View File

@@ -0,0 +1,35 @@
import { IService } from '@esengine/ecs-framework';
import { ICompiler } from './ICompiler';
export class CompilerRegistry implements IService {
private compilers: Map<string, ICompiler> = new Map();
register(compiler: ICompiler): void {
if (this.compilers.has(compiler.id)) {
console.warn(`Compiler with id "${compiler.id}" is already registered. Overwriting.`);
}
this.compilers.set(compiler.id, compiler);
console.log(`[CompilerRegistry] Registered compiler: ${compiler.name} (${compiler.id})`);
}
unregister(compilerId: string): void {
this.compilers.delete(compilerId);
console.log(`[CompilerRegistry] Unregistered compiler: ${compilerId}`);
}
get(compilerId: string): ICompiler | undefined {
return this.compilers.get(compilerId);
}
getAll(): ICompiler[] {
return Array.from(this.compilers.values());
}
clear(): void {
this.compilers.clear();
}
dispose(): void {
this.clear();
}
}

View File

@@ -21,6 +21,24 @@ export class FileActionRegistry implements IService {
}
}
/**
* 注销文件操作处理器
*/
unregisterActionHandler(handler: FileActionHandler): void {
for (const ext of handler.extensions) {
const handlers = this.actionHandlers.get(ext);
if (handlers) {
const index = handlers.indexOf(handler);
if (index !== -1) {
handlers.splice(index, 1);
}
if (handlers.length === 0) {
this.actionHandlers.delete(ext);
}
}
}
}
/**
* 注册文件创建模板
*/
@@ -28,6 +46,16 @@ export class FileActionRegistry implements IService {
this.creationTemplates.push(template);
}
/**
* 注销文件创建模板
*/
unregisterCreationTemplate(template: FileCreationTemplate): void {
const index = this.creationTemplates.indexOf(template);
if (index !== -1) {
this.creationTemplates.splice(index, 1);
}
}
/**
* 获取文件扩展名的处理器
*/

View File

@@ -0,0 +1,19 @@
export interface ICommand {
execute(): void | Promise<void>;
undo(): void | Promise<void>;
getDescription(): string;
canMergeWith(other: ICommand): boolean;
mergeWith(other: ICommand): ICommand;
}
export interface ICommandManager {
execute(command: ICommand): void;
executeBatch(commands: ICommand[]): void;
undo(): void;
redo(): void;
canUndo(): boolean;
canRedo(): boolean;
clear(): void;
getUndoHistory(): string[];
getRedoHistory(): string[];
}

View File

@@ -0,0 +1,33 @@
export interface CompileResult {
success: boolean;
message: string;
outputFiles?: string[];
errors?: string[];
}
import type { IFileSystem } from './IFileSystem';
import type { IDialog } from './IDialog';
import type { IService, ServiceType } from '@esengine/ecs-framework';
export interface CompilerModuleContext {
fileSystem: IFileSystem;
dialog: IDialog;
}
export interface CompilerContext {
projectPath: string | null;
moduleContext: CompilerModuleContext;
getService<T extends IService>(serviceClass: ServiceType<T>): T | undefined;
}
export interface ICompiler<TOptions = unknown> {
readonly id: string;
readonly name: string;
readonly description: string;
compile(options: TOptions, context: CompilerContext): Promise<CompileResult>;
createConfigUI?(onOptionsChange: (options: TOptions) => void, context: CompilerContext): React.ReactElement;
validateOptions?(options: TOptions): string | null;
}

View File

@@ -0,0 +1,21 @@
export interface DialogOptions {
title?: string;
defaultPath?: string;
filters?: Array<{ name: string; extensions: string[] }>;
}
export interface OpenDialogOptions extends DialogOptions {
directory?: boolean;
multiple?: boolean;
}
export interface SaveDialogOptions extends DialogOptions {
defaultFileName?: string;
}
export interface IDialog {
openDialog(options: OpenDialogOptions): Promise<string | string[] | null>;
saveDialog(options: SaveDialogOptions): Promise<string | null>;
showMessage(title: string, message: string, type?: 'info' | 'warning' | 'error'): Promise<void>;
showConfirm(title: string, message: string): Promise<boolean>;
}

View File

@@ -0,0 +1,12 @@
export interface IEditorDataStore<TNode, TConnection> {
getNodes(): TNode[];
getConnections(): TConnection[];
getNode(nodeId: string): TNode | undefined;
getConnection(connectionId: string): TConnection | undefined;
addNode(node: TNode): void;
removeNode(nodeId: string): void;
updateNode(nodeId: string, updates: Partial<TNode>): void;
addConnection(connection: TConnection): void;
removeConnection(connectionId: string): void;
clear(): void;
}

View File

@@ -0,0 +1,19 @@
export interface IFileSystem {
readFile(path: string): Promise<string>;
writeFile(path: string, content: string): Promise<void>;
writeBinary(path: string, data: Uint8Array): Promise<void>;
exists(path: string): Promise<boolean>;
createDirectory(path: string): Promise<void>;
listDirectory(path: string): Promise<FileEntry[]>;
deleteFile(path: string): Promise<void>;
deleteDirectory(path: string): Promise<void>;
scanFiles(basePath: string, pattern: string): Promise<string[]>;
}
export interface FileEntry {
name: string;
path: string;
isDirectory: boolean;
size?: number;
modified?: Date;
}

View File

@@ -0,0 +1,77 @@
import React from 'react';
/**
* Inspector提供器接口
* 用于扩展Inspector面板支持不同类型的对象检视
*/
export interface IInspectorProvider<T = unknown> {
/**
* 提供器唯一标识
*/
readonly id: string;
/**
* 提供器名称
*/
readonly name: string;
/**
* 优先级,数字越大优先级越高
*/
readonly priority?: number;
/**
* 判断是否可以处理该目标
*/
canHandle(target: unknown): target is T;
/**
* 渲染Inspector内容
*/
render(target: T, context: InspectorContext): React.ReactElement;
}
/**
* Inspector上下文
*/
export interface InspectorContext {
/**
* 当前选中的目标
*/
target: unknown;
/**
* 是否只读模式
*/
readonly?: boolean;
/**
* 项目路径
*/
projectPath?: string | null;
/**
* 额外的上下文数据
*/
[key: string]: unknown;
}
/**
* Inspector目标类型
*/
export interface InspectorTarget<T = unknown> {
/**
* 目标类型
*/
type: string;
/**
* 目标数据
*/
data: T;
/**
* 额外的元数据
*/
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,6 @@
export type NotificationType = 'success' | 'error' | 'warning' | 'info';
export interface INotification {
show(message: string, type?: NotificationType, duration?: number): void;
hide(id: string): void;
}

View File

@@ -0,0 +1,77 @@
import { IInspectorProvider, InspectorContext } from './IInspectorProvider';
import { IService } from '@esengine/ecs-framework';
import React from 'react';
export class InspectorRegistry implements IService {
private providers: Map<string, IInspectorProvider> = new Map();
/**
* 注册Inspector提供器
*/
register(provider: IInspectorProvider): void {
if (this.providers.has(provider.id)) {
console.warn(`Inspector provider with id "${provider.id}" is already registered`);
return;
}
this.providers.set(provider.id, provider);
}
/**
* 注销Inspector提供器
*/
unregister(providerId: string): void {
this.providers.delete(providerId);
}
/**
* 获取指定ID的提供器
*/
getProvider(providerId: string): IInspectorProvider | undefined {
return this.providers.get(providerId);
}
/**
* 获取所有提供器
*/
getAllProviders(): IInspectorProvider[] {
return Array.from(this.providers.values());
}
/**
* 查找可以处理指定目标的提供器
* 按优先级排序,返回第一个可以处理的提供器
*/
findProvider(target: unknown): IInspectorProvider | undefined {
const providers = Array.from(this.providers.values())
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
for (const provider of providers) {
if (provider.canHandle(target)) {
return provider;
}
}
return undefined;
}
/**
* 渲染Inspector内容
* 自动查找合适的提供器并渲染
*/
render(target: unknown, context: InspectorContext): React.ReactElement | null {
const provider = this.findProvider(target);
if (!provider) {
return null;
}
return provider.render(target, context);
}
clear(): void {
this.providers.clear();
}
dispose(): void {
this.clear();
}
}

View File

@@ -22,8 +22,10 @@ export type LogListener = (entry: LogEntry) => void;
export class LogService implements IService {
private logs: LogEntry[] = [];
private listeners: Set<LogListener> = new Set();
private nextId = 0;
private nextId = Date.now(); // 使用时间戳作为起始ID避免重复
private maxLogs = 1000;
private pendingNotifications: LogEntry[] = [];
private notificationScheduled = false;
private originalConsole = {
log: console.log.bind(console),
@@ -146,15 +148,29 @@ export class LogService implements IService {
}
/**
* 通知监听器
* 通知监听器批处理日志通知以避免在React渲染期间触发状态更新
*/
private notifyListeners(entry: LogEntry): void {
for (const listener of this.listeners) {
try {
listener(entry);
} catch (error) {
this.originalConsole.error('Error in log listener:', error);
}
this.pendingNotifications.push(entry);
if (!this.notificationScheduled) {
this.notificationScheduled = true;
queueMicrotask(() => {
const notifications = [...this.pendingNotifications];
this.pendingNotifications = [];
this.notificationScheduled = false;
for (const notification of notifications) {
for (const listener of this.listeners) {
try {
listener(notification);
} catch (error) {
this.originalConsole.error('Error in log listener:', error);
}
}
}
});
}
}

View File

@@ -20,6 +20,23 @@ export * from './Services/LogService';
export * from './Services/SettingsRegistry';
export * from './Services/SceneManagerService';
export * from './Services/FileActionRegistry';
export * from './Services/CompilerRegistry';
export * from './Services/ICompiler';
export * from './Services/ICommand';
export * from './Services/BaseCommand';
export * from './Services/CommandManager';
export * from './Services/IEditorDataStore';
export * from './Services/IFileSystem';
export * from './Services/IDialog';
export * from './Services/INotification';
export * from './Services/IInspectorProvider';
export * from './Services/InspectorRegistry';
export * from './Module/IEventBus';
export * from './Module/ICommandRegistry';
export * from './Module/IPanelRegistry';
export * from './Module/IModuleContext';
export * from './Module/IEditorModule';
export * from './Types/UITypes';
export * from './Types/IFileAPI';