Files
LangBot/web/src/app/home/plugins/page.tsx
T
2025-08-21 10:47:53 +08:00

360 lines
12 KiB
TypeScript

'use client';
import PluginInstalledComponent, {
PluginInstalledComponentRef,
} from '@/app/home/plugins/plugin-installed/PluginInstalledComponent';
import MarketPage from '@/app/home/plugins/plugin-market/PluginMarketComponent';
// import PluginSortDialog from '@/app/home/plugins/plugin-sort/PluginSortDialog';
import styles from './plugins.module.css';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import {
PlusIcon,
ChevronDownIcon,
UploadIcon,
StoreIcon,
Download,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useState, useRef, useCallback } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { PluginV4 } from '@/app/infra/entities/plugin';
enum PluginInstallStatus {
WAIT_INPUT = 'wait_input',
ASK_CONFIRM = 'ask_confirm',
INSTALLING = 'installing',
ERROR = 'error',
}
export default function PluginConfigPage() {
const { t } = useTranslation();
const [modalOpen, setModalOpen] = useState(false);
// const [sortModalOpen, setSortModalOpen] = useState(false);
const [activeTab, setActiveTab] = useState('installed');
const [installSource, setInstallSource] = useState<string>('local');
const [installInfo, setInstallInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
const [pluginInstallStatus, setPluginInstallStatus] =
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
const [installError, setInstallError] = useState<string | null>(null);
const [githubURL, setGithubURL] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
function watchTask(taskId: number) {
let alreadySuccess = false;
console.log('taskId:', taskId);
// 每秒拉取一次任务状态
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((resp) => {
console.log('task status:', resp);
if (resp.runtime.done) {
clearInterval(interval);
if (resp.runtime.exception) {
setInstallError(resp.runtime.exception);
setPluginInstallStatus(PluginInstallStatus.ERROR);
} else {
// success
if (!alreadySuccess) {
toast.success(t('plugins.installSuccess'));
alreadySuccess = true;
}
setGithubURL('');
setModalOpen(false);
pluginInstalledRef.current?.refreshPluginList();
}
}
});
}, 1000);
}
function handleModalConfirm() {
installPlugin(installSource, installInfo as Record<string, any>); // eslint-disable-line @typescript-eslint/no-explicit-any
}
function installPlugin(
installSource: string,
installInfo: Record<string, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
) {
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
if (installSource === 'github') {
httpClient
.installPluginFromGithub(installInfo.url)
.then((resp) => {
const taskId = resp.task_id;
watchTask(taskId);
})
.catch((err) => {
console.log('error when install plugin:', err);
setInstallError(err.message);
setPluginInstallStatus(PluginInstallStatus.ERROR);
});
} else if (installSource === 'local') {
httpClient
.installPluginFromLocal(installInfo.file)
.then((resp) => {
const taskId = resp.task_id;
watchTask(taskId);
})
.catch((err) => {
console.log('error when install plugin:', err);
setInstallError(err.message);
setPluginInstallStatus(PluginInstallStatus.ERROR);
});
} else if (installSource === 'marketplace') {
httpClient
.installPluginFromMarketplace(
installInfo.plugin_author,
installInfo.plugin_name,
installInfo.plugin_version,
)
.then((resp) => {
const taskId = resp.task_id;
watchTask(taskId);
});
}
}
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;
}
setModalOpen(true);
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
setInstallError(null);
installPlugin('local', { file });
},
[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} ${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]">
<TabsTrigger value="installed" className="px-6 py-4 cursor-pointer">
{t('plugins.installed')}
</TabsTrigger>
<TabsTrigger value="market" className="px-6 py-4 cursor-pointer">
{t('plugins.marketplace')}
</TabsTrigger>
</TabsList>
<div className="flex flex-row justify-end items-center">
{/* <Button
variant="outline"
className="px-6 py-4 cursor-pointer mr-2"
onClick={() => {
// setSortModalOpen(true);
}}
>
{t('plugins.arrange')}
</Button> */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" className="px-6 py-4 cursor-pointer">
<PlusIcon className="w-4 h-4" />
{t('plugins.install')}
<ChevronDownIcon className="ml-2 w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleFileSelect}>
<UploadIcon className="w-4 h-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setActiveTab('market');
}}
>
<StoreIcon className="w-4 h-4" />
{t('plugins.marketplace')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<TabsContent value="installed">
<PluginInstalledComponent ref={pluginInstalledRef} />
</TabsContent>
<TabsContent value="market">
<MarketPage
installPlugin={(plugin: PluginV4) => {
setInstallSource('marketplace');
setInstallInfo({
plugin_author: plugin.author,
plugin_name: plugin.name,
plugin_version: plugin.latest_version,
});
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
setModalOpen(true);
}}
/>
</TabsContent>
</Tabs>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="w-[500px] p-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-4">
<Download className="size-6" />
<span>{t('plugins.installPlugin')}</span>
</DialogTitle>
</DialogHeader>
{pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && (
<div className="mt-4">
<p className="mb-2">{t('plugins.onlySupportGithub')}</p>
<Input
placeholder={t('plugins.enterGithubLink')}
value={githubURL}
onChange={(e) => setGithubURL(e.target.value)}
className="mb-4"
/>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<div className="mt-4">
<p className="mb-2">
{t('plugins.askConfirm', {
name: installInfo.plugin_name,
version: installInfo.plugin_version,
})}
</p>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.INSTALLING && (
<div className="mt-4">
<p className="mb-2">{t('plugins.installing')}</p>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
<div className="mt-4">
<p className="mb-2">{t('plugins.installFailed')}</p>
<p className="mb-2 text-red-500">{installError}</p>
</div>
)}
<DialogFooter>
{(pluginInstallStatus === PluginInstallStatus.WAIT_INPUT ||
pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM) && (
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={() => handleModalConfirm()}>
{t('common.confirm')}
</Button>
</>
)}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
<Button variant="default" onClick={() => setModalOpen(false)}>
{t('common.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* 拖拽提示覆盖层 */}
{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}
onSortComplete={() => {
pluginInstalledRef.current?.refreshPluginList();
}}
/> */}
</div>
);
}