From 288b2941484e0d1c33d1cd33785715a5e23c78cc Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Fri, 15 Aug 2025 22:05:39 +0800 Subject: [PATCH] feat: plugin installation webui --- web/src/app/home/plugins/page.tsx | 128 ++++++++++++++++-- .../PluginUploadDialog.tsx | 69 ++++++++++ web/src/app/infra/http/BackendClient.ts | 6 + web/src/app/infra/http/BaseHttpClient.ts | 16 +++ web/src/i18n/locales/en-US.ts | 8 ++ web/src/i18n/locales/ja-JP.ts | 8 ++ web/src/i18n/locales/zh-Hans.ts | 7 + 7 files changed, 234 insertions(+), 8 deletions(-) create mode 100644 web/src/app/home/plugins/plugin-upload-dialog/PluginUploadDialog.tsx diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 2d3ce125..b2776132 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -4,6 +4,9 @@ import PluginInstalledComponent, { } from '@/app/home/plugins/plugin-installed/PluginInstalledComponent'; import PluginMarketComponent from '@/app/home/plugins/plugin-market/PluginMarketComponent'; import PluginSortDialog from '@/app/home/plugins/plugin-sort/PluginSortDialog'; +import PluginUploadDialog, { + UploadModalStatus, +} from '@/app/home/plugins/plugin-upload-dialog/PluginUploadDialog'; import styles from './plugins.module.css'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; @@ -23,7 +26,7 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { GithubIcon } from 'lucide-react'; -import { useState, useRef } from 'react'; +import { useState, useRef, useCallback } from 'react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; @@ -43,7 +46,14 @@ export default function PluginConfigPage() { useState(PluginInstallStatus.WAIT_INPUT); const [installError, setInstallError] = useState(null); const [githubURL, setGithubURL] = useState(''); + const [uploadModalOpen, setUploadModalOpen] = useState(false); + const [uploadStatus, setUploadStatus] = useState( + UploadModalStatus.UPLOADING, + ); + const [uploadError, setUploadError] = useState(null); + const [isDragOver, setIsDragOver] = useState(false); const pluginInstalledRef = useRef(null); + const fileInputRef = useRef(null); function handleModalConfirm() { installPlugin(githubURL); @@ -88,8 +98,93 @@ export default function PluginConfigPage() { }); } + const validateFileType = (file: File): boolean => { + const allowedExtensions = ['.lbpkg', '.zip']; + const fileName = file.name.toLowerCase(); + return allowedExtensions.some((ext) => fileName.endsWith(ext)); + }; + + const uploadPluginFile = useCallback( + async (file: File) => { + if (!validateFileType(file)) { + toast.error(t('plugins.unsupportedFileType')); + return; + } + + setUploadModalOpen(true); + setUploadStatus(UploadModalStatus.UPLOADING); + setUploadError(null); + + try { + // 暂时直接显示成功,等后续实现进度显示 + setTimeout(() => { + setUploadStatus(UploadModalStatus.SUCCESS); + toast.success(t('plugins.uploadSuccess')); + pluginInstalledRef.current?.refreshPluginList(); + }, 1000); + } catch (err: unknown) { + setUploadError((err as Error)?.message || t('plugins.uploadFailed')); + setUploadStatus(UploadModalStatus.ERROR); + } + }, + [t], + ); + + const handleFileSelect = useCallback(() => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }, []); + + const handleFileChange = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + uploadPluginFile(file); + } + // 清空input值,以便可以重复选择同一个文件 + event.target.value = ''; + }, + [uploadPluginFile], + ); + + const handleDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault(); + setIsDragOver(true); + }, []); + + const handleDragLeave = useCallback((event: React.DragEvent) => { + event.preventDefault(); + setIsDragOver(false); + }, []); + + const handleDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + setIsDragOver(false); + + const files = Array.from(event.dataTransfer.files); + if (files.length > 0) { + uploadPluginFile(files[0]); + } + }, + [uploadPluginFile], + ); + return ( -
+
+
@@ -120,12 +215,7 @@ export default function PluginConfigPage() { - { - // TODO: 本地上传功能待实现 - console.log('本地上传功能待实现'); - }} - > + {t('plugins.uploadLocal')} @@ -206,6 +296,28 @@ export default function PluginConfigPage() { + {/* 上传状态弹窗 */} + + + {/* 拖拽提示覆盖层 */} + {isDragOver && ( +
+
+
+ +

+ {t('plugins.dragToUpload')} +

+
+
+
+ )} + void; + status: UploadModalStatus; + error?: string | null; +} + +export default function PluginUploadDialog({ + open, + onOpenChange, + status, + error, +}: PluginUploadDialogProps) { + const { t } = useTranslation(); + + return ( + + + + + + {t('plugins.uploadLocalPlugin')} + + +
+ {status === UploadModalStatus.UPLOADING && ( +

{t('plugins.uploadingPlugin')}

+ )} + {status === UploadModalStatus.SUCCESS && ( +

{t('plugins.uploadSuccess')}

+ )} + {status === UploadModalStatus.ERROR && ( + <> +

{t('plugins.uploadFailed')}

+

{error}

+ + )} +
+ + {(status === UploadModalStatus.SUCCESS || + status === UploadModalStatus.ERROR) && ( + + )} + +
+
+ ); +} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index a30a5380..529bd5de 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -253,6 +253,12 @@ export class BackendClient extends BaseHttpClient { return this.post('/api/v1/plugins/install/github', { source }); } + public installPluginFromLocal(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + return this.postFile('/api/v1/plugins/install/local', formData); + } + public removePlugin( author: string, name: string, diff --git a/web/src/app/infra/http/BaseHttpClient.ts b/web/src/app/infra/http/BaseHttpClient.ts index dc440799..019a54e6 100644 --- a/web/src/app/infra/http/BaseHttpClient.ts +++ b/web/src/app/infra/http/BaseHttpClient.ts @@ -192,4 +192,20 @@ export abstract class BaseHttpClient { public delete(url: string, config?: RequestConfig): Promise { return this.request({ method: 'delete', url, ...config }); } + + public postFile( + url: string, + formData: FormData, + config?: RequestConfig, + ): Promise { + return this.request({ + method: 'post', + url, + data: formData, + headers: { + 'Content-Type': 'multipart/form-data', + }, + ...config, + }); + } } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 032ee70c..85c0c07c 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -181,6 +181,14 @@ const enUS = { starCount: 'Stars: {{count}}', uploadLocal: 'Upload Local', debugging: 'Debugging', + uploadLocalPlugin: 'Upload Local Plugin', + dragToUpload: 'Drag plugin file here to upload', + unsupportedFileType: + 'Unsupported file type, only .lbpkg and .zip files are supported', + uploadingPlugin: 'Uploading plugin...', + uploadSuccess: 'Upload successful', + uploadFailed: 'Upload failed', + selectFileToUpload: 'Select plugin file to upload', }, pipelines: { title: 'Pipelines', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index e17a5988..6613d118 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -181,6 +181,14 @@ const jaJP = { starCount: 'スター:{{count}}', uploadLocal: 'ローカルアップロード', debugging: 'デバッグ中', + uploadLocalPlugin: 'ローカルプラグインのアップロード', + dragToUpload: 'ファイルをここにドラッグしてアップロード', + unsupportedFileType: + 'サポートされていないファイルタイプです。.lbpkg と .zip ファイルのみサポートされています', + uploadingPlugin: 'プラグインをアップロード中...', + uploadSuccess: 'アップロード成功', + uploadFailed: 'アップロード失敗', + selectFileToUpload: 'アップロードするプラグインファイルを選択', }, pipelines: { title: 'パイプライン', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 3a723e12..821cdaec 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -177,6 +177,13 @@ const zhHans = { starCount: '星标:{{count}}', uploadLocal: '本地上传', debugging: '调试中', + uploadLocalPlugin: '上传本地插件', + dragToUpload: '拖拽文件到此处上传', + unsupportedFileType: '不支持的文件类型,仅支持 .lbpkg 和 .zip 文件', + uploadingPlugin: '正在上传插件...', + uploadSuccess: '上传成功', + uploadFailed: '上传失败', + selectFileToUpload: '选择要上传的插件文件', }, pipelines: { title: '流水线',