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,94 @@
import React from 'react';
import { Trash2, Replace, Plus } from 'lucide-react';
interface NodeContextMenuProps {
visible: boolean;
position: { x: number; y: number };
nodeId: string | null;
isBlackboardVariable?: boolean;
onReplaceNode?: () => void;
onDeleteNode?: () => void;
onCreateNode?: () => void;
}
export const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
visible,
position,
nodeId,
isBlackboardVariable = false,
onReplaceNode,
onDeleteNode,
onCreateNode
}) => {
if (!visible) return null;
const menuItemStyle = {
padding: '8px 16px',
cursor: 'pointer',
color: '#cccccc',
fontSize: '13px',
transition: 'background-color 0.15s',
display: 'flex',
alignItems: 'center',
gap: '8px'
};
return (
<div
style={{
position: 'fixed',
left: position.x,
top: position.y,
backgroundColor: '#2d2d30',
border: '1px solid #454545',
borderRadius: '4px',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
zIndex: 10000,
minWidth: '150px',
padding: '4px 0'
}}
onClick={(e) => e.stopPropagation()}
>
{nodeId ? (
<>
{onReplaceNode && (
<div
onClick={onReplaceNode}
style={menuItemStyle}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Replace size={14} />
</div>
)}
{onDeleteNode && (
<div
onClick={onDeleteNode}
style={{...menuItemStyle, color: '#f48771'}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#5a1a1a'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Trash2 size={14} />
</div>
)}
</>
) : (
<>
{onCreateNode && (
<div
onClick={onCreateNode}
style={menuItemStyle}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Plus size={14} />
</div>
)}
</>
)}
</div>
);
};

View File

@@ -0,0 +1,348 @@
import React, { useRef, useEffect, useState, useMemo } from 'react';
import { NodeTemplate } from '@esengine/behavior-tree';
import { Search, X, LucideIcon, ChevronDown, ChevronRight } from 'lucide-react';
import { NodeFactory } from '../../infrastructure/factories/NodeFactory';
interface QuickCreateMenuProps {
visible: boolean;
position: { x: number; y: number };
searchText: string;
selectedIndex: number;
mode: 'create' | 'replace';
iconMap: Record<string, LucideIcon>;
onSearchChange: (text: string) => void;
onIndexChange: (index: number) => void;
onNodeSelect: (template: NodeTemplate) => void;
onClose: () => void;
}
interface CategoryGroup {
category: string;
templates: NodeTemplate[];
isExpanded: boolean;
}
export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
visible,
position,
searchText,
selectedIndex,
iconMap,
onSearchChange,
onIndexChange,
onNodeSelect,
onClose
}) => {
const selectedNodeRef = useRef<HTMLDivElement>(null);
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
const [shouldAutoScroll, setShouldAutoScroll] = useState(false);
const nodeFactory = useMemo(() => new NodeFactory(), []);
const allTemplates = useMemo(() => nodeFactory.getAllTemplates(), [nodeFactory]);
const searchTextLower = searchText.toLowerCase();
const filteredTemplates = searchTextLower
? allTemplates.filter((t: NodeTemplate) => {
const className = t.className || '';
return t.displayName.toLowerCase().includes(searchTextLower) ||
t.description.toLowerCase().includes(searchTextLower) ||
t.category.toLowerCase().includes(searchTextLower) ||
className.toLowerCase().includes(searchTextLower);
})
: allTemplates;
const categoryGroups: CategoryGroup[] = React.useMemo(() => {
const groups = new Map<string, NodeTemplate[]>();
filteredTemplates.forEach((template: NodeTemplate) => {
const category = template.category || '未分类';
if (!groups.has(category)) {
groups.set(category, []);
}
groups.get(category)!.push(template);
});
return Array.from(groups.entries()).map(([category, templates]) => ({
category,
templates,
isExpanded: searchTextLower ? true : expandedCategories.has(category)
})).sort((a, b) => a.category.localeCompare(b.category));
}, [filteredTemplates, expandedCategories, searchTextLower]);
const flattenedTemplates = React.useMemo(() => {
return categoryGroups.flatMap(group =>
group.isExpanded ? group.templates : []
);
}, [categoryGroups]);
const toggleCategory = (category: string) => {
setExpandedCategories(prev => {
const newSet = new Set(prev);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
return newSet;
});
};
useEffect(() => {
if (allTemplates.length > 0 && expandedCategories.size === 0) {
const categories = new Set(allTemplates.map(t => t.category || '未分类'));
setExpandedCategories(categories);
}
}, [allTemplates, expandedCategories.size]);
useEffect(() => {
if (shouldAutoScroll && selectedNodeRef.current) {
selectedNodeRef.current.scrollIntoView({
block: 'nearest',
behavior: 'smooth'
});
setShouldAutoScroll(false);
}
}, [selectedIndex, shouldAutoScroll]);
if (!visible) return null;
let globalIndex = -1;
return (
<>
<style>{`
.quick-create-menu-list::-webkit-scrollbar {
width: 8px;
}
.quick-create-menu-list::-webkit-scrollbar-track {
background: #1e1e1e;
}
.quick-create-menu-list::-webkit-scrollbar-thumb {
background: #3c3c3c;
border-radius: 4px;
}
.quick-create-menu-list::-webkit-scrollbar-thumb:hover {
background: #4c4c4c;
}
.category-header {
transition: background-color 0.15s;
}
.category-header:hover {
background-color: #3c3c3c;
}
`}</style>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '300px',
maxHeight: '500px',
backgroundColor: '#2d2d2d',
borderRadius: '6px',
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* 搜索框 */}
<div style={{
padding: '12px',
borderBottom: '1px solid #3c3c3c',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<Search size={16} style={{ color: '#999', flexShrink: 0 }} />
<input
type="text"
placeholder="搜索节点..."
autoFocus
value={searchText}
onChange={(e) => {
onSearchChange(e.target.value);
onIndexChange(0);
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setShouldAutoScroll(true);
onIndexChange(Math.min(selectedIndex + 1, flattenedTemplates.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setShouldAutoScroll(true);
onIndexChange(Math.max(selectedIndex - 1, 0));
} else if (e.key === 'Enter' && flattenedTemplates.length > 0) {
e.preventDefault();
const selectedTemplate = flattenedTemplates[selectedIndex];
if (selectedTemplate) {
onNodeSelect(selectedTemplate);
}
}
}}
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
color: '#ccc',
fontSize: '14px',
padding: '4px'
}}
/>
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: '#999',
cursor: 'pointer',
padding: '4px',
display: 'flex',
alignItems: 'center'
}}
>
<X size={16} />
</button>
</div>
{/* 节点列表 */}
<div
className="quick-create-menu-list"
style={{
flex: 1,
overflowY: 'auto',
padding: '4px'
}}
>
{categoryGroups.length === 0 ? (
<div style={{
padding: '20px',
textAlign: 'center',
color: '#666',
fontSize: '12px'
}}>
</div>
) : (
categoryGroups.map((group) => {
return (
<div key={group.category} style={{ marginBottom: '4px' }}>
<div
className="category-header"
onClick={() => toggleCategory(group.category)}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
backgroundColor: '#1e1e1e',
borderRadius: '3px',
cursor: 'pointer',
userSelect: 'none'
}}
>
{group.isExpanded ? (
<ChevronDown size={14} style={{ color: '#999', flexShrink: 0 }} />
) : (
<ChevronRight size={14} style={{ color: '#999', flexShrink: 0 }} />
)}
<span style={{
color: '#aaa',
fontSize: '12px',
fontWeight: '600',
flex: 1
}}>
{group.category}
</span>
<span style={{
color: '#666',
fontSize: '11px',
backgroundColor: '#2d2d2d',
padding: '2px 6px',
borderRadius: '10px'
}}>
{group.templates.length}
</span>
</div>
{group.isExpanded && (
<div style={{ paddingLeft: '8px', paddingTop: '4px' }}>
{group.templates.map((template: NodeTemplate) => {
globalIndex++;
const IconComponent = template.icon ? iconMap[template.icon] : null;
const className = template.className || '';
const isSelected = globalIndex === selectedIndex;
return (
<div
key={template.className || template.displayName}
ref={isSelected ? selectedNodeRef : null}
onClick={() => onNodeSelect(template)}
onMouseEnter={() => onIndexChange(globalIndex)}
style={{
padding: '8px 12px',
marginBottom: '4px',
backgroundColor: isSelected ? '#0e639c' : '#1e1e1e',
borderLeft: `3px solid ${template.color || '#666'}`,
borderRadius: '3px',
cursor: 'pointer',
transition: 'all 0.15s',
transform: isSelected ? 'translateX(2px)' : 'translateX(0)'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '4px'
}}>
{IconComponent && (
<IconComponent size={14} style={{ color: template.color || '#999', flexShrink: 0 }} />
)}
<div style={{ flex: 1 }}>
<div style={{
color: '#ccc',
fontSize: '13px',
fontWeight: '500',
marginBottom: '2px'
}}>
{template.displayName}
</div>
{className && (
<div style={{
color: '#666',
fontSize: '10px',
fontFamily: 'Consolas, Monaco, monospace',
opacity: 0.8
}}>
{className}
</div>
)}
</div>
</div>
<div style={{
fontSize: '11px',
color: '#999',
lineHeight: '1.4'
}}>
{template.description}
</div>
</div>
);
})}
</div>
)}
</div>
);
})
)}
</div>
</div>
</>
);
};