Files
esengine/packages/editor-app/src/components/PluginManagerWindow.tsx
YHH bce3a6e253 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): 修复字符串替换安全问题
2025-11-18 14:46:51 +08:00

439 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useMemo } from 'react';
import { EditorPluginManager, IEditorPluginMetadata, EditorPluginCategory } from '@esengine/editor-core';
import * as LucideIcons from 'lucide-react';
import {
Package,
CheckCircle,
XCircle,
Search,
Grid,
List,
ChevronDown,
ChevronRight,
X,
RefreshCw,
ShoppingCart
} from 'lucide-react';
import { PluginMarketPanel } from './PluginMarketPanel';
import { PluginMarketService } from '../services/PluginMarketService';
import { GitHubService } from '../services/GitHubService';
import '../styles/PluginManagerWindow.css';
interface PluginManagerWindowProps {
pluginManager: EditorPluginManager;
githubService: GitHubService;
onClose: () => void;
onRefresh?: () => Promise<void>;
onOpen?: () => void;
locale: string;
projectPath: string | null;
}
const categoryIcons: Record<EditorPluginCategory, string> = {
[EditorPluginCategory.Tool]: 'Wrench',
[EditorPluginCategory.Window]: 'LayoutGrid',
[EditorPluginCategory.Inspector]: 'Search',
[EditorPluginCategory.System]: 'Settings',
[EditorPluginCategory.ImportExport]: 'Package'
};
export function PluginManagerWindow({ pluginManager, githubService, onClose, onRefresh, onOpen, locale, projectPath }: PluginManagerWindowProps) {
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
zh: {
title: '插件管理器',
searchPlaceholder: '搜索插件...',
enabled: '已启用',
disabled: '已禁用',
enable: '启用',
disable: '禁用',
enablePlugin: '启用插件',
disablePlugin: '禁用插件',
refresh: '刷新',
refreshPluginList: '刷新插件列表',
close: '关闭',
listView: '列表视图',
gridView: '网格视图',
noPlugins: '未安装插件',
installed: '安装于',
categoryTools: '工具',
categoryWindows: '窗口',
categoryInspectors: '检查器',
categorySystem: '系统',
categoryImportExport: '导入/导出',
tabInstalled: '已安装',
tabMarketplace: '插件市场'
},
en: {
title: 'Plugin Manager',
searchPlaceholder: 'Search plugins...',
enabled: 'Enabled',
disabled: 'Disabled',
enable: 'Enable',
disable: 'Disable',
enablePlugin: 'Enable plugin',
disablePlugin: 'Disable plugin',
refresh: 'Refresh',
refreshPluginList: 'Refresh plugin list',
close: 'Close',
listView: 'List view',
gridView: 'Grid view',
noPlugins: 'No plugins installed',
installed: 'Installed',
categoryTools: 'Tools',
categoryWindows: 'Windows',
categoryInspectors: 'Inspectors',
categorySystem: 'System',
categoryImportExport: 'Import/Export',
tabInstalled: 'Installed',
tabMarketplace: 'Marketplace'
}
};
return translations[locale]?.[key] || translations.en?.[key] || key;
};
const getCategoryName = (category: EditorPluginCategory): string => {
const categoryKeys: Record<EditorPluginCategory, string> = {
[EditorPluginCategory.Tool]: 'categoryTools',
[EditorPluginCategory.Window]: 'categoryWindows',
[EditorPluginCategory.Inspector]: 'categoryInspectors',
[EditorPluginCategory.System]: 'categorySystem',
[EditorPluginCategory.ImportExport]: 'categoryImportExport'
};
return t(categoryKeys[category]);
};
const [activeTab, setActiveTab] = useState<'installed' | 'marketplace'>('installed');
const [plugins, setPlugins] = useState<IEditorPluginMetadata[]>([]);
const [filter, setFilter] = useState('');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
const [expandedCategories, setExpandedCategories] = useState<Set<EditorPluginCategory>>(
new Set(Object.values(EditorPluginCategory))
);
const [isRefreshing, setIsRefreshing] = useState(false);
const marketService = useMemo(() => new PluginMarketService(pluginManager), [pluginManager]);
// 设置项目路径到 marketService
useEffect(() => {
marketService.setProjectPath(projectPath);
}, [projectPath, marketService]);
const updatePluginList = () => {
const allPlugins = pluginManager.getAllPluginMetadata();
setPlugins(allPlugins);
};
useEffect(() => {
if (onOpen) {
onOpen();
}
updatePluginList();
}, [pluginManager]);
// 监听 locale 变化,重新获取插件列表(以刷新插件的 displayName 和 description
useEffect(() => {
updatePluginList();
}, [locale]);
const handleRefresh = async () => {
if (!onRefresh || isRefreshing) return;
setIsRefreshing(true);
try {
await onRefresh();
updatePluginList();
} catch (error) {
console.error('Failed to refresh plugins:', error);
} finally {
setIsRefreshing(false);
}
};
const togglePlugin = async (name: string, enabled: boolean) => {
try {
if (enabled) {
await pluginManager.disablePlugin(name);
} else {
await pluginManager.enablePlugin(name);
}
const allPlugins = pluginManager.getAllPluginMetadata();
setPlugins(allPlugins);
} catch (error) {
console.error(`Failed to toggle plugin ${name}:`, error);
}
};
const toggleCategory = (category: EditorPluginCategory) => {
const newExpanded = new Set(expandedCategories);
if (newExpanded.has(category)) {
newExpanded.delete(category);
} else {
newExpanded.add(category);
}
setExpandedCategories(newExpanded);
};
const filteredPlugins = plugins.filter((plugin) => {
if (!filter) return true;
const searchLower = filter.toLowerCase();
return (
plugin.name.toLowerCase().includes(searchLower) ||
plugin.displayName.toLowerCase().includes(searchLower) ||
plugin.description?.toLowerCase().includes(searchLower)
);
});
const pluginsByCategory = filteredPlugins.reduce(
(acc, plugin) => {
if (!acc[plugin.category]) {
acc[plugin.category] = [];
}
acc[plugin.category].push(plugin);
return acc;
},
{} as Record<EditorPluginCategory, IEditorPluginMetadata[]>
);
const enabledCount = plugins.filter((p) => p.enabled).length;
const disabledCount = plugins.filter((p) => !p.enabled).length;
const renderPluginCard = (plugin: IEditorPluginMetadata) => {
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
return (
<div key={plugin.name} className={`plugin-card ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-card-header">
<div className="plugin-card-icon">
{IconComponent ? <IconComponent size={24} /> : <Package size={24} />}
</div>
<div className="plugin-card-info">
<div className="plugin-card-title">{plugin.displayName}</div>
<div className="plugin-card-version">v{plugin.version}</div>
</div>
<button
className={`plugin-toggle ${plugin.enabled ? 'enabled' : 'disabled'}`}
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
title={plugin.enabled ? t('disablePlugin') : t('enablePlugin')}
>
{plugin.enabled ? <CheckCircle size={18} /> : <XCircle size={18} />}
</button>
</div>
{plugin.description && <div className="plugin-card-description">{plugin.description}</div>}
<div className="plugin-card-footer">
<span className="plugin-card-category">
{(() => {
const CategoryIcon = (LucideIcons as any)[categoryIcons[plugin.category]];
return CategoryIcon ? <CategoryIcon size={14} style={{ marginRight: '4px' }} /> : null;
})()}
{getCategoryName(plugin.category)}
</span>
{plugin.installedAt && (
<span className="plugin-card-installed">
{t('installed')}: {new Date(plugin.installedAt).toLocaleDateString()}
</span>
)}
</div>
</div>
);
};
const renderPluginList = (plugin: IEditorPluginMetadata) => {
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
return (
<div key={plugin.name} className={`plugin-list-item ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-list-icon">
{IconComponent ? <IconComponent size={20} /> : <Package size={20} />}
</div>
<div className="plugin-list-info">
<div className="plugin-list-name">
{plugin.displayName}
<span className="plugin-list-version">v{plugin.version}</span>
</div>
{plugin.description && <div className="plugin-list-description">{plugin.description}</div>}
</div>
<div className="plugin-list-status">
{plugin.enabled ? (
<span className="status-badge enabled">{t('enabled')}</span>
) : (
<span className="status-badge disabled">{t('disabled')}</span>
)}
</div>
<button
className="plugin-list-toggle"
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
title={plugin.enabled ? t('disablePlugin') : t('enablePlugin')}
>
{plugin.enabled ? t('disable') : t('enable')}
</button>
</div>
);
};
return (
<div className="plugin-manager-overlay" onClick={onClose}>
<div className="plugin-manager-window" onClick={(e) => e.stopPropagation()}>
<div className="plugin-manager-header">
<div className="plugin-manager-title">
<Package size={20} />
<h2>{t('title')}</h2>
</div>
<button className="plugin-manager-close" onClick={onClose} title={t('close')}>
<X size={20} />
</button>
</div>
<div className="plugin-manager-tabs">
<button
className={`plugin-manager-tab ${activeTab === 'installed' ? 'active' : ''}`}
onClick={() => setActiveTab('installed')}
>
<Package size={16} />
{t('tabInstalled')}
</button>
<button
className={`plugin-manager-tab ${activeTab === 'marketplace' ? 'active' : ''}`}
onClick={() => setActiveTab('marketplace')}
>
<ShoppingCart size={16} />
{t('tabMarketplace')}
</button>
</div>
{activeTab === 'installed' && (
<>
<div className="plugin-toolbar">
<div className="plugin-toolbar-left">
<div className="plugin-search">
<Search size={14} />
<input
type="text"
placeholder={t('searchPlaceholder')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
</div>
<div className="plugin-toolbar-right">
<div className="plugin-stats">
<span className="stat-item enabled">
<CheckCircle size={14} />
{enabledCount} {t('enabled')}
</span>
<span className="stat-item disabled">
<XCircle size={14} />
{disabledCount} {t('disabled')}
</span>
</div>
{onRefresh && (
<button
className="plugin-refresh-btn"
onClick={handleRefresh}
disabled={isRefreshing}
title={t('refreshPluginList')}
style={{
padding: '6px 12px',
display: 'flex',
alignItems: 'center',
gap: '6px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: isRefreshing ? 'not-allowed' : 'pointer',
fontSize: '12px',
opacity: isRefreshing ? 0.6 : 1
}}
>
<RefreshCw size={14} className={isRefreshing ? 'spinning' : ''} />
{t('refresh')}
</button>
)}
<div className="plugin-view-mode">
<button
className={viewMode === 'list' ? 'active' : ''}
onClick={() => setViewMode('list')}
title={t('listView')}
>
<List size={14} />
</button>
<button
className={viewMode === 'grid' ? 'active' : ''}
onClick={() => setViewMode('grid')}
title={t('gridView')}
>
<Grid size={14} />
</button>
</div>
</div>
</div>
<div
className="plugin-content"
style={{ display: activeTab === 'installed' ? 'block' : 'none' }}
>
{plugins.length === 0 ? (
<div className="plugin-empty">
<Package size={48} />
<p>{t('noPlugins')}</p>
</div>
) : (
<div className="plugin-categories">
{Object.entries(pluginsByCategory).map(([category, categoryPlugins]) => {
const cat = category as EditorPluginCategory;
const isExpanded = expandedCategories.has(cat);
return (
<div key={category} className="plugin-category">
<div
className="plugin-category-header"
onClick={() => toggleCategory(cat)}
>
<button className="plugin-category-toggle">
{isExpanded ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)}
</button>
<span className="plugin-category-icon">
{(() => {
const CategoryIcon = (LucideIcons as any)[
categoryIcons[cat]
];
return CategoryIcon ? <CategoryIcon size={16} /> : null;
})()}
</span>
<span className="plugin-category-name">{getCategoryName(cat)}</span>
<span className="plugin-category-count">
{categoryPlugins.length}
</span>
</div>
{isExpanded && (
<div className={`plugin-category-content ${viewMode}`}>
{viewMode === 'grid'
? categoryPlugins.map(renderPluginCard)
: categoryPlugins.map(renderPluginList)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</>
)}
{activeTab === 'marketplace' && (
<PluginMarketPanel
marketService={marketService}
locale={locale}
projectPath={projectPath}
onReloadPlugins={onRefresh}
/>
)}
</div>
</div>
);
}