mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-16 07:20:59 +00:00
feat: plugin installation webui
This commit is contained in:
@@ -4,6 +4,9 @@ import PluginInstalledComponent, {
|
|||||||
} from '@/app/home/plugins/plugin-installed/PluginInstalledComponent';
|
} from '@/app/home/plugins/plugin-installed/PluginInstalledComponent';
|
||||||
import PluginMarketComponent from '@/app/home/plugins/plugin-market/PluginMarketComponent';
|
import PluginMarketComponent from '@/app/home/plugins/plugin-market/PluginMarketComponent';
|
||||||
import PluginSortDialog from '@/app/home/plugins/plugin-sort/PluginSortDialog';
|
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 styles from './plugins.module.css';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -23,7 +26,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { GithubIcon } from 'lucide-react';
|
import { GithubIcon } from 'lucide-react';
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useCallback } from 'react';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -43,7 +46,14 @@ export default function PluginConfigPage() {
|
|||||||
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
|
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
|
||||||
const [installError, setInstallError] = useState<string | null>(null);
|
const [installError, setInstallError] = useState<string | null>(null);
|
||||||
const [githubURL, setGithubURL] = useState('');
|
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 pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
function handleModalConfirm() {
|
function handleModalConfirm() {
|
||||||
installPlugin(githubURL);
|
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 (
|
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">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
<div className="flex flex-row justify-between items-center px-[0.8rem]">
|
<div className="flex flex-row justify-between items-center px-[0.8rem]">
|
||||||
<TabsList className="shadow-md py-5 bg-[#f0f0f0]">
|
<TabsList className="shadow-md py-5 bg-[#f0f0f0]">
|
||||||
@@ -120,12 +215,7 @@ export default function PluginConfigPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={handleFileSelect}>
|
||||||
onClick={() => {
|
|
||||||
// TODO: 本地上传功能待实现
|
|
||||||
console.log('本地上传功能待实现');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UploadIcon className="w-4 h-4" />
|
<UploadIcon className="w-4 h-4" />
|
||||||
{t('plugins.uploadLocal')}
|
{t('plugins.uploadLocal')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -206,6 +296,28 @@ export default function PluginConfigPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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
|
<PluginSortDialog
|
||||||
open={sortModalOpen}
|
open={sortModalOpen}
|
||||||
onOpenChange={setSortModalOpen}
|
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 });
|
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(
|
public removePlugin(
|
||||||
author: string,
|
author: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
|||||||
@@ -192,4 +192,20 @@ export abstract class BaseHttpClient {
|
|||||||
public delete<T = unknown>(url: string, config?: RequestConfig): Promise<T> {
|
public delete<T = unknown>(url: string, config?: RequestConfig): Promise<T> {
|
||||||
return this.request<T>({ method: 'delete', url, ...config });
|
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}}',
|
starCount: 'Stars: {{count}}',
|
||||||
uploadLocal: 'Upload Local',
|
uploadLocal: 'Upload Local',
|
||||||
debugging: 'Debugging',
|
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: {
|
pipelines: {
|
||||||
title: 'Pipelines',
|
title: 'Pipelines',
|
||||||
|
|||||||
@@ -181,6 +181,14 @@ const jaJP = {
|
|||||||
starCount: 'スター:{{count}}',
|
starCount: 'スター:{{count}}',
|
||||||
uploadLocal: 'ローカルアップロード',
|
uploadLocal: 'ローカルアップロード',
|
||||||
debugging: 'デバッグ中',
|
debugging: 'デバッグ中',
|
||||||
|
uploadLocalPlugin: 'ローカルプラグインのアップロード',
|
||||||
|
dragToUpload: 'ファイルをここにドラッグしてアップロード',
|
||||||
|
unsupportedFileType:
|
||||||
|
'サポートされていないファイルタイプです。.lbpkg と .zip ファイルのみサポートされています',
|
||||||
|
uploadingPlugin: 'プラグインをアップロード中...',
|
||||||
|
uploadSuccess: 'アップロード成功',
|
||||||
|
uploadFailed: 'アップロード失敗',
|
||||||
|
selectFileToUpload: 'アップロードするプラグインファイルを選択',
|
||||||
},
|
},
|
||||||
pipelines: {
|
pipelines: {
|
||||||
title: 'パイプライン',
|
title: 'パイプライン',
|
||||||
|
|||||||
@@ -177,6 +177,13 @@ const zhHans = {
|
|||||||
starCount: '星标:{{count}}',
|
starCount: '星标:{{count}}',
|
||||||
uploadLocal: '本地上传',
|
uploadLocal: '本地上传',
|
||||||
debugging: '调试中',
|
debugging: '调试中',
|
||||||
|
uploadLocalPlugin: '上传本地插件',
|
||||||
|
dragToUpload: '拖拽文件到此处上传',
|
||||||
|
unsupportedFileType: '不支持的文件类型,仅支持 .lbpkg 和 .zip 文件',
|
||||||
|
uploadingPlugin: '正在上传插件...',
|
||||||
|
uploadSuccess: '上传成功',
|
||||||
|
uploadFailed: '上传失败',
|
||||||
|
selectFileToUpload: '选择要上传的插件文件',
|
||||||
},
|
},
|
||||||
pipelines: {
|
pipelines: {
|
||||||
title: '流水线',
|
title: '流水线',
|
||||||
|
|||||||
Reference in New Issue
Block a user