feat(plugins): implement plugin deletion functionality with confirmation and error handling

This commit is contained in:
LTbinglingfeng
2026-06-13 22:42:16 +08:00
Unverified
parent fa93d2e77b
commit b9b45e9034
7 changed files with 123 additions and 5 deletions
+70 -5
View File
@@ -64,6 +64,12 @@ function PluginCardLogo({ src }: { src: string }) {
const hasStatus = (error: unknown, status: number) =>
isRecord(error) && error.status === status;
const hasRestartRequired = (value: unknown) =>
isRecord(value) && value.restart_required === true;
const hasRestartRequiredError = (error: unknown) =>
isRecord(error) && (hasRestartRequired(error.details) || hasRestartRequired(error.data));
const normalizeFieldType = (field: PluginConfigField) => field.type.trim().toLowerCase();
const stringifyArrayItem = (value: unknown): string => {
@@ -235,6 +241,7 @@ export function PluginsPage() {
const apiBase = useAuthStore((state) => state.apiBase);
const clearConfigCache = useConfigStore((state) => state.clearCache);
const showNotification = useNotificationStore((state) => state.showNotification);
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
const [data, setData] = useState<PluginListResponse | null>(null);
const [filter, setFilter] = useState('');
@@ -244,6 +251,7 @@ export function PluginsPage() {
const [editingConfig, setEditingConfig] = useState<PluginConfigObject>({});
const [draft, setDraft] = useState<PluginConfigDraft | null>(null);
const [mutatingID, setMutatingID] = useState('');
const [deletingID, setDeletingID] = useState('');
const [openingConfigID, setOpeningConfigID] = useState('');
const configRequestSeq = useRef(0);
@@ -326,7 +334,7 @@ export function PluginsPage() {
);
const openConfigSheet = async (plugin: PluginListEntry) => {
if (openingConfigID || mutatingID) return;
if (openingConfigID || mutatingID || deletingID) return;
const requestSeq = configRequestSeq.current + 1;
configRequestSeq.current = requestSeq;
@@ -364,7 +372,7 @@ export function PluginsPage() {
};
const closeConfigSheet = () => {
if (mutatingID || openingConfigID) return;
if (mutatingID || openingConfigID || deletingID) return;
setEditingPlugin(null);
setEditingConfig({});
setDraft(null);
@@ -375,6 +383,7 @@ export function PluginsPage() {
};
const handleTogglePlugin = async (plugin: PluginListEntry, enabled: boolean) => {
if (deletingID) return;
setMutatingID(plugin.id);
try {
await pluginsApi.updateEnabled(plugin.id, enabled);
@@ -395,8 +404,51 @@ export function PluginsPage() {
}
};
const handleDeletePlugin = (plugin: PluginListEntry) => {
if (!connected || mutatingID || openingConfigID || deletingID) return;
const name = getPluginTitle(plugin);
showConfirmation({
title: t('plugin_management.delete_confirm_title'),
message: t('plugin_management.delete_confirm_message', { name, id: plugin.id }),
variant: 'danger',
confirmText: t('plugin_management.delete_plugin'),
onConfirm: async () => {
setDeletingID(plugin.id);
setMutatingID(plugin.id);
try {
const result = await pluginsApi.deletePlugin(plugin.id);
clearConfigCache();
if (editingPlugin?.id === plugin.id) {
setEditingPlugin(null);
setEditingConfig({});
setDraft(null);
}
await loadPluginsAfterMutation(false);
notifyPluginResourcesChanged();
showNotification(t('plugin_management.delete_success'), 'success');
if (result.restartRequired) {
showNotification(t('plugin_management.delete_restart_required'), 'warning');
}
} catch (err: unknown) {
const restartRequired = hasRestartRequiredError(err);
const fallback = restartRequired
? t('plugin_management.delete_restart_required')
: t('plugin_management.delete_failed');
showNotification(
`${t('plugin_management.delete_failed')}: ${getErrorMessage(err, fallback)}`,
restartRequired ? 'warning' : 'error'
);
} finally {
setDeletingID('');
setMutatingID('');
}
},
});
};
const handleSaveConfig = async () => {
if (!editingPlugin || !draft || openingConfigID || mutatingID) return;
if (!editingPlugin || !draft || openingConfigID || mutatingID || deletingID) return;
const { nextConfig, errors } = buildConfigPayload(
draft,
editingPlugin.configFields,
@@ -707,7 +759,7 @@ export function PluginsPage() {
variant="secondary"
size="sm"
onClick={loadPlugins}
disabled={!connected || loading || Boolean(mutatingID)}
disabled={!connected || loading || Boolean(mutatingID || deletingID)}
loading={loading}
>
<IconRefreshCw size={16} />
@@ -749,7 +801,8 @@ export function PluginsPage() {
const logo = resolvePluginAsset(plugin.logo || plugin.metadata?.logo || '');
const github = plugin.metadata?.githubRepository.trim();
const openingConfig = openingConfigID === plugin.id;
const actionBusy = Boolean(mutatingID || openingConfigID);
const deletingPlugin = deletingID === plugin.id;
const actionBusy = Boolean(mutatingID || openingConfigID || deletingID);
const version = plugin.metadata?.version;
const author = plugin.metadata?.author;
@@ -836,6 +889,18 @@ export function PluginsPage() {
<IconSettings size={14} />
{t('plugin_management.edit_config')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => handleDeletePlugin(plugin)}
disabled={!connected || actionBusy}
loading={deletingPlugin}
title={t('plugin_management.delete_plugin')}
aria-label={t('plugin_management.delete_plugin')}
>
<IconTrash2 size={14} />
{t('plugin_management.delete_plugin')}
</Button>
{github ? (
<a
className={styles.iconLink}
+6
View File
@@ -1149,6 +1149,12 @@
"array_item_placeholder": "Enter array item",
"add_array_item": "Add array item",
"remove_array_item": "Remove array item",
"delete_plugin": "Delete",
"delete_confirm_title": "Delete plugin",
"delete_confirm_message": "Delete {{name}} ({{id}})? This removes the local plugin file and saved config.",
"delete_success": "Plugin deleted",
"delete_failed": "Failed to delete plugin",
"delete_restart_required": "The loaded plugin cannot be removed until the backend restarts.",
"toggle_success": "Plugin status updated",
"toggle_failed": "Failed to update plugin status",
"save_success": "Plugin config saved",
+8
View File
@@ -1098,6 +1098,8 @@
"description": "Просматривайте найденные и зарегистрированные плагины, управляйте переключателями экземпляров, полями конфигурации и ссылками на ресурсы.",
"refresh": "Обновить",
"load_failed": "Не удалось загрузить плагины",
"config_load_failed": "Не удалось прочитать конфигурацию плагина",
"config_not_found": "Не удалось прочитать конфигурацию плагина: backend не нашёл этот плагин.",
"unsupported_backend": "Текущий backend не предоставляет API управления плагинами. Используйте более новую сборку backend с эндпоинтами управления плагинами и перезапустите сервис.",
"global_status": "Глобальный статус",
"global_enabled": "Включено",
@@ -1134,6 +1136,12 @@
"array_item_placeholder": "Введите элемент массива",
"add_array_item": "Добавить элемент массива",
"remove_array_item": "Удалить элемент массива",
"delete_plugin": "Удалить",
"delete_confirm_title": "Удалить плагин",
"delete_confirm_message": "Удалить {{name}} ({{id}})? Будут удалены локальный файл плагина и сохранённая конфигурация.",
"delete_success": "Плагин удалён",
"delete_failed": "Не удалось удалить плагин",
"delete_restart_required": "Загруженный плагин можно удалить только после перезапуска backend.",
"toggle_success": "Статус плагина обновлён",
"toggle_failed": "Не удалось обновить статус плагина",
"save_success": "Конфигурация плагина сохранена",
+6
View File
@@ -1149,6 +1149,12 @@
"array_item_placeholder": "输入数组项",
"add_array_item": "添加数组项",
"remove_array_item": "删除数组项",
"delete_plugin": "删除",
"delete_confirm_title": "删除插件",
"delete_confirm_message": "确定删除 {{name}}{{id}})吗?这会移除本地插件文件和已保存配置。",
"delete_success": "插件已删除",
"delete_failed": "插件删除失败",
"delete_restart_required": "已加载的插件需要重启后端后才能移除。",
"toggle_success": "插件状态已更新",
"toggle_failed": "插件状态更新失败",
"save_success": "插件配置已保存",
+6
View File
@@ -1175,6 +1175,12 @@
"array_item_placeholder": "輸入陣列項目",
"add_array_item": "新增陣列項目",
"remove_array_item": "刪除陣列項目",
"delete_plugin": "刪除",
"delete_confirm_title": "刪除插件",
"delete_confirm_message": "確定刪除 {{name}}{{id}})嗎?這會移除本機插件檔案和已儲存設定。",
"delete_success": "插件已刪除",
"delete_failed": "插件刪除失敗",
"delete_restart_required": "已載入的插件需要重新啟動後端後才能移除。",
"toggle_success": "插件狀態已更新",
"toggle_failed": "插件狀態更新失敗",
"save_success": "插件設定已儲存",
+18
View File
@@ -3,6 +3,7 @@ import { isRecord } from '@/utils/helpers';
import type {
PluginConfigField,
PluginConfigObject,
PluginDeleteResult,
PluginListEntry,
PluginListResponse,
PluginMetadata,
@@ -118,6 +119,18 @@ const normalizePluginList = (value: unknown): PluginListResponse => {
const normalizePluginConfig = (value: unknown): PluginConfigObject =>
isRecord(value) ? { ...value } : {};
const normalizeDeleteResult = (value: unknown): PluginDeleteResult => {
const source = isRecord(value) ? value : {};
return {
status: asString(source.status).trim(),
id: asString(source.id).trim(),
path: asString(source.path).trim(),
fileDeleted: asBoolean(source.file_deleted),
configuredRemoved: asBoolean(source.configured_removed),
restartRequired: asBoolean(source.restart_required),
};
};
const normalizeStoreEntry = (value: unknown): PluginStoreEntry | null => {
if (!isRecord(value)) return null;
const id = asString(value.id).trim();
@@ -183,6 +196,11 @@ export const pluginsApi = {
updateEnabled: (id: string, enabled: boolean) =>
apiClient.patch(`/plugins/${encodeURIComponent(id)}/enabled`, { enabled }),
async deletePlugin(id: string): Promise<PluginDeleteResult> {
const data = await apiClient.delete(`/plugins/${encodeURIComponent(id)}`);
return normalizeDeleteResult(data);
},
async getConfig(id: string): Promise<PluginConfigObject> {
const data = await apiClient.get(`/plugins/${encodeURIComponent(id)}/config`);
return normalizePluginConfig(data);
+9
View File
@@ -51,6 +51,15 @@ export interface PluginListResponse {
plugins: PluginListEntry[];
}
export interface PluginDeleteResult {
status: string;
id: string;
path: string;
fileDeleted: boolean;
configuredRemoved: boolean;
restartRequired: boolean;
}
export interface PluginStoreEntry {
id: string;
name: string;