mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-09 23:36:02 +00:00
feat: plugin deletion and upgrade
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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');
|
||||
|
||||
99
web/src/hooks/useAsyncTask.ts
Normal file
99
web/src/hooks/useAsyncTask.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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...',
|
||||
|
||||
@@ -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: 'プラグインを検索...',
|
||||
|
||||
@@ -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: '搜索插件...',
|
||||
|
||||
Reference in New Issue
Block a user