feat: plugin deletion and upgrade

This commit is contained in:
Junyan Qin
2025-08-17 18:07:51 +08:00
parent a0c42a5f6e
commit b176959836
22 changed files with 336 additions and 956 deletions

View File

@@ -18,15 +18,15 @@ import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { toast } from 'sonner';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
export interface PluginInstalledComponentRef {
refreshPluginList: () => void;
}
enum PluginRemoveStatus {
WAIT_INPUT = 'WAIT_INPUT',
REMOVING = 'REMOVING',
ERROR = 'ERROR',
enum PluginOperationType {
DELETE = 'DELETE',
UPDATE = 'UPDATE',
}
// eslint-disable-next-line react/display-name
@@ -38,15 +38,26 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
const [selectedPlugin, setSelectedPlugin] = useState<PluginCardVO | null>(
null,
);
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
const [pluginRemoveStatus, setPluginRemoveStatus] =
useState<PluginRemoveStatus>(PluginRemoveStatus.WAIT_INPUT);
const [pluginRemoveError, setPluginRemoveError] = useState<string | null>(
null,
);
const [pluginToDelete, setPluginToDelete] = useState<PluginCardVO | null>(
null,
const [showOperationModal, setShowOperationModal] = useState(false);
const [operationType, setOperationType] = useState<PluginOperationType>(
PluginOperationType.DELETE,
);
const [targetPlugin, setTargetPlugin] = useState<PluginCardVO | null>(null);
const asyncTask = useAsyncTask({
onSuccess: () => {
const successMessage =
operationType === PluginOperationType.DELETE
? t('plugins.deleteSuccess')
: t('plugins.updateSuccess');
toast.success(successMessage);
setShowOperationModal(false);
getPluginList();
},
onError: () => {
// Error is already handled in the hook state
},
});
useEffect(() => {
initData();
@@ -94,115 +105,139 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
}
function handlePluginDelete(plugin: PluginCardVO) {
setPluginToDelete(plugin);
setShowDeleteConfirmModal(true);
setPluginRemoveStatus(PluginRemoveStatus.WAIT_INPUT);
setTargetPlugin(plugin);
setOperationType(PluginOperationType.DELETE);
setShowOperationModal(true);
asyncTask.reset();
}
function deletePlugin() {
setPluginRemoveStatus(PluginRemoveStatus.REMOVING);
httpClient
.removePlugin(pluginToDelete!.author, pluginToDelete!.name)
function handlePluginUpdate(plugin: PluginCardVO) {
setTargetPlugin(plugin);
setOperationType(PluginOperationType.UPDATE);
setShowOperationModal(true);
asyncTask.reset();
}
function executeOperation() {
if (!targetPlugin) return;
const apiCall =
operationType === PluginOperationType.DELETE
? httpClient.removePlugin(targetPlugin.author, targetPlugin.name)
: httpClient.upgradePlugin(targetPlugin.author, targetPlugin.name);
apiCall
.then((res) => {
const taskId = res.task_id;
let alreadySuccess = false;
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((res) => {
if (res.runtime.done) {
clearInterval(interval);
if (res.runtime.exception) {
setPluginRemoveError(res.runtime.exception);
setPluginRemoveStatus(PluginRemoveStatus.ERROR);
} else {
// success
if (!alreadySuccess) {
toast.success('插件删除成功');
alreadySuccess = true;
}
setPluginRemoveStatus(PluginRemoveStatus.WAIT_INPUT);
setShowDeleteConfirmModal(false);
}
}
});
}, 1000);
asyncTask.startTask(res.task_id);
})
.catch((error) => {
setPluginRemoveError(error.message);
setPluginRemoveStatus(PluginRemoveStatus.ERROR);
const errorMessage =
operationType === PluginOperationType.DELETE
? t('plugins.deleteError') + error.message
: t('plugins.updateError') + error.message;
toast.error(errorMessage);
});
}
return (
<>
<Dialog
open={showDeleteConfirmModal}
open={showOperationModal}
onOpenChange={(open) => {
if (!open) {
setShowDeleteConfirmModal(false);
setPluginRemoveStatus(PluginRemoveStatus.WAIT_INPUT);
setPluginToDelete(null);
setShowOperationModal(false);
setTargetPlugin(null);
asyncTask.reset();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('plugins.deleteConfirm')}</DialogTitle>
<DialogTitle>
{operationType === PluginOperationType.DELETE
? t('plugins.deleteConfirm')
: t('plugins.updateConfirm')}
</DialogTitle>
</DialogHeader>
<DialogDescription>
{pluginRemoveStatus === PluginRemoveStatus.WAIT_INPUT && (
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<div>
{t('plugins.confirmDeletePlugin', {
author: pluginToDelete?.author ?? '',
name: pluginToDelete?.name ?? '',
})}
{operationType === PluginOperationType.DELETE
? t('plugins.confirmDeletePlugin', {
author: targetPlugin?.author ?? '',
name: targetPlugin?.name ?? '',
})
: t('plugins.confirmUpdatePlugin', {
author: targetPlugin?.author ?? '',
name: targetPlugin?.name ?? '',
})}
</div>
)}
{pluginRemoveStatus === PluginRemoveStatus.REMOVING && (
<div>{t('plugins.deleting')}</div>
)}
{pluginRemoveStatus === PluginRemoveStatus.ERROR && (
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<div>
{t('plugins.deleteError')}
<div className="text-red-500">{pluginRemoveError}</div>
{operationType === PluginOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</div>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<div>
{operationType === PluginOperationType.DELETE
? t('plugins.deleteError')
: t('plugins.updateError')}
<div className="text-red-500">{asyncTask.error}</div>
</div>
)}
</DialogDescription>
<DialogFooter>
{pluginRemoveStatus === PluginRemoveStatus.WAIT_INPUT && (
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant="outline"
onClick={() => {
setShowDeleteConfirmModal(false);
setPluginRemoveStatus(PluginRemoveStatus.WAIT_INPUT);
setPluginToDelete(null);
setShowOperationModal(false);
setTargetPlugin(null);
asyncTask.reset();
}}
>
{t('plugins.cancel')}
</Button>
)}
{pluginRemoveStatus === PluginRemoveStatus.WAIT_INPUT && (
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant="destructive"
variant={
operationType === PluginOperationType.DELETE
? 'destructive'
: 'default'
}
onClick={() => {
deletePlugin();
executeOperation();
}}
>
{t('plugins.confirmDelete')}
{operationType === PluginOperationType.DELETE
? t('plugins.confirmDelete')
: t('plugins.confirmUpdate')}
</Button>
)}
{pluginRemoveStatus === PluginRemoveStatus.REMOVING && (
<Button variant="destructive" disabled>
{t('plugins.deleting')}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<Button
variant={
operationType === PluginOperationType.DELETE
? 'destructive'
: 'default'
}
disabled
>
{operationType === PluginOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</Button>
)}
{pluginRemoveStatus === PluginRemoveStatus.ERROR && (
{asyncTask.status === AsyncTaskStatus.ERROR && (
<Button
variant="default"
onClick={() => {
setShowDeleteConfirmModal(false);
setPluginRemoveStatus(PluginRemoveStatus.WAIT_INPUT);
setShowOperationModal(false);
asyncTask.reset();
}}
>
{t('plugins.close')}
@@ -256,6 +291,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
cardVO={vo}
onCardClick={() => handlePluginClick(vo)}
onDeleteClick={() => handlePluginDelete(vo)}
onUpgradeClick={() => handlePluginUpdate(vo)}
/>
</div>
);

View File

@@ -14,6 +14,7 @@ import {
ExternalLink,
Ellipsis,
Trash,
ArrowUp,
} from 'lucide-react';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { PluginComponent } from '@/app/infra/entities/plugin';
@@ -22,17 +23,9 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
enum PluginRemoveStatus {
WAIT_INPUT = 'WAIT_INPUT',
REMOVING = 'REMOVING',
ERROR = 'ERROR',
}
function getComponentList(components: PluginComponent[], t: TFunction) {
const componentKindCount: Record<string, number> = {};
@@ -80,14 +73,17 @@ export default function PluginCardComponent({
cardVO,
onCardClick,
onDeleteClick,
onUpgradeClick,
}: {
cardVO: PluginCardVO;
onCardClick: () => void;
onDeleteClick: (cardVO: PluginCardVO) => void;
onUpgradeClick: (cardVO: PluginCardVO) => void;
}) {
const { t } = useTranslation();
const [enabled, setEnabled] = useState(cardVO.enabled);
const [switchEnable, setSwitchEnable] = useState(true);
const [dropdownOpen, setDropdownOpen] = useState(false);
function handleEnable(e: React.MouseEvent) {
e.stopPropagation(); // 阻止事件冒泡
@@ -212,18 +208,33 @@ export default function PluginCardComponent({
</div>
<div className="flex items-center justify-center">
<DropdownMenu>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost">
<Ellipsis className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{/**upgrade */}
{cardVO.install_source === 'marketplace' && (
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onUpgradeClick(cardVO);
setDropdownOpen(false);
}}
>
<ArrowUp className="w-4 h-4" />
<span>{t('plugins.update')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
onClick={(e) => {
onDeleteClick(cardVO);
e.stopPropagation();
onDeleteClick(cardVO);
setDropdownOpen(false);
}}
>
<Trash className="w-4 h-4" />

View File

@@ -4,14 +4,6 @@ import { Plugin } from '@/app/infra/entities/plugin';
import { httpClient } from '@/app/infra/http/HttpClient';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next';
@@ -60,7 +52,11 @@ export default function PluginForm({
};
if (!pluginInfo || !pluginConfig) {
return <div>{t('plugins.loading')}</div>;
return (
<div className="flex items-center justify-center h-full mb-[2rem]">
{t('plugins.loading')}
</div>
);
}
return (

View File

@@ -240,13 +240,6 @@ export class BackendClient extends BaseHttpClient {
return this.put('/api/v1/plugins/reorder', { plugins });
}
public updatePlugin(
author: string,
name: string,
): Promise<AsyncTaskCreatedResp> {
return this.post(`/api/v1/plugins/${author}/${name}/update`);
}
public installPluginFromGithub(
source: string,
): Promise<AsyncTaskCreatedResp> {
@@ -278,6 +271,13 @@ export class BackendClient extends BaseHttpClient {
return this.delete(`/api/v1/plugins/${author}/${name}`);
}
public upgradePlugin(
author: string,
name: string,
): Promise<AsyncTaskCreatedResp> {
return this.post(`/api/v1/plugins/${author}/${name}/upgrade`);
}
// ============ System API ============
public getSystemInfo(): Promise<ApiRespSystemInfo> {
return this.get('/api/v1/system/info');

View File

@@ -0,0 +1,99 @@
import { useState, useEffect, useRef } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { AsyncTask } from '@/app/infra/entities/api';
export enum AsyncTaskStatus {
WAIT_INPUT = 'WAIT_INPUT',
RUNNING = 'RUNNING',
SUCCESS = 'SUCCESS',
ERROR = 'ERROR',
}
export interface UseAsyncTaskOptions {
onSuccess?: () => void;
onError?: (error: string) => void;
pollInterval?: number;
}
export interface UseAsyncTaskResult {
status: AsyncTaskStatus;
error: string | null;
startTask: (taskId: number) => void;
reset: () => void;
}
export function useAsyncTask(
options: UseAsyncTaskOptions = {},
): UseAsyncTaskResult {
const { onSuccess, onError, pollInterval = 1000 } = options;
const [status, setStatus] = useState<AsyncTaskStatus>(
AsyncTaskStatus.WAIT_INPUT,
);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const alreadySuccessRef = useRef<boolean>(false);
const clearPollingInterval = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
const reset = () => {
clearPollingInterval();
setStatus(AsyncTaskStatus.WAIT_INPUT);
setError(null);
alreadySuccessRef.current = false;
};
const startTask = (taskId: number) => {
setStatus(AsyncTaskStatus.RUNNING);
setError(null);
alreadySuccessRef.current = false;
const interval = setInterval(() => {
httpClient
.getAsyncTask(taskId)
.then((res: AsyncTask) => {
if (res.runtime.done) {
clearPollingInterval();
if (res.runtime.exception) {
setError(res.runtime.exception);
setStatus(AsyncTaskStatus.ERROR);
onError?.(res.runtime.exception);
} else {
if (!alreadySuccessRef.current) {
alreadySuccessRef.current = true;
setStatus(AsyncTaskStatus.SUCCESS);
onSuccess?.();
}
}
}
})
.catch((error) => {
clearPollingInterval();
const errorMessage = error.message || 'Unknown error';
setError(errorMessage);
setStatus(AsyncTaskStatus.ERROR);
onError?.(errorMessage);
});
}, pollInterval);
intervalRef.current = interval;
};
useEffect(() => {
return () => {
clearPollingInterval();
};
}, []);
return {
status,
error,
startTask,
reset,
};
}

View File

@@ -175,6 +175,7 @@ const enUS = {
deleteError: 'Delete failed: ',
close: 'Close',
deleteConfirm: 'Delete Confirmation',
deleteSuccess: 'Delete successful',
modifyFailed: 'Modify failed: ',
eventCount: 'Events: {{count}}',
toolCount: 'Tools: {{count}}',
@@ -193,6 +194,17 @@ const enUS = {
fromGithub: 'From GitHub',
fromLocal: 'From Local',
fromMarketplace: 'From Marketplace',
componentsList: 'Components: ',
noComponents: 'No components',
delete: 'Delete Plugin',
update: 'Update Plugin',
updateConfirm: 'Update Confirmation',
confirmUpdatePlugin:
'Are you sure you want to update the plugin ({{author}}/{{name}})?',
confirmUpdate: 'Confirm Update',
updating: 'Updating...',
updateSuccess: 'Plugin updated successfully',
updateError: 'Update failed: ',
},
market: {
searchPlaceholder: 'Search plugins...',

View File

@@ -175,6 +175,7 @@ const jaJP = {
deleteError: '削除に失敗しました:',
close: '閉じる',
deleteConfirm: '削除の確認',
deleteSuccess: '削除に成功しました',
modifyFailed: '変更に失敗しました:',
eventCount: 'イベント:{{count}}',
toolCount: 'ツール:{{count}}',
@@ -193,6 +194,17 @@ const jaJP = {
fromGithub: 'GitHubから',
fromLocal: 'ローカルから',
fromMarketplace: 'プラグインマーケットから',
componentsList: '部品:',
noComponents: '部品がありません',
delete: 'プラグインを削除',
update: 'プラグインを更新',
updateConfirm: '更新の確認',
confirmUpdatePlugin:
'プラグイン「{{author}}/{{name}}」を更新してもよろしいですか?',
confirmUpdate: '更新を確認',
updating: '更新中...',
updateSuccess: 'プラグインの更新に成功しました',
updateError: '更新に失敗しました:',
},
market: {
searchPlaceholder: 'プラグインを検索...',

View File

@@ -171,6 +171,7 @@ const zhHans = {
deleteError: '删除失败:',
close: '关闭',
deleteConfirm: '删除确认',
deleteSuccess: '删除成功',
modifyFailed: '修改失败:',
eventCount: '事件:{{count}}',
toolCount: '工具:{{count}}',
@@ -190,7 +191,14 @@ const zhHans = {
fromMarketplace: '来自市场',
componentsList: '组件: ',
noComponents: '无组件',
delete: '删除',
delete: '删除插件',
update: '更新插件',
updateConfirm: '更新确认',
confirmUpdatePlugin: '你确定要更新插件({{author}}/{{name}})吗?',
confirmUpdate: '确认更新',
updating: '更新中...',
updateSuccess: '插件更新成功',
updateError: '更新失败:',
},
market: {
searchPlaceholder: '搜索插件...',