feat: plugin installation webui

This commit is contained in:
Junyan Qin
2025-08-15 22:05:39 +08:00
parent b464d238c5
commit 288b294148
7 changed files with 234 additions and 8 deletions

View File

@@ -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>(PluginInstallStatus.WAIT_INPUT);
const [installError, setInstallError] = useState<string | null>(null);
const [githubURL, setGithubURL] = useState('');
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [uploadStatus, setUploadStatus] = useState<UploadModalStatus>(
UploadModalStatus.UPLOADING,
);
const [uploadError, setUploadError] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className={styles.pageContainer}>
<div
className={`${styles.pageContainer} ${isDragOver ? 'bg-blue-50' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept=".lbpkg,.zip"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<div className="flex flex-row justify-between items-center px-[0.8rem]">
<TabsList className="shadow-md py-5 bg-[#f0f0f0]">
@@ -120,12 +215,7 @@ export default function PluginConfigPage() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
// TODO: 本地上传功能待实现
console.log('本地上传功能待实现');
}}
>
<DropdownMenuItem onClick={handleFileSelect}>
<UploadIcon className="w-4 h-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
@@ -206,6 +296,28 @@ export default function PluginConfigPage() {
</DialogContent>
</Dialog>
{/* 上传状态弹窗 */}
<PluginUploadDialog
open={uploadModalOpen}
onOpenChange={setUploadModalOpen}
status={uploadStatus}
error={uploadError}
/>
{/* 拖拽提示覆盖层 */}
{isDragOver && (
<div className="fixed inset-0 bg-gray-500 bg-opacity-50 flex items-center justify-center z-50 pointer-events-none">
<div className="bg-white rounded-lg p-8 shadow-lg border-2 border-dashed border-gray-500">
<div className="text-center">
<UploadIcon className="mx-auto h-12 w-12 text-gray-500 mb-4" />
<p className="text-lg font-medium text-gray-700">
{t('plugins.dragToUpload')}
</p>
</div>
</div>
</div>
)}
<PluginSortDialog
open={sortModalOpen}
onOpenChange={setSortModalOpen}

View File

@@ -0,0 +1,69 @@
'use client';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { UploadIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export enum UploadModalStatus {
UPLOADING = 'uploading',
SUCCESS = 'success',
ERROR = 'error',
}
interface PluginUploadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
status: UploadModalStatus;
error?: string | null;
}
export default function PluginUploadDialog({
open,
onOpenChange,
status,
error,
}: PluginUploadDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[400px] p-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-4">
<UploadIcon className="size-6" />
<span>{t('plugins.uploadLocalPlugin')}</span>
</DialogTitle>
</DialogHeader>
<div className="mt-4">
{status === UploadModalStatus.UPLOADING && (
<p className="mb-2">{t('plugins.uploadingPlugin')}</p>
)}
{status === UploadModalStatus.SUCCESS && (
<p className="mb-2 text-green-600">{t('plugins.uploadSuccess')}</p>
)}
{status === UploadModalStatus.ERROR && (
<>
<p className="mb-2">{t('plugins.uploadFailed')}</p>
<p className="mb-2 text-red-500">{error}</p>
</>
)}
</div>
<DialogFooter>
{(status === UploadModalStatus.SUCCESS ||
status === UploadModalStatus.ERROR) && (
<Button variant="default" onClick={() => onOpenChange(false)}>
{t('common.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -253,6 +253,12 @@ export class BackendClient extends BaseHttpClient {
return this.post('/api/v1/plugins/install/github', { source });
}
public installPluginFromLocal(file: File): Promise<AsyncTaskCreatedResp> {
const formData = new FormData();
formData.append('file', file);
return this.postFile('/api/v1/plugins/install/local', formData);
}
public removePlugin(
author: string,
name: string,

View File

@@ -192,4 +192,20 @@ export abstract class BaseHttpClient {
public delete<T = unknown>(url: string, config?: RequestConfig): Promise<T> {
return this.request<T>({ method: 'delete', url, ...config });
}
public postFile<T = unknown>(
url: string,
formData: FormData,
config?: RequestConfig,
): Promise<T> {
return this.request<T>({
method: 'post',
url,
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
...config,
});
}
}

View File

@@ -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',

View File

@@ -181,6 +181,14 @@ const jaJP = {
starCount: 'スター:{{count}}',
uploadLocal: 'ローカルアップロード',
debugging: 'デバッグ中',
uploadLocalPlugin: 'ローカルプラグインのアップロード',
dragToUpload: 'ファイルをここにドラッグしてアップロード',
unsupportedFileType:
'サポートされていないファイルタイプです。.lbpkg と .zip ファイルのみサポートされています',
uploadingPlugin: 'プラグインをアップロード中...',
uploadSuccess: 'アップロード成功',
uploadFailed: 'アップロード失敗',
selectFileToUpload: 'アップロードするプラグインファイルを選択',
},
pipelines: {
title: 'パイプライン',

View File

@@ -177,6 +177,13 @@ const zhHans = {
starCount: '星标:{{count}}',
uploadLocal: '本地上传',
debugging: '调试中',
uploadLocalPlugin: '上传本地插件',
dragToUpload: '拖拽文件到此处上传',
unsupportedFileType: '不支持的文件类型,仅支持 .lbpkg 和 .zip 文件',
uploadingPlugin: '正在上传插件...',
uploadSuccess: '上传成功',
uploadFailed: '上传失败',
selectFileToUpload: '选择要上传的插件文件',
},
pipelines: {
title: '流水线',