refactor(plugins): enhance plugin configuration handling and improve error messages

This commit is contained in:
LTbinglingfeng
2026-06-13 05:20:30 +08:00
Unverified
parent 227a5679ad
commit 6bca83f70a
6 changed files with 79 additions and 43 deletions
+60 -41
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -21,7 +21,12 @@ import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { pluginsApi } from '@/services/api'; import { pluginsApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { getErrorMessage, isRecord } from '@/utils/helpers'; import { getErrorMessage, isRecord } from '@/utils/helpers';
import type { PluginConfigField, PluginListEntry, PluginListResponse } from '@/types'; import type {
PluginConfigField,
PluginConfigObject,
PluginListEntry,
PluginListResponse,
} from '@/types';
import { getPluginTitle, resolvePluginAssetURL } from './pluginResources'; import { getPluginTitle, resolvePluginAssetURL } from './pluginResources';
import styles from './PluginsPage.module.scss'; import styles from './PluginsPage.module.scss';
@@ -45,29 +50,11 @@ function PluginCardLogo({ src }: { src: string }) {
); );
} }
const cloneRecord = (value: unknown): Record<string, unknown> =>
isRecord(value) ? { ...value } : {};
const hasStatus = (error: unknown, status: number) => const hasStatus = (error: unknown, status: number) =>
isRecord(error) && error.status === status; isRecord(error) && error.status === status;
const normalizeFieldType = (field: PluginConfigField) => field.type.trim().toLowerCase(); const normalizeFieldType = (field: PluginConfigField) => field.type.trim().toLowerCase();
const getPluginsConfigMap = (rawConfig: Record<string, unknown>): Record<string, unknown> => {
const plugins = rawConfig.plugins;
if (!isRecord(plugins)) return {};
const configs = plugins.configs;
return isRecord(configs) ? configs : {};
};
const getPluginRawConfig = (
rawConfig: Record<string, unknown>,
pluginID: string
): Record<string, unknown> => {
const configs = getPluginsConfigMap(rawConfig);
return cloneRecord(configs[pluginID]);
};
const stringifyArrayItem = (value: unknown): string => { const stringifyArrayItem = (value: unknown): string => {
if (value === undefined || value === null) return ''; if (value === undefined || value === null) return '';
if (typeof value === 'string') return value; if (typeof value === 'string') return value;
@@ -98,7 +85,7 @@ const getFieldDraftValue = (field: PluginConfigField, value: unknown): PluginDra
const buildDraft = ( const buildDraft = (
plugin: PluginListEntry, plugin: PluginListEntry,
currentConfig: Record<string, unknown> currentConfig: PluginConfigObject
): PluginConfigDraft => { ): PluginConfigDraft => {
const enabled = typeof currentConfig.enabled === 'boolean' ? currentConfig.enabled : plugin.enabled; const enabled = typeof currentConfig.enabled === 'boolean' ? currentConfig.enabled : plugin.enabled;
const priority = const priority =
@@ -146,11 +133,11 @@ const parseJSONField = (
const buildConfigPayload = ( const buildConfigPayload = (
draft: PluginConfigDraft, draft: PluginConfigDraft,
fields: PluginConfigField[], fields: PluginConfigField[],
currentConfig: Record<string, unknown>, currentConfig: PluginConfigObject,
t: (key: string, options?: Record<string, unknown>) => string t: (key: string, options?: Record<string, unknown>) => string
) => { ) => {
const errors: Record<string, string> = {}; const errors: Record<string, string> = {};
const nextConfig: Record<string, unknown> = { ...currentConfig }; const nextConfig: PluginConfigObject = { ...currentConfig };
const priorityText = draft.priority.trim(); const priorityText = draft.priority.trim();
nextConfig.enabled = draft.enabled; nextConfig.enabled = draft.enabled;
@@ -235,18 +222,19 @@ export function PluginsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const apiBase = useAuthStore((state) => state.apiBase); const apiBase = useAuthStore((state) => state.apiBase);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearConfigCache = useConfigStore((state) => state.clearCache); const clearConfigCache = useConfigStore((state) => state.clearCache);
const showNotification = useNotificationStore((state) => state.showNotification); const showNotification = useNotificationStore((state) => state.showNotification);
const [data, setData] = useState<PluginListResponse | null>(null); const [data, setData] = useState<PluginListResponse | null>(null);
const [rawConfig, setRawConfig] = useState<Record<string, unknown>>({});
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [editingPlugin, setEditingPlugin] = useState<PluginListEntry | null>(null); const [editingPlugin, setEditingPlugin] = useState<PluginListEntry | null>(null);
const [editingConfig, setEditingConfig] = useState<PluginConfigObject>({});
const [draft, setDraft] = useState<PluginConfigDraft | null>(null); const [draft, setDraft] = useState<PluginConfigDraft | null>(null);
const [mutatingID, setMutatingID] = useState(''); const [mutatingID, setMutatingID] = useState('');
const [openingConfigID, setOpeningConfigID] = useState('');
const configRequestSeq = useRef(0);
const connected = connectionStatus === 'connected'; const connected = connectionStatus === 'connected';
@@ -260,12 +248,8 @@ export function PluginsPage() {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const [plugins, config] = await Promise.all([ const plugins = await pluginsApi.list();
pluginsApi.list(),
fetchConfig(undefined, true).catch(() => null),
]);
setData(plugins); setData(plugins);
setRawConfig(config?.raw ?? {});
} catch (err: unknown) { } catch (err: unknown) {
setError( setError(
hasStatus(err, 404) hasStatus(err, 404)
@@ -275,7 +259,7 @@ export function PluginsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [connected, fetchConfig, t]); }, [connected, t]);
useHeaderRefresh(loadPlugins, connected); useHeaderRefresh(loadPlugins, connected);
@@ -320,15 +304,48 @@ export function PluginsPage() {
[apiBase] [apiBase]
); );
const openConfigSheet = (plugin: PluginListEntry) => { const openConfigSheet = async (plugin: PluginListEntry) => {
const currentConfig = getPluginRawConfig(rawConfig, plugin.id); if (openingConfigID || mutatingID) return;
const requestSeq = configRequestSeq.current + 1;
configRequestSeq.current = requestSeq;
setOpeningConfigID(plugin.id);
setEditingPlugin(plugin); setEditingPlugin(plugin);
setDraft(buildDraft(plugin, currentConfig)); setEditingConfig({});
setDraft(null);
try {
const currentConfig = await pluginsApi.getConfig(plugin.id);
if (configRequestSeq.current !== requestSeq) return;
setEditingConfig(currentConfig);
setDraft(buildDraft(plugin, currentConfig));
} catch (err: unknown) {
if (configRequestSeq.current !== requestSeq) return;
setEditingPlugin(null);
setEditingConfig({});
setDraft(null);
showNotification(
hasStatus(err, 404)
? t('plugin_management.config_not_found')
: `${t('plugin_management.config_load_failed')}: ${getErrorMessage(
err,
t('plugin_management.config_load_failed')
)}`,
'error'
);
} finally {
if (configRequestSeq.current === requestSeq) {
setOpeningConfigID('');
}
}
}; };
const closeConfigSheet = () => { const closeConfigSheet = () => {
if (mutatingID) return; if (mutatingID || openingConfigID) return;
setEditingPlugin(null); setEditingPlugin(null);
setEditingConfig({});
setDraft(null); setDraft(null);
}; };
@@ -357,12 +374,11 @@ export function PluginsPage() {
}; };
const handleSaveConfig = async () => { const handleSaveConfig = async () => {
if (!editingPlugin || !draft) return; if (!editingPlugin || !draft || openingConfigID || mutatingID) return;
const currentConfig = getPluginRawConfig(rawConfig, editingPlugin.id);
const { nextConfig, errors } = buildConfigPayload( const { nextConfig, errors } = buildConfigPayload(
draft, draft,
editingPlugin.configFields, editingPlugin.configFields,
currentConfig, editingConfig,
t t
); );
@@ -378,6 +394,7 @@ export function PluginsPage() {
clearConfigCache(); clearConfigCache();
await loadPlugins(); await loadPlugins();
setEditingPlugin(null); setEditingPlugin(null);
setEditingConfig({});
setDraft(null); setDraft(null);
showNotification(t('plugin_management.save_success'), 'success'); showNotification(t('plugin_management.save_success'), 'success');
} catch (err: unknown) { } catch (err: unknown) {
@@ -706,7 +723,8 @@ export function PluginsPage() {
{visiblePlugins.map((plugin) => { {visiblePlugins.map((plugin) => {
const logo = resolvePluginAsset(plugin.logo || plugin.metadata?.logo || ''); const logo = resolvePluginAsset(plugin.logo || plugin.metadata?.logo || '');
const github = plugin.metadata?.githubRepository.trim(); const github = plugin.metadata?.githubRepository.trim();
const mutating = mutatingID === plugin.id; const openingConfig = openingConfigID === plugin.id;
const actionBusy = Boolean(mutatingID || openingConfigID);
const version = plugin.metadata?.version; const version = plugin.metadata?.version;
const author = plugin.metadata?.author; const author = plugin.metadata?.author;
@@ -780,14 +798,15 @@ export function PluginsPage() {
<ToggleSwitch <ToggleSwitch
checked={plugin.enabled} checked={plugin.enabled}
onChange={(enabled) => handleTogglePlugin(plugin, enabled)} onChange={(enabled) => handleTogglePlugin(plugin, enabled)}
disabled={!connected || mutating} disabled={!connected || actionBusy}
ariaLabel={t('plugin_management.enabled')} ariaLabel={t('plugin_management.enabled')}
/> />
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => openConfigSheet(plugin)} onClick={() => openConfigSheet(plugin)}
disabled={!connected} disabled={!connected || actionBusy}
loading={openingConfig}
> >
<IconSettings size={14} /> <IconSettings size={14} />
{t('plugin_management.edit_config')} {t('plugin_management.edit_config')}
+2
View File
@@ -1105,6 +1105,8 @@
"description": "Review discovered and registered plugins, then manage instance toggles, config fields, and resource links.", "description": "Review discovered and registered plugins, then manage instance toggles, config fields, and resource links.",
"refresh": "Refresh", "refresh": "Refresh",
"load_failed": "Failed to load plugins", "load_failed": "Failed to load plugins",
"config_load_failed": "Failed to read plugin config",
"config_not_found": "Unable to read plugin config because the backend could not find this plugin.",
"unsupported_backend": "The current backend does not expose the plugin management API. Use a newer backend build that includes plugin management endpoints, then restart the service.", "unsupported_backend": "The current backend does not expose the plugin management API. Use a newer backend build that includes plugin management endpoints, then restart the service.",
"global_status": "Global status", "global_status": "Global status",
"global_enabled": "Enabled", "global_enabled": "Enabled",
+2
View File
@@ -1105,6 +1105,8 @@
"description": "查看已发现和已注册的插件,管理实例开关、配置字段和资源入口。", "description": "查看已发现和已注册的插件,管理实例开关、配置字段和资源入口。",
"refresh": "刷新", "refresh": "刷新",
"load_failed": "加载插件失败", "load_failed": "加载插件失败",
"config_load_failed": "读取插件配置失败",
"config_not_found": "无法读取插件配置,后端未找到该插件。",
"unsupported_backend": "当前后端未暴露插件管理 API。请使用包含插件管理接口的新后端构建,并重启服务。", "unsupported_backend": "当前后端未暴露插件管理 API。请使用包含插件管理接口的新后端构建,并重启服务。",
"global_status": "全局状态", "global_status": "全局状态",
"global_enabled": "已启用", "global_enabled": "已启用",
+2
View File
@@ -1131,6 +1131,8 @@
"description": "查看已發現和已註冊的插件,管理實例開關、設定欄位和資源入口。", "description": "查看已發現和已註冊的插件,管理實例開關、設定欄位和資源入口。",
"refresh": "重新整理", "refresh": "重新整理",
"load_failed": "載入插件失敗", "load_failed": "載入插件失敗",
"config_load_failed": "讀取插件設定失敗",
"config_not_found": "無法讀取插件設定,後端未找到該插件。",
"unsupported_backend": "目前後端未暴露插件管理 API。請使用包含插件管理介面的新版後端建置,並重新啟動服務。", "unsupported_backend": "目前後端未暴露插件管理 API。請使用包含插件管理介面的新版後端建置,並重新啟動服務。",
"global_status": "全域狀態", "global_status": "全域狀態",
"global_enabled": "已啟用", "global_enabled": "已啟用",
+11 -2
View File
@@ -2,6 +2,7 @@ import { apiClient } from './client';
import { isRecord } from '@/utils/helpers'; import { isRecord } from '@/utils/helpers';
import type { import type {
PluginConfigField, PluginConfigField,
PluginConfigObject,
PluginListEntry, PluginListEntry,
PluginListResponse, PluginListResponse,
PluginMetadata, PluginMetadata,
@@ -114,6 +115,9 @@ const normalizePluginList = (value: unknown): PluginListResponse => {
}; };
}; };
const normalizePluginConfig = (value: unknown): PluginConfigObject =>
isRecord(value) ? { ...value } : {};
const normalizeStoreEntry = (value: unknown): PluginStoreEntry | null => { const normalizeStoreEntry = (value: unknown): PluginStoreEntry | null => {
if (!isRecord(value)) return null; if (!isRecord(value)) return null;
const id = asString(value.id).trim(); const id = asString(value.id).trim();
@@ -179,10 +183,15 @@ export const pluginsApi = {
updateEnabled: (id: string, enabled: boolean) => updateEnabled: (id: string, enabled: boolean) =>
apiClient.patch(`/plugins/${encodeURIComponent(id)}/enabled`, { enabled }), apiClient.patch(`/plugins/${encodeURIComponent(id)}/enabled`, { enabled }),
putConfig: (id: string, config: Record<string, unknown>) => async getConfig(id: string): Promise<PluginConfigObject> {
const data = await apiClient.get(`/plugins/${encodeURIComponent(id)}/config`);
return normalizePluginConfig(data);
},
putConfig: (id: string, config: PluginConfigObject) =>
apiClient.put(`/plugins/${encodeURIComponent(id)}/config`, config), apiClient.put(`/plugins/${encodeURIComponent(id)}/config`, config),
patchConfig: (id: string, patch: Record<string, unknown>) => patchConfig: (id: string, patch: PluginConfigObject) =>
apiClient.patch(`/plugins/${encodeURIComponent(id)}/config`, patch), apiClient.patch(`/plugins/${encodeURIComponent(id)}/config`, patch),
}; };
+2
View File
@@ -14,6 +14,8 @@ export interface PluginConfigField {
description: string; description: string;
} }
export type PluginConfigObject = Record<string, unknown>;
export interface PluginMetadata { export interface PluginMetadata {
name: string; name: string;
version: string; version: string;