Files
esengine/packages/editor-app/src/components/PluginUpdateDialog.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

355 lines
15 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 } from 'react';
import { X, FolderOpen, Loader, CheckCircle, AlertCircle, RefreshCw } from 'lucide-react';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import type { GitHubService, PublishedPlugin } from '../services/GitHubService';
import { PluginPublishService, type PublishProgress } from '../services/PluginPublishService';
import { PluginBuildService, type BuildProgress } from '../services/PluginBuildService';
import { open } from '@tauri-apps/plugin-shell';
import { EditorPluginCategory } from '@esengine/editor-core';
import type { IEditorPluginMetadata } from '@esengine/editor-core';
import '../styles/PluginUpdateDialog.css';
interface PluginUpdateDialogProps {
plugin: PublishedPlugin;
githubService: GitHubService;
onClose: () => void;
onSuccess: () => void;
locale: string;
}
type Step = 'selectFolder' | 'info' | 'building' | 'publishing' | 'success' | 'error';
function calculateNextVersion(currentVersion: string): string {
const parts = currentVersion.split('.').map(Number);
if (parts.length !== 3 || parts.some(isNaN)) return currentVersion;
const [major, minor, patch] = parts;
return `${major}.${minor}.${(patch ?? 0) + 1}`;
}
export function PluginUpdateDialog({ plugin, githubService, onClose, onSuccess, locale }: PluginUpdateDialogProps) {
const [publishService] = useState(() => new PluginPublishService(githubService));
const [buildService] = useState(() => new PluginBuildService());
const [step, setStep] = useState<Step>('selectFolder');
const [pluginFolder, setPluginFolder] = useState('');
const [version, setVersion] = useState('');
const [releaseNotes, setReleaseNotes] = useState('');
const [suggestedVersion] = useState(() => calculateNextVersion(plugin.latestVersion));
const [error, setError] = useState('');
const [buildLog, setBuildLog] = useState<string[]>([]);
const [buildProgress, setBuildProgress] = useState<BuildProgress | null>(null);
const [publishProgress, setPublishProgress] = useState<PublishProgress | null>(null);
const [prUrl, setPrUrl] = useState('');
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
zh: {
title: '更新插件',
currentVersion: '当前版本',
newVersion: '新版本号',
useSuggested: '使用建议版本',
releaseNotes: '更新说明',
releaseNotesPlaceholder: '描述这个版本的变更...',
selectFolder: '选择插件文件夹',
selectFolderDesc: '选择包含更新后插件源代码的文件夹',
browseFolder: '浏览文件夹',
selectedFolder: '已选择文件夹',
next: '下一步',
back: '上一步',
buildAndPublish: '构建并发布',
building: '构建中...',
publishing: '发布中...',
success: '更新成功!',
error: '更新失败',
viewPR: '查看 PR',
close: '关闭',
buildError: '构建失败',
publishError: '发布失败',
buildingStep1: '正在安装依赖...',
buildingStep2: '正在构建项目...',
buildingStep3: '正在打包 ZIP...',
publishingStep1: '正在 Fork 仓库...',
publishingStep2: '正在创建分支...',
publishingStep3: '正在上传文件...',
publishingStep4: '正在创建 Pull Request...',
reviewMessage: '你的插件更新已创建 PR维护者将进行审核。审核通过后新版本将自动发布到市场。'
},
en: {
title: 'Update Plugin',
currentVersion: 'Current Version',
newVersion: 'New Version',
useSuggested: 'Use Suggested',
releaseNotes: 'Release Notes',
releaseNotesPlaceholder: 'Describe the changes in this version...',
selectFolder: 'Select Plugin Folder',
selectFolderDesc: 'Select the folder containing your updated plugin source code',
browseFolder: 'Browse Folder',
selectedFolder: 'Selected Folder',
next: 'Next',
back: 'Back',
buildAndPublish: 'Build & Publish',
building: 'Building...',
publishing: 'Publishing...',
success: 'Update Successful!',
error: 'Update Failed',
viewPR: 'View PR',
close: 'Close',
buildError: 'Build Failed',
publishError: 'Publish Failed',
buildingStep1: 'Installing dependencies...',
buildingStep2: 'Building project...',
buildingStep3: 'Packaging ZIP...',
publishingStep1: 'Forking repository...',
publishingStep2: 'Creating branch...',
publishingStep3: 'Uploading files...',
publishingStep4: 'Creating Pull Request...',
reviewMessage: 'Your plugin update has been created as a PR. Maintainers will review it. Once approved, the new version will be published to the marketplace.'
}
};
return translations[locale]?.[key] || translations.en?.[key] || key;
};
const handleSelectFolder = async () => {
try {
const selected = await openDialog({
directory: true,
multiple: false,
title: t('selectFolder')
});
if (!selected) return;
setPluginFolder(selected as string);
setStep('info');
} catch (err) {
console.error('[PluginUpdateDialog] Failed to select folder:', err);
setError(err instanceof Error ? err.message : 'Failed to select folder');
}
};
const handleBuildAndPublish = async () => {
if (!version || !releaseNotes) {
alert('Please fill in all required fields');
return;
}
setStep('building');
setBuildLog([]);
setError('');
try {
buildService.setProgressCallback((progress) => {
setBuildProgress(progress);
if (progress.output) {
setBuildLog((prev) => [...prev, progress.output!]);
}
});
const zipPath = await buildService.buildPlugin(pluginFolder);
console.log('[PluginUpdateDialog] Build completed:', zipPath);
setStep('publishing');
publishService.setProgressCallback((progress) => {
setPublishProgress(progress);
});
const { readTextFile } = await import('@tauri-apps/plugin-fs');
const packageJsonPath = `${pluginFolder}/package.json`;
const packageJsonContent = await readTextFile(packageJsonPath);
const pkgJson = JSON.parse(packageJsonContent);
const pluginMetadata: IEditorPluginMetadata = {
name: pkgJson.name,
displayName: pkgJson.description || pkgJson.name,
description: pkgJson.description || '',
version: pkgJson.version,
category: EditorPluginCategory.Tool,
icon: 'Package',
enabled: true,
installedAt: Date.now()
};
const publishInfo = {
pluginMetadata,
version,
releaseNotes,
category: plugin.category_type as 'official' | 'community',
repositoryUrl: plugin.repositoryUrl || '',
tags: []
};
const prUrl = await publishService.publishPlugin(publishInfo, zipPath);
console.log('[PluginUpdateDialog] Update published:', prUrl);
setPrUrl(prUrl);
setStep('success');
onSuccess();
} catch (err) {
console.error('[PluginUpdateDialog] Failed to update plugin:', err);
setError(err instanceof Error ? err.message : 'Update failed');
setStep('error');
}
};
const renderStepContent = () => {
switch (step) {
case 'selectFolder':
return (
<div className="update-dialog-step">
<h3>{t('selectFolder')}</h3>
<p className="step-description">{t('selectFolderDesc')}</p>
<button className="btn-browse" onClick={handleSelectFolder}>
<FolderOpen size={16} />
{t('browseFolder')}
</button>
</div>
);
case 'info':
return (
<div className="update-dialog-step">
<div className="current-plugin-info">
<h4>{plugin.name}</h4>
<p>
{t('currentVersion')}: <strong>v{plugin.latestVersion}</strong>
</p>
</div>
{pluginFolder && (
<div className="selected-folder-info">
<FolderOpen size={16} />
<span>{pluginFolder}</span>
</div>
)}
<div className="form-group">
<label>{t('newVersion')} *</label>
<div className="version-input-group">
<input
type="text"
value={version}
onChange={(e) => setVersion(e.target.value)}
placeholder={suggestedVersion}
/>
<button
type="button"
className="btn-suggest"
onClick={() => setVersion(suggestedVersion)}
>
{t('useSuggested')} ({suggestedVersion})
</button>
</div>
</div>
<div className="form-group">
<label>{t('releaseNotes')} *</label>
<textarea
rows={6}
value={releaseNotes}
onChange={(e) => setReleaseNotes(e.target.value)}
placeholder={t('releaseNotesPlaceholder')}
/>
</div>
<div className="update-dialog-actions">
<button className="btn-back" onClick={() => setStep('selectFolder')}>
{t('back')}
</button>
<button
className="btn-primary"
onClick={handleBuildAndPublish}
disabled={!version || !releaseNotes}
>
{t('buildAndPublish')}
</button>
</div>
</div>
);
case 'building':
return (
<div className="update-dialog-step">
<h3>{t('building')}</h3>
{buildProgress && (
<div className="progress-container">
<p className="progress-message">{buildProgress.message}</p>
</div>
)}
{buildLog.length > 0 && (
<div className="build-log">
{buildLog.map((log, index) => (
<div key={index} className="log-line">
{log}
</div>
))}
</div>
)}
</div>
);
case 'publishing':
return (
<div className="update-dialog-step">
<h3>{t('publishing')}</h3>
{publishProgress && (
<div className="progress-container">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${publishProgress.progress}%` }} />
</div>
<p className="progress-message">{publishProgress.message}</p>
</div>
)}
</div>
);
case 'success':
return (
<div className="update-dialog-step success-step">
<CheckCircle size={64} className="success-icon" />
<h3>{t('success')}</h3>
<p className="success-message">{t('reviewMessage')}</p>
{prUrl && (
<button className="btn-view-pr" onClick={() => open(prUrl)}>
{t('viewPR')}
</button>
)}
<button className="btn-close" onClick={onClose}>
{t('close')}
</button>
</div>
);
case 'error':
return (
<div className="update-dialog-step error-step">
<AlertCircle size={64} className="error-icon" />
<h3>{t('error')}</h3>
<p className="error-message">{error}</p>
<button className="btn-close" onClick={onClose}>
{t('close')}
</button>
</div>
);
default:
return null;
}
};
return (
<div className="plugin-update-dialog-overlay">
<div className="plugin-update-dialog">
<div className="update-dialog-header">
<h2>{t('title')}: {plugin.name}</h2>
<button className="update-dialog-close" onClick={onClose}>
<X size={20} />
</button>
</div>
<div className="update-dialog-content">{renderStepContent()}</div>
</div>
</div>
);
}