mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-04 21:06:03 +00:00
feat: plugin installation webui
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -181,6 +181,14 @@ const jaJP = {
|
||||
starCount: 'スター:{{count}}',
|
||||
uploadLocal: 'ローカルアップロード',
|
||||
debugging: 'デバッグ中',
|
||||
uploadLocalPlugin: 'ローカルプラグインのアップロード',
|
||||
dragToUpload: 'ファイルをここにドラッグしてアップロード',
|
||||
unsupportedFileType:
|
||||
'サポートされていないファイルタイプです。.lbpkg と .zip ファイルのみサポートされています',
|
||||
uploadingPlugin: 'プラグインをアップロード中...',
|
||||
uploadSuccess: 'アップロード成功',
|
||||
uploadFailed: 'アップロード失敗',
|
||||
selectFileToUpload: 'アップロードするプラグインファイルを選択',
|
||||
},
|
||||
pipelines: {
|
||||
title: 'パイプライン',
|
||||
|
||||
@@ -177,6 +177,13 @@ const zhHans = {
|
||||
starCount: '星标:{{count}}',
|
||||
uploadLocal: '本地上传',
|
||||
debugging: '调试中',
|
||||
uploadLocalPlugin: '上传本地插件',
|
||||
dragToUpload: '拖拽文件到此处上传',
|
||||
unsupportedFileType: '不支持的文件类型,仅支持 .lbpkg 和 .zip 文件',
|
||||
uploadingPlugin: '正在上传插件...',
|
||||
uploadSuccess: '上传成功',
|
||||
uploadFailed: '上传失败',
|
||||
selectFileToUpload: '选择要上传的插件文件',
|
||||
},
|
||||
pipelines: {
|
||||
title: '流水线',
|
||||
|
||||
Reference in New Issue
Block a user