feat: add plugin management feature with internationalization support

- Introduced new plugin management section in the application.
- Added translations for English, Russian, Simplified Chinese, and Traditional Chinese.
- Created new API endpoints for managing plugins, including listing, enabling/disabling, and configuring plugins.
- Updated routing to include a dedicated PluginsPage.
- Defined new types for plugin configuration and metadata in TypeScript.
- Enhanced the existing API client to handle plugin-related requests.
This commit is contained in:
LTbinglingfeng
2026-06-11 01:45:10 +08:00
Unverified
parent 4586b1dec2
commit 947f9f880a
13 changed files with 1813 additions and 3 deletions
+11 -3
View File
@@ -18,6 +18,7 @@ import {
IconSidebarDashboard,
IconSidebarLogs,
IconSidebarOauth,
IconSidebarPlugins,
IconSidebarProviders,
IconSidebarQuota,
IconSidebarSystem,
@@ -41,6 +42,7 @@ const sidebarIcons: Record<string, ReactNode> = {
authFiles: <IconSidebarAuthFiles size={18} />,
oauth: <IconSidebarOauth size={18} />,
quota: <IconSidebarQuota size={18} />,
plugins: <IconSidebarPlugins size={18} />,
config: <IconSidebarConfig size={18} />,
logs: <IconSidebarLogs size={18} />,
system: <IconSidebarSystem size={18} />,
@@ -235,7 +237,7 @@ export function MainLayout() {
const isLogsPage = location.pathname.startsWith('/logs');
const showSidebarLabels = !sidebarCollapsed || sidebarOpen;
// 将顶部悬浮控制区高度写入 CSS 变量,供移动端粘性元素和浮层避让。
// Keep floating header height available to sticky mobile elements and overlays.
useLayoutEffect(() => {
const updateHeaderHeight = () => {
const height = headerRef.current?.offsetHeight;
@@ -264,7 +266,7 @@ export function MainLayout() {
};
}, []);
// 将主内容区的中心点写入 CSS 变量,供底部浮层(配置面板操作栏、提供商导航)对齐到内容区
// Keep the content center available to bottom overlays that align with the main area.
useLayoutEffect(() => {
const updateContentCenter = () => {
const el = contentRef.current;
@@ -379,7 +381,7 @@ export function MainLayout() {
useEffect(() => {
fetchConfig().catch(() => {
// ignore initial failure; login flow会提示
// Ignore the initial failure; the login flow shows the user-facing prompt.
});
}, [fetchConfig]);
@@ -448,6 +450,12 @@ export function MainLayout() {
metaKey: 'nav_meta.config_management',
icon: sidebarIcons.config,
},
{
path: '/plugins',
labelKey: 'nav.plugins',
metaKey: 'nav_meta.plugins',
icon: sidebarIcons.plugins,
},
{
path: '/system',
labelKey: 'nav.system_info',
+22
View File
@@ -130,6 +130,17 @@ export function IconSettings({ size = 20, ...props }: IconProps) {
);
}
export function IconPlug({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M12 22v-5" />
<path d="M9 8V2" />
<path d="M15 8V2" />
<path d="M6 8h12v4a6 6 0 0 1-12 0Z" />
</svg>
);
}
export function IconScrollText({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
@@ -463,6 +474,17 @@ export function IconSidebarConfig({ size = 20, ...props }: IconProps) {
);
}
export function IconSidebarPlugins({ size = 20, ...props }: IconProps) {
return (
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
<path d="M12 22v-5" />
<path d="M9 8V2" />
<path d="M15 8V2" />
<path d="M6 8h12v4a6 6 0 0 1-12 0Z" />
</svg>
);
}
export function IconSidebarProviders({ size = 20, ...props }: IconProps) {
return (
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
@@ -0,0 +1,530 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.page {
display: flex;
flex-direction: column;
gap: $spacing-lg;
width: 100%;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: $spacing-md;
:global(.btn > span) {
display: inline-flex;
align-items: center;
gap: 8px;
}
@include mobile {
flex-direction: column;
align-items: stretch;
}
}
.title {
margin: 0;
color: var(--text-primary);
font-size: 28px;
font-weight: 700;
line-height: 1.2;
}
.description {
margin: $spacing-sm 0 0;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.5;
}
.errorBox,
.warningBox {
padding: $spacing-md;
border-radius: $radius-md;
font-size: 14px;
line-height: 1.5;
}
.errorBox {
border: 1px solid var(--danger-color);
background: rgba($error-color, 0.1);
color: var(--danger-color);
}
.warningBox {
border: 1px solid color-mix(in srgb, var(--warning-color, #c65746) 42%, var(--border-color));
background: color-mix(in srgb, var(--warning-color, #c65746) 9%, var(--bg-secondary));
color: var(--text-primary);
}
.statsGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: $spacing-md;
@media (max-width: 1100px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@include mobile {
grid-template-columns: 1fr;
}
}
.statTile {
display: flex;
min-width: 0;
min-height: 76px;
flex-direction: column;
justify-content: center;
gap: 6px;
padding: $spacing-md;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: color-mix(in srgb, var(--bg-secondary) 84%, transparent);
span {
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
}
strong {
min-width: 0;
color: var(--text-primary);
font-size: 20px;
line-height: 1.2;
overflow-wrap: anywhere;
}
}
.toolbar {
display: flex;
max-width: 520px;
:global(.form-group) {
width: 100%;
margin: 0;
}
:global(.input) {
padding-right: 36px;
}
@include mobile {
max-width: none;
}
}
.pluginGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: $spacing-md;
@include mobile {
grid-template-columns: 1fr;
}
}
.pluginCard {
display: flex;
min-width: 0;
min-height: 260px;
flex-direction: column;
gap: $spacing-md;
padding: $spacing-lg;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-primary);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.04);
}
.skeletonCard {
min-height: 260px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background:
linear-gradient(
90deg,
color-mix(in srgb, var(--bg-secondary) 86%, transparent) 25%,
color-mix(in srgb, var(--bg-hover) 70%, transparent) 37%,
color-mix(in srgb, var(--bg-secondary) 86%, transparent) 63%
);
background-size: 400% 100%;
animation: skeletonPulse 1.35s ease-in-out infinite;
}
@keyframes skeletonPulse {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
.cardHeader {
display: grid;
grid-template-columns: 48px minmax(0, 1fr) auto;
align-items: center;
gap: $spacing-md;
}
.logoBox {
display: inline-flex;
width: 48px;
height: 48px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: color-mix(in srgb, var(--bg-secondary) 90%, transparent);
color: var(--text-secondary);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.pluginIdentity {
min-width: 0;
h2 {
margin: 0;
color: var(--text-primary);
font-size: 17px;
font-weight: 700;
line-height: 1.25;
overflow-wrap: anywhere;
}
span {
display: block;
margin-top: 4px;
color: var(--text-tertiary);
font-family: $font-mono;
font-size: 12px;
overflow-wrap: anywhere;
}
}
.badgeRow {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.badge,
.badgeSuccess,
.badgeWarning,
.badgeMuted {
display: inline-flex;
min-height: 24px;
align-items: center;
border-radius: $radius-sm;
padding: 3px 8px;
font-size: 12px;
font-weight: 600;
line-height: 1.25;
}
.badge {
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
color: var(--text-secondary);
}
.badgeSuccess {
background: rgba($success-color, 0.12);
color: var(--success-color);
}
.badgeWarning {
background: rgba($warning-color, 0.12);
color: var(--warning-color);
}
.badgeMuted {
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
color: var(--text-tertiary);
}
.metaList {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 6px $spacing-sm;
margin: 0;
color: var(--text-secondary);
font-size: 13px;
dt {
color: var(--text-tertiary);
font-weight: 600;
}
dd {
min-width: 0;
margin: 0;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.cardActions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $spacing-sm;
margin-top: auto;
:global(.btn > span) {
display: inline-flex;
align-items: center;
gap: 8px;
}
}
.linkButton,
.iconLink {
display: inline-flex;
min-height: 34px;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
text-decoration: none;
transition:
border-color $transition-fast,
background-color $transition-fast,
color $transition-fast;
&:hover {
border-color: var(--primary-color);
background: var(--bg-hover);
color: var(--primary-color);
}
}
.linkButton {
gap: 8px;
max-width: 100%;
padding: 0 12px;
overflow-wrap: anywhere;
}
.iconLink {
width: 34px;
flex: 0 0 auto;
}
.sheetFooter {
display: flex;
justify-content: flex-end;
gap: $spacing-sm;
}
.configForm {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.formSection {
display: flex;
flex-direction: column;
gap: $spacing-md;
h3 {
margin: 0;
color: var(--text-primary);
font-size: 16px;
font-weight: 700;
}
:global(.form-group) {
margin: 0;
}
}
.fieldRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
min-height: 52px;
padding: $spacing-md;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: color-mix(in srgb, var(--bg-secondary) 72%, transparent);
}
.fieldText {
min-width: 0;
}
.fieldLabel {
color: var(--text-primary);
font-size: 14px;
font-weight: 650;
overflow-wrap: anywhere;
}
.fieldDescription,
.fieldHint {
margin-top: 4px;
color: var(--text-tertiary);
font-size: 12px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.formField {
display: flex;
flex-direction: column;
gap: 6px;
label {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
overflow-wrap: anywhere;
}
}
.textarea {
min-height: 148px;
resize: vertical;
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: 10px 12px;
background: var(--bg-primary);
color: var(--text-primary);
font-family: $font-mono;
font-size: 13px;
line-height: 1.5;
&:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 18%, transparent);
}
}
.arrayEditor {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.arrayItemRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: $spacing-sm;
}
.arrayInput {
width: 100%;
min-width: 0;
height: 38px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: 0 12px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
line-height: 38px;
box-sizing: border-box;
&:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 18%, transparent);
}
}
.arrayActions {
display: inline-flex;
flex: 0 0 auto;
gap: 4px;
}
.iconButton:global(.btn.btn-sm) {
width: 34px;
height: 34px;
min-width: 34px;
padding: 0;
> span {
display: inline-flex;
align-items: center;
justify-content: center;
}
}
.fieldError {
padding: 8px 10px;
border-radius: $radius-sm;
background: rgba($error-color, 0.1);
color: var(--danger-color);
font-size: 12px;
line-height: 1.4;
}
.emptyConfig {
padding: $spacing-md;
border: 1px dashed var(--border-color);
border-radius: $radius-md;
background: color-mix(in srgb, var(--bg-secondary) 70%, transparent);
color: var(--text-secondary);
font-size: 14px;
}
@include mobile {
.page {
gap: $spacing-md;
}
.pluginCard {
padding: $spacing-md;
}
.cardHeader {
grid-template-columns: 44px minmax(0, 1fr) auto;
gap: $spacing-sm;
}
.logoBox {
width: 44px;
height: 44px;
}
.metaList {
grid-template-columns: 1fr;
}
.sheetFooter {
flex-direction: column-reverse;
:global(.btn) {
width: 100%;
}
}
.arrayItemRow {
grid-template-columns: minmax(0, 1fr) auto;
}
}
+841
View File
@@ -0,0 +1,841 @@
import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Sheet } from '@/components/ui/Sheet';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import {
IconExternalLink,
IconGithub,
IconPlug,
IconPlus,
IconRefreshCw,
IconSearch,
IconSettings,
IconTrash2,
} from '@/components/ui/icons';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { pluginsApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { PluginConfigField, PluginListEntry, PluginListResponse } from '@/types';
import { normalizeApiBase } from '@/utils/connection';
import styles from './PluginsPage.module.scss';
type PluginDraftValue = string | boolean | string[];
interface PluginConfigDraft {
enabled: boolean;
priority: string;
values: Record<string, PluginDraftValue>;
errors: Record<string, string>;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const cloneRecord = (value: unknown): Record<string, unknown> =>
isRecord(value) ? { ...value } : {};
const getErrorMessage = (error: unknown, fallback: string) =>
error instanceof Error ? error.message : typeof error === 'string' ? error : fallback;
const hasStatus = (error: unknown, status: number) =>
isRecord(error) && error.status === status;
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 getPluginTitle = (plugin: PluginListEntry) =>
plugin.metadata?.name.trim() || plugin.id;
const stringifyArrayItem = (value: unknown): string => {
if (value === undefined || value === null) return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
const getFieldDraftValue = (field: PluginConfigField, value: unknown): PluginDraftValue => {
const type = normalizeFieldType(field);
if (type === 'boolean') return value === true;
if (type === 'array') {
if (Array.isArray(value)) {
return value.length > 0 ? value.map((item) => stringifyArrayItem(item)) : [''];
}
if (value !== undefined && value !== null) return [stringifyArrayItem(value)];
return [''];
}
if (value === undefined || value === null) return '';
if (type === 'object') {
return JSON.stringify(value, null, 2);
}
return String(value);
};
const buildDraft = (
plugin: PluginListEntry,
currentConfig: Record<string, unknown>
): PluginConfigDraft => {
const enabled = typeof currentConfig.enabled === 'boolean' ? currentConfig.enabled : plugin.enabled;
const priority =
typeof currentConfig.priority === 'number' || typeof currentConfig.priority === 'string'
? String(currentConfig.priority)
: '0';
const values: PluginConfigDraft['values'] = {};
plugin.configFields.forEach((field) => {
values[field.name] = getFieldDraftValue(field, currentConfig[field.name]);
});
return {
enabled,
priority,
values,
errors: {},
};
};
const parseJSONField = (
text: string,
fieldType: string,
fieldName: string,
t: (key: string, options?: Record<string, unknown>) => string,
errors: Record<string, string>
) => {
try {
const parsed = JSON.parse(text);
if (fieldType === 'array' && !Array.isArray(parsed)) {
errors[fieldName] = t('plugin_management.expected_array');
return undefined;
}
if (fieldType === 'object' && !isRecord(parsed)) {
errors[fieldName] = t('plugin_management.expected_object');
return undefined;
}
return parsed;
} catch {
errors[fieldName] = t('plugin_management.invalid_json');
return undefined;
}
};
const buildConfigPayload = (
draft: PluginConfigDraft,
fields: PluginConfigField[],
currentConfig: Record<string, unknown>,
t: (key: string, options?: Record<string, unknown>) => string
) => {
const errors: Record<string, string> = {};
const nextConfig: Record<string, unknown> = { ...currentConfig };
const priorityText = draft.priority.trim();
nextConfig.enabled = draft.enabled;
if (!priorityText) {
nextConfig.priority = 0;
} else if (!/^-?\d+$/.test(priorityText)) {
errors.priority = t('plugin_management.invalid_priority');
} else {
nextConfig.priority = Number.parseInt(priorityText, 10);
}
fields.forEach((field) => {
const fieldType = normalizeFieldType(field);
const value = draft.values[field.name];
if (fieldType === 'boolean') {
nextConfig[field.name] = value === true;
return;
}
if (fieldType === 'array') {
const items = Array.isArray(value)
? value.map((item) => item.trim()).filter(Boolean)
: [];
if (items.length === 0) {
delete nextConfig[field.name];
} else {
nextConfig[field.name] = items;
}
return;
}
const text = typeof value === 'string' ? value.trim() : '';
if (!text) {
delete nextConfig[field.name];
return;
}
if (fieldType === 'enum') {
if (field.enumValues.length > 0 && !field.enumValues.includes(text)) {
errors[field.name] = t('plugin_management.invalid_enum');
return;
}
nextConfig[field.name] = text;
return;
}
if (fieldType === 'number') {
const parsed = Number(text);
if (!Number.isFinite(parsed)) {
errors[field.name] = t('plugin_management.invalid_number');
return;
}
nextConfig[field.name] = parsed;
return;
}
if (fieldType === 'integer') {
if (!/^-?\d+$/.test(text)) {
errors[field.name] = t('plugin_management.invalid_integer');
return;
}
nextConfig[field.name] = Number.parseInt(text, 10);
return;
}
if (fieldType === 'object') {
const parsed = parseJSONField(text, fieldType, field.name, t, errors);
if (errors[field.name]) return;
nextConfig[field.name] = parsed;
return;
}
nextConfig[field.name] = text;
});
return { nextConfig, errors };
};
export function PluginsPage() {
const { t } = useTranslation();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const apiBase = useAuthStore((state) => state.apiBase);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearConfigCache = useConfigStore((state) => state.clearCache);
const showNotification = useNotificationStore((state) => state.showNotification);
const [data, setData] = useState<PluginListResponse | null>(null);
const [rawConfig, setRawConfig] = useState<Record<string, unknown>>({});
const [filter, setFilter] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingPlugin, setEditingPlugin] = useState<PluginListEntry | null>(null);
const [draft, setDraft] = useState<PluginConfigDraft | null>(null);
const [mutatingID, setMutatingID] = useState('');
const connected = connectionStatus === 'connected';
const loadPlugins = useCallback(async () => {
if (!connected) {
setLoading(false);
setError(t('notification.connection_required'));
return;
}
setLoading(true);
setError('');
try {
const [plugins, config] = await Promise.all([
pluginsApi.list(),
fetchConfig(undefined, true).catch(() => null),
]);
setData(plugins);
setRawConfig(config?.raw ?? {});
} catch (err: unknown) {
setError(
hasStatus(err, 404)
? t('plugin_management.unsupported_backend')
: getErrorMessage(err, t('plugin_management.load_failed'))
);
} finally {
setLoading(false);
}
}, [connected, fetchConfig, t]);
useHeaderRefresh(loadPlugins, connected);
useEffect(() => {
void loadPlugins();
}, [loadPlugins]);
const pluginStats = useMemo(() => {
const plugins = data?.plugins ?? [];
return {
discovered: plugins.length,
registered: plugins.filter((plugin) => plugin.registered).length,
configured: plugins.filter((plugin) => plugin.configured).length,
effective: plugins.filter((plugin) => plugin.effectiveEnabled).length,
};
}, [data?.plugins]);
const visiblePlugins = useMemo(() => {
const query = filter.trim().toLowerCase();
const plugins = data?.plugins ?? [];
if (!query) return plugins;
return plugins.filter((plugin) => {
const haystack = [
plugin.id,
plugin.path,
plugin.metadata?.name,
plugin.metadata?.author,
plugin.metadata?.version,
plugin.metadata?.githubRepository,
...plugin.menus.map((menu) => `${menu.menu} ${menu.path} ${menu.description}`),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
}, [data?.plugins, filter]);
const resolvePluginAssetURL = useCallback(
(value: string) => {
const trimmed = value.trim();
if (!trimmed) return '';
if (/^(https?:|data:|blob:)/i.test(trimmed)) return trimmed;
if (!trimmed.startsWith('/')) return trimmed;
const base = normalizeApiBase(apiBase);
return base ? `${base}${trimmed}` : trimmed;
},
[apiBase]
);
const openConfigSheet = (plugin: PluginListEntry) => {
const currentConfig = getPluginRawConfig(rawConfig, plugin.id);
setEditingPlugin(plugin);
setDraft(buildDraft(plugin, currentConfig));
};
const closeConfigSheet = () => {
if (mutatingID) return;
setEditingPlugin(null);
setDraft(null);
};
const updateDraft = (updater: (current: PluginConfigDraft) => PluginConfigDraft) => {
setDraft((current) => (current ? updater(current) : current));
};
const handleTogglePlugin = async (plugin: PluginListEntry, enabled: boolean) => {
setMutatingID(plugin.id);
try {
await pluginsApi.updateEnabled(plugin.id, enabled);
clearConfigCache();
await loadPlugins();
showNotification(t('plugin_management.toggle_success'), 'success');
} catch (err: unknown) {
showNotification(
`${t('plugin_management.toggle_failed')}: ${getErrorMessage(
err,
t('plugin_management.toggle_failed')
)}`,
'error'
);
} finally {
setMutatingID('');
}
};
const handleSaveConfig = async () => {
if (!editingPlugin || !draft) return;
const currentConfig = getPluginRawConfig(rawConfig, editingPlugin.id);
const { nextConfig, errors } = buildConfigPayload(
draft,
editingPlugin.configFields,
currentConfig,
t
);
if (Object.keys(errors).length > 0) {
setDraft({ ...draft, errors });
showNotification(t('plugin_management.validation_failed'), 'warning');
return;
}
setMutatingID(editingPlugin.id);
try {
await pluginsApi.putConfig(editingPlugin.id, nextConfig);
clearConfigCache();
await loadPlugins();
setEditingPlugin(null);
setDraft(null);
showNotification(t('plugin_management.save_success'), 'success');
} catch (err: unknown) {
showNotification(
`${t('plugin_management.save_failed')}: ${getErrorMessage(
err,
t('plugin_management.save_failed')
)}`,
'error'
);
} finally {
setMutatingID('');
}
};
const handleFieldTextChange =
(fieldName: string) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = event.target.value;
updateDraft((current) => ({
...current,
values: { ...current.values, [fieldName]: value },
errors: { ...current.errors, [fieldName]: '' },
}));
};
const handleFieldBooleanChange = (fieldName: string, value: boolean) => {
updateDraft((current) => ({
...current,
values: { ...current.values, [fieldName]: value },
errors: { ...current.errors, [fieldName]: '' },
}));
};
const updateArrayField = (
fieldName: string,
updater: (items: string[]) => string[]
) => {
updateDraft((current) => {
const currentValue = current.values[fieldName];
const items = Array.isArray(currentValue) ? currentValue : [''];
return {
...current,
values: { ...current.values, [fieldName]: updater(items) },
errors: { ...current.errors, [fieldName]: '' },
};
});
};
const handlePriorityChange = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
updateDraft((current) => ({
...current,
priority: value,
errors: { ...current.errors, priority: '' },
}));
};
const renderFieldEditor = (field: PluginConfigField) => {
if (!draft) return null;
const fieldType = normalizeFieldType(field);
const value = draft.values[field.name];
const textValue = typeof value === 'string' ? value : '';
const errorText = draft.errors[field.name];
if (fieldType === 'boolean') {
return (
<div key={field.name} className={styles.fieldRow}>
<div className={styles.fieldText}>
<div className={styles.fieldLabel}>{field.name}</div>
{field.description ? (
<div className={styles.fieldDescription}>{field.description}</div>
) : null}
</div>
<ToggleSwitch
checked={value === true}
onChange={(nextValue) => handleFieldBooleanChange(field.name, nextValue)}
ariaLabel={field.name}
/>
</div>
);
}
if (fieldType === 'enum' && field.enumValues.length > 0) {
return (
<div key={field.name} className={styles.formField}>
<label htmlFor={`plugin-field-${field.name}`}>{field.name}</label>
<Select
id={`plugin-field-${field.name}`}
value={textValue}
options={field.enumValues.map((item) => ({ value: item, label: item }))}
onChange={(nextValue) =>
updateDraft((current) => ({
...current,
values: { ...current.values, [field.name]: nextValue },
errors: { ...current.errors, [field.name]: '' },
}))
}
placeholder={t('plugin_management.select_placeholder')}
/>
{field.description ? (
<div className={styles.fieldHint}>{field.description}</div>
) : null}
{errorText ? <div className={styles.fieldError}>{errorText}</div> : null}
</div>
);
}
if (fieldType === 'array') {
const items = Array.isArray(value) && value.length > 0 ? value : [''];
return (
<div key={field.name} className={styles.formField}>
<div className={styles.fieldLabel}>{field.name}</div>
<div className={styles.arrayEditor}>
{items.map((item, index) => (
<div key={`${field.name}-${index}`} className={styles.arrayItemRow}>
<input
className={styles.arrayInput}
aria-label={`${field.name} ${index + 1}`}
value={item}
onChange={(event) =>
updateArrayField(field.name, (currentItems) =>
currentItems.map((currentItem, currentIndex) =>
currentIndex === index ? event.target.value : currentItem
)
)
}
placeholder={t('plugin_management.array_item_placeholder')}
/>
<div className={styles.arrayActions}>
<Button
type="button"
variant="ghost"
size="sm"
className={styles.iconButton}
onClick={() =>
updateArrayField(field.name, (currentItems) => [
...currentItems.slice(0, index + 1),
'',
...currentItems.slice(index + 1),
])
}
title={t('plugin_management.add_array_item')}
aria-label={t('plugin_management.add_array_item')}
>
<IconPlus size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className={styles.iconButton}
onClick={() =>
updateArrayField(field.name, (currentItems) =>
currentItems.length <= 1
? ['']
: currentItems.filter((_, currentIndex) => currentIndex !== index)
)
}
title={t('plugin_management.remove_array_item')}
aria-label={t('plugin_management.remove_array_item')}
>
<IconTrash2 size={16} />
</Button>
</div>
</div>
))}
</div>
{field.description ? (
<div className={styles.fieldHint}>{field.description}</div>
) : null}
{errorText ? <div className={styles.fieldError}>{errorText}</div> : null}
</div>
);
}
if (fieldType === 'object') {
return (
<div key={field.name} className={styles.formField}>
<label htmlFor={`plugin-field-${field.name}`}>{field.name}</label>
<textarea
id={`plugin-field-${field.name}`}
className={styles.textarea}
value={textValue}
onChange={handleFieldTextChange(field.name)}
placeholder="{}"
spellCheck={false}
/>
{field.description ? (
<div className={styles.fieldHint}>{field.description}</div>
) : null}
{errorText ? <div className={styles.fieldError}>{errorText}</div> : null}
</div>
);
}
return (
<Input
key={field.name}
id={`plugin-field-${field.name}`}
label={field.name}
value={textValue}
onChange={handleFieldTextChange(field.name)}
inputMode={fieldType === 'integer' || fieldType === 'number' ? 'decimal' : undefined}
hint={field.description || undefined}
error={errorText || undefined}
/>
);
};
const savingConfig = Boolean(editingPlugin && mutatingID === editingPlugin.id);
return (
<div className={styles.page}>
<div className={styles.header}>
<div>
<h1 className={styles.title}>{t('plugin_management.title')}</h1>
<p className={styles.description}>{t('plugin_management.description')}</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={loadPlugins}
disabled={!connected || loading}
loading={loading}
>
<IconRefreshCw size={16} />
{t('plugin_management.refresh')}
</Button>
</div>
{error ? <div className={styles.errorBox}>{error}</div> : null}
{data && !data.pluginsEnabled ? (
<div className={styles.warningBox}>{t('plugin_management.global_disabled_hint')}</div>
) : null}
<div className={styles.statsGrid}>
<div className={styles.statTile}>
<span>{t('plugin_management.global_status')}</span>
<strong>
{data?.pluginsEnabled
? t('plugin_management.global_enabled')
: t('plugin_management.global_disabled')}
</strong>
</div>
<div className={styles.statTile}>
<span>{t('plugin_management.plugins_dir')}</span>
<strong>{data?.pluginsDir || 'plugins'}</strong>
</div>
<div className={styles.statTile}>
<span>{t('plugin_management.discovered')}</span>
<strong>{pluginStats.discovered}</strong>
</div>
<div className={styles.statTile}>
<span>{t('plugin_management.effective')}</span>
<strong>
{pluginStats.effective}/{pluginStats.registered}
</strong>
</div>
</div>
<div className={styles.toolbar}>
<Input
type="search"
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder={t('plugin_management.search_placeholder')}
aria-label={t('plugin_management.search_label')}
rightElement={<IconSearch size={16} />}
/>
</div>
{loading ? (
<div className={styles.pluginGrid} aria-busy="true">
{Array.from({ length: 4 }, (_, index) => (
<div key={index} className={styles.skeletonCard} />
))}
</div>
) : visiblePlugins.length === 0 ? (
<EmptyState
title={t('plugin_management.no_plugins')}
description={t('plugin_management.no_plugins_desc')}
action={
<Button variant="secondary" size="sm" onClick={loadPlugins} disabled={!connected}>
<IconRefreshCw size={16} />
{t('plugin_management.refresh')}
</Button>
}
/>
) : (
<div className={styles.pluginGrid}>
{visiblePlugins.map((plugin) => {
const logo = resolvePluginAssetURL(plugin.logo || plugin.metadata?.logo || '');
const github = plugin.metadata?.githubRepository.trim();
const mutating = mutatingID === plugin.id;
return (
<article key={plugin.id} className={styles.pluginCard}>
<div className={styles.cardHeader}>
<div className={styles.logoBox} aria-hidden="true">
{logo ? <img src={logo} alt="" /> : <IconPlug size={22} />}
</div>
<div className={styles.pluginIdentity}>
<h2>{getPluginTitle(plugin)}</h2>
<span>{plugin.id}</span>
</div>
<ToggleSwitch
checked={plugin.enabled}
onChange={(enabled) => handleTogglePlugin(plugin, enabled)}
disabled={!connected || mutating}
ariaLabel={t('plugin_management.enabled')}
/>
</div>
<div className={styles.badgeRow}>
<span className={plugin.effectiveEnabled ? styles.badgeSuccess : styles.badgeMuted}>
{plugin.effectiveEnabled
? t('plugin_management.status_effective')
: t('plugin_management.status_inactive')}
</span>
<span className={plugin.registered ? styles.badge : styles.badgeWarning}>
{plugin.registered
? t('plugin_management.registered')
: t('plugin_management.not_registered')}
</span>
<span className={plugin.configured ? styles.badge : styles.badgeMuted}>
{plugin.configured
? t('plugin_management.configured')
: t('plugin_management.not_configured')}
</span>
{plugin.supportsOAuth ? (
<span className={styles.badge}>{t('plugin_management.oauth')}</span>
) : null}
</div>
<dl className={styles.metaList}>
{plugin.metadata?.version ? (
<>
<dt>{t('plugin_management.version_label')}</dt>
<dd>{plugin.metadata.version}</dd>
</>
) : null}
{plugin.metadata?.author ? (
<>
<dt>{t('plugin_management.author_label')}</dt>
<dd>{plugin.metadata.author}</dd>
</>
) : null}
{plugin.path ? (
<>
<dt>{t('plugin_management.path_label')}</dt>
<dd title={plugin.path}>{plugin.path}</dd>
</>
) : null}
</dl>
<div className={styles.cardActions}>
<Button
variant="secondary"
size="sm"
onClick={() => openConfigSheet(plugin)}
disabled={!connected}
>
<IconSettings size={16} />
{t('plugin_management.edit_config')}
</Button>
{plugin.menus.map((menu) => (
<a
key={`${plugin.id}-${menu.path}`}
className={styles.linkButton}
href={resolvePluginAssetURL(menu.path)}
target="_blank"
rel="noreferrer"
title={menu.description || menu.menu}
>
<IconExternalLink size={16} />
{menu.menu || t('plugin_management.open_resource')}
</a>
))}
{github ? (
<a
className={styles.iconLink}
href={github}
target="_blank"
rel="noreferrer"
title={t('plugin_management.open_repository')}
aria-label={t('plugin_management.open_repository')}
>
<IconGithub size={16} />
</a>
) : null}
</div>
</article>
);
})}
</div>
)}
<Sheet
open={Boolean(editingPlugin && draft)}
onClose={closeConfigSheet}
size="lg"
title={
editingPlugin
? t('plugin_management.config_title', { name: getPluginTitle(editingPlugin) })
: t('plugin_management.edit_config')
}
description={editingPlugin?.id}
closeDisabled={savingConfig}
footer={
<div className={styles.sheetFooter}>
<Button variant="secondary" onClick={closeConfigSheet} disabled={savingConfig}>
{t('common.cancel')}
</Button>
<Button onClick={handleSaveConfig} loading={savingConfig}>
{t('common.save')}
</Button>
</div>
}
>
{draft && editingPlugin ? (
<div className={styles.configForm}>
<section className={styles.formSection}>
<h3>{t('plugin_management.base_settings')}</h3>
<div className={styles.fieldRow}>
<div className={styles.fieldText}>
<div className={styles.fieldLabel}>{t('plugin_management.enabled')}</div>
<div className={styles.fieldDescription}>
{t('plugin_management.enabled_hint')}
</div>
</div>
<ToggleSwitch
checked={draft.enabled}
onChange={(enabled) => updateDraft((current) => ({ ...current, enabled }))}
ariaLabel={t('plugin_management.enabled')}
/>
</div>
<Input
label={t('plugin_management.priority')}
value={draft.priority}
onChange={handlePriorityChange}
inputMode="numeric"
error={draft.errors.priority || undefined}
/>
</section>
<section className={styles.formSection}>
<h3>{t('plugin_management.config_fields')}</h3>
{editingPlugin.configFields.length > 0 ? (
editingPlugin.configFields.map((field) => renderFieldEditor(field))
) : (
<div className={styles.emptyConfig}>{t('plugin_management.no_config_fields')}</div>
)}
</section>
</div>
) : null}
</Sheet>
</div>
);
}
+56
View File
@@ -116,6 +116,7 @@
"auth_files": "Auth Files",
"oauth": "OAuth Login",
"quota_management": "Quota Management",
"plugins": "Plugins",
"config_management": "Config Panel",
"logs": "Logs Viewer",
"system_info": "Management Center Info"
@@ -132,6 +133,7 @@
"auth_files": "Auth files & credentials",
"oauth": "OAuth authorization",
"quota_management": "API keys & limits",
"plugins": "Plugin toggles & resources",
"logs": "Request tracing",
"config_management": "Gateway configuration",
"system_info": "Runtime & diagnostics"
@@ -1084,6 +1086,60 @@
"refresh_all_credentials": "Refresh all credentials",
"card_idle_hint": "Use the top \"Refresh all credentials\" button to fetch the latest quota data."
},
"plugin_management": {
"title": "Plugin Management",
"description": "Review discovered and registered plugins, then manage instance toggles, config fields, and resource links.",
"refresh": "Refresh",
"load_failed": "Failed to load plugins",
"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_enabled": "Enabled",
"global_disabled": "Disabled",
"global_disabled_hint": "plugins.enabled is false, so enabled plugin instances will not become effective.",
"plugins_dir": "Plugin directory",
"discovered": "Discovered",
"effective": "Effective",
"search_placeholder": "Search plugin ID, name, author, or resource...",
"search_label": "Search plugins",
"no_plugins": "No plugins",
"no_plugins_desc": "No plugins were found in the plugin directory or runtime registry.",
"configured": "Configured",
"not_configured": "Not configured",
"registered": "Registered",
"not_registered": "Not registered",
"oauth": "OAuth",
"status_effective": "Effective",
"status_inactive": "Inactive",
"version_label": "Version",
"author_label": "Author",
"path_label": "Path",
"edit_config": "Edit config",
"open_resource": "Open resource",
"open_repository": "Open repository",
"config_title": "Configure {{name}}",
"base_settings": "Base settings",
"enabled": "Enabled",
"enabled_hint": "Controls the enabled field for this plugin instance under plugins.configs.",
"priority": "Priority",
"config_fields": "Config fields",
"no_config_fields": "This plugin does not declare visual config fields.",
"select_placeholder": "Select",
"array_item_placeholder": "Enter array item",
"add_array_item": "Add array item",
"remove_array_item": "Remove array item",
"toggle_success": "Plugin status updated",
"toggle_failed": "Failed to update plugin status",
"save_success": "Plugin config saved",
"save_failed": "Failed to save plugin config",
"validation_failed": "Fix plugin config form errors first",
"invalid_priority": "Enter an integer priority",
"invalid_number": "Enter a valid number",
"invalid_integer": "Enter a valid integer",
"invalid_json": "Enter valid JSON",
"expected_array": "Enter a JSON array",
"expected_object": "Enter a JSON object",
"invalid_enum": "Choose one of the declared enum values"
},
"system_info": {
"title": "Management Center Info",
"about_title": "CLI Proxy API Management Center",
+56
View File
@@ -116,6 +116,7 @@
"auth_files": "Файлы аутентификации",
"oauth": "OAuth вход",
"quota_management": "Управление квотами",
"plugins": "Плагины",
"config_management": "Панель конфигурации",
"logs": "Просмотр логов",
"system_info": "Информация системы"
@@ -132,6 +133,7 @@
"auth_files": "Файлы и учётные данные",
"oauth": "Авторизация OAuth",
"quota_management": "API ключи и лимиты",
"plugins": "Переключатели и ресурсы плагинов",
"logs": "Трассировка запросов",
"config_management": "Базовая конфигурация шлюза",
"system_info": "Состояние и диагностика"
@@ -1079,6 +1081,60 @@
"refresh_all_credentials": "Обновить все учётные данные",
"card_idle_hint": "Используйте кнопку «Обновить все учётные данные» сверху, чтобы загрузить актуальные данные по квотам."
},
"plugin_management": {
"title": "Управление плагинами",
"description": "Просматривайте найденные и зарегистрированные плагины, управляйте переключателями экземпляров, полями конфигурации и ссылками на ресурсы.",
"refresh": "Обновить",
"load_failed": "Не удалось загрузить плагины",
"unsupported_backend": "Текущий backend не предоставляет API управления плагинами. Используйте более новую сборку backend с эндпоинтами управления плагинами и перезапустите сервис.",
"global_status": "Глобальный статус",
"global_enabled": "Включено",
"global_disabled": "Отключено",
"global_disabled_hint": "plugins.enabled имеет значение false, поэтому включённые экземпляры плагинов не станут активными.",
"plugins_dir": "Каталог плагинов",
"discovered": "Найдено",
"effective": "Активно",
"search_placeholder": "Искать ID, имя, автора или ресурс плагина...",
"search_label": "Поиск плагинов",
"no_plugins": "Плагинов нет",
"no_plugins_desc": "В каталоге плагинов и реестре времени выполнения плагины не найдены.",
"configured": "Настроен",
"not_configured": "Не настроен",
"registered": "Зарегистрирован",
"not_registered": "Не зарегистрирован",
"oauth": "OAuth",
"status_effective": "Активен",
"status_inactive": "Неактивен",
"version_label": "Версия",
"author_label": "Автор",
"path_label": "Путь",
"edit_config": "Изменить конфиг",
"open_resource": "Открыть ресурс",
"open_repository": "Открыть репозиторий",
"config_title": "Настроить {{name}}",
"base_settings": "Базовые настройки",
"enabled": "Включено",
"enabled_hint": "Управляет полем enabled для этого экземпляра плагина в plugins.configs.",
"priority": "Приоритет",
"config_fields": "Поля конфигурации",
"no_config_fields": "Этот плагин не объявляет визуальные поля конфигурации.",
"select_placeholder": "Выберите",
"array_item_placeholder": "Введите элемент массива",
"add_array_item": "Добавить элемент массива",
"remove_array_item": "Удалить элемент массива",
"toggle_success": "Статус плагина обновлён",
"toggle_failed": "Не удалось обновить статус плагина",
"save_success": "Конфигурация плагина сохранена",
"save_failed": "Не удалось сохранить конфигурацию плагина",
"validation_failed": "Сначала исправьте ошибки формы конфигурации плагина",
"invalid_priority": "Введите целочисленный приоритет",
"invalid_number": "Введите корректное число",
"invalid_integer": "Введите корректное целое число",
"invalid_json": "Введите корректный JSON",
"expected_array": "Введите JSON-массив",
"expected_object": "Введите JSON-объект",
"invalid_enum": "Выберите одно из объявленных значений enum"
},
"system_info": {
"title": "Информация о центре управления",
"about_title": "CLI Proxy API Management Center",
+56
View File
@@ -116,6 +116,7 @@
"auth_files": "认证文件",
"oauth": "OAuth 登录",
"quota_management": "配额管理",
"plugins": "插件管理",
"config_management": "配置面板",
"logs": "日志查看",
"system_info": "中心信息"
@@ -132,6 +133,7 @@
"auth_files": "Auth 文件与凭证",
"oauth": "OAuth 授权登录",
"quota_management": "API Key 与限额",
"plugins": "插件启停与资源",
"logs": "请求追踪与排查",
"config_management": "网关基础配置",
"system_info": "运行与诊断信息"
@@ -1084,6 +1086,60 @@
"refresh_all_credentials": "刷新全部凭证",
"card_idle_hint": "请使用顶部“刷新全部凭证”按钮获取最新额度。"
},
"plugin_management": {
"title": "插件管理",
"description": "查看已发现和已注册的插件,管理实例开关、配置字段和资源入口。",
"refresh": "刷新",
"load_failed": "加载插件失败",
"unsupported_backend": "当前后端未暴露插件管理 API。请使用包含插件管理接口的新后端构建,并重启服务。",
"global_status": "全局状态",
"global_enabled": "已启用",
"global_disabled": "已停用",
"global_disabled_hint": "当前 plugins.enabled 为 false,插件实例即使启用也不会生效。",
"plugins_dir": "插件目录",
"discovered": "发现插件",
"effective": "生效插件",
"search_placeholder": "搜索插件 ID、名称、作者或资源...",
"search_label": "搜索插件",
"no_plugins": "暂无插件",
"no_plugins_desc": "未从插件目录或运行时注册表中发现插件。",
"configured": "已配置",
"not_configured": "未配置",
"registered": "已注册",
"not_registered": "未注册",
"oauth": "OAuth",
"status_effective": "生效中",
"status_inactive": "未生效",
"version_label": "版本",
"author_label": "作者",
"path_label": "路径",
"edit_config": "编辑配置",
"open_resource": "打开资源",
"open_repository": "打开仓库",
"config_title": "配置 {{name}}",
"base_settings": "基础设置",
"enabled": "启用",
"enabled_hint": "控制 plugins.configs 中该插件实例的 enabled 字段。",
"priority": "优先级",
"config_fields": "配置字段",
"no_config_fields": "该插件没有声明可视化配置字段。",
"select_placeholder": "请选择",
"array_item_placeholder": "输入数组项",
"add_array_item": "添加数组项",
"remove_array_item": "删除数组项",
"toggle_success": "插件状态已更新",
"toggle_failed": "插件状态更新失败",
"save_success": "插件配置已保存",
"save_failed": "插件配置保存失败",
"validation_failed": "请先修复插件配置表单错误",
"invalid_priority": "请输入整数优先级",
"invalid_number": "请输入有效数字",
"invalid_integer": "请输入有效整数",
"invalid_json": "请输入有效 JSON",
"expected_array": "请输入 JSON 数组",
"expected_object": "请输入 JSON 对象",
"invalid_enum": "请选择声明的枚举值"
},
"system_info": {
"title": "管理中心信息",
"about_title": "CLI Proxy API Management Center",
+56
View File
@@ -116,6 +116,7 @@
"auth_files": "驗證檔案",
"oauth": "OAuth 登入",
"quota_management": "配額管理",
"plugins": "插件管理",
"config_management": "設定面板",
"logs": "記錄檢視",
"system_info": "中心資訊"
@@ -132,6 +133,7 @@
"auth_files": "Auth 檔案與憑證",
"oauth": "OAuth 授權登入",
"quota_management": "API Key 與限額",
"plugins": "插件啟停與資源",
"logs": "請求追蹤與排查",
"config_management": "閘道基礎設定",
"system_info": "運行與診斷資訊"
@@ -1110,6 +1112,60 @@
"refresh_all_credentials": "重新整理全部憑證",
"card_idle_hint": "請使用頂部「重新整理全部憑證」按鈕取得最新配額。"
},
"plugin_management": {
"title": "插件管理",
"description": "查看已發現和已註冊的插件,管理實例開關、設定欄位和資源入口。",
"refresh": "重新整理",
"load_failed": "載入插件失敗",
"unsupported_backend": "目前後端未暴露插件管理 API。請使用包含插件管理介面的新版後端建置,並重新啟動服務。",
"global_status": "全域狀態",
"global_enabled": "已啟用",
"global_disabled": "已停用",
"global_disabled_hint": "目前 plugins.enabled 為 false,插件實例即使啟用也不會生效。",
"plugins_dir": "插件目錄",
"discovered": "發現插件",
"effective": "生效插件",
"search_placeholder": "搜尋插件 ID、名稱、作者或資源...",
"search_label": "搜尋插件",
"no_plugins": "暫無插件",
"no_plugins_desc": "未從插件目錄或執行時註冊表中發現插件。",
"configured": "已設定",
"not_configured": "未設定",
"registered": "已註冊",
"not_registered": "未註冊",
"oauth": "OAuth",
"status_effective": "生效中",
"status_inactive": "未生效",
"version_label": "版本",
"author_label": "作者",
"path_label": "路徑",
"edit_config": "編輯設定",
"open_resource": "開啟資源",
"open_repository": "開啟倉庫",
"config_title": "設定 {{name}}",
"base_settings": "基礎設定",
"enabled": "啟用",
"enabled_hint": "控制 plugins.configs 中該插件實例的 enabled 欄位。",
"priority": "優先級",
"config_fields": "設定欄位",
"no_config_fields": "該插件沒有宣告可視化設定欄位。",
"select_placeholder": "請選擇",
"array_item_placeholder": "輸入陣列項目",
"add_array_item": "新增陣列項目",
"remove_array_item": "刪除陣列項目",
"toggle_success": "插件狀態已更新",
"toggle_failed": "插件狀態更新失敗",
"save_success": "插件設定已儲存",
"save_failed": "插件設定儲存失敗",
"validation_failed": "請先修復插件設定表單錯誤",
"invalid_priority": "請輸入整數優先級",
"invalid_number": "請輸入有效數字",
"invalid_integer": "請輸入有效整數",
"invalid_json": "請輸入有效 JSON",
"expected_array": "請輸入 JSON 陣列",
"expected_object": "請輸入 JSON 物件",
"invalid_enum": "請選擇宣告的枚舉值"
},
"system_info": {
"title": "管理中心資訊",
"about_title": "CLI Proxy API Management Center",
+3
View File
@@ -6,6 +6,7 @@ import { AuthFilesOAuthExcludedEditPage } from '@/pages/AuthFilesOAuthExcludedEd
import { AuthFilesOAuthModelAliasEditPage } from '@/pages/AuthFilesOAuthModelAliasEditPage';
import { OAuthPage } from '@/pages/OAuthPage';
import { QuotaPage } from '@/pages/QuotaPage';
import { PluginsPage } from '@/features/plugins/PluginsPage';
import { ConfigPage } from '@/pages/ConfigPage';
import { LogsPage } from '@/pages/LogsPage';
import { SystemPage } from '@/pages/SystemPage';
@@ -22,6 +23,8 @@ const mainRoutes = [
{ path: '/auth-files/oauth-model-alias', element: <AuthFilesOAuthModelAliasEditPage /> },
{ path: '/oauth', element: <OAuthPage /> },
{ path: '/quota', element: <QuotaPage /> },
{ path: '/plugins', element: <PluginsPage /> },
{ path: '/plugins/*', element: <Navigate to="/plugins" replace /> },
{ path: '/config', element: <ConfigPage /> },
{ path: '/logs', element: <LogsPage /> },
{ path: '/system', element: <SystemPage /> },
+1
View File
@@ -11,5 +11,6 @@ export * from './oauth';
export * from './logs';
export * from './version';
export * from './models';
export * from './plugins';
export * from './transformers';
export * from './vertex';
+130
View File
@@ -0,0 +1,130 @@
import { apiClient } from './client';
import type {
PluginConfigField,
PluginListEntry,
PluginListResponse,
PluginMetadata,
PluginMenu,
} from '@/types';
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const asString = (value: unknown): string => {
if (value === undefined || value === null) return '';
return String(value);
};
const asBoolean = (value: unknown): boolean => value === true;
const normalizeConfigField = (value: unknown): PluginConfigField | null => {
if (!isRecord(value)) return null;
const name = asString(value.name).trim();
if (!name) return null;
const enumValues = Array.isArray(value.enum_values)
? value.enum_values.map((item) => asString(item)).filter(Boolean)
: [];
return {
name,
type: asString(value.type).trim() || 'string',
enumValues,
description: asString(value.description).trim(),
};
};
const normalizeConfigFields = (value: unknown): PluginConfigField[] =>
Array.isArray(value)
? value.map((item) => normalizeConfigField(item)).filter(Boolean) as PluginConfigField[]
: [];
const normalizeMetadata = (value: unknown): PluginMetadata | null => {
if (!isRecord(value)) return null;
const name = asString(value.name).trim();
const version = asString(value.version).trim();
const author = asString(value.author).trim();
const githubRepository = asString(value.github_repository).trim();
const logo = asString(value.logo).trim();
const configFields = normalizeConfigFields(value.config_fields);
if (!name && !version && !author && !githubRepository && !logo && configFields.length === 0) {
return null;
}
return {
name,
version,
author,
githubRepository,
logo,
configFields,
};
};
const normalizeMenu = (value: unknown): PluginMenu | null => {
if (!isRecord(value)) return null;
const path = asString(value.path).trim();
const menu = asString(value.menu).trim();
if (!path && !menu) return null;
return {
path,
menu,
description: asString(value.description).trim(),
};
};
const normalizeMenus = (value: unknown): PluginMenu[] =>
Array.isArray(value)
? value.map((item) => normalizeMenu(item)).filter(Boolean) as PluginMenu[]
: [];
const normalizePluginEntry = (value: unknown): PluginListEntry | null => {
if (!isRecord(value)) return null;
const id = asString(value.id).trim();
if (!id) return null;
const metadata = normalizeMetadata(value.metadata);
const configFields = normalizeConfigFields(value.config_fields);
return {
id,
path: asString(value.path).trim(),
configured: asBoolean(value.configured),
registered: asBoolean(value.registered),
enabled: value.enabled !== false,
effectiveEnabled: asBoolean(value.effective_enabled),
supportsOAuth: asBoolean(value.supports_oauth),
logo: asString(value.logo || metadata?.logo).trim(),
configFields: configFields.length > 0 ? configFields : metadata?.configFields ?? [],
menus: normalizeMenus(value.menus),
metadata,
};
};
const normalizePluginList = (value: unknown): PluginListResponse => {
const source = isRecord(value) ? value : {};
const plugins = Array.isArray(source.plugins)
? source.plugins.map((item) => normalizePluginEntry(item)).filter(Boolean) as PluginListEntry[]
: [];
return {
pluginsEnabled: asBoolean(source.plugins_enabled),
pluginsDir: asString(source.plugins_dir).trim() || 'plugins',
plugins,
};
};
export const pluginsApi = {
async list(): Promise<PluginListResponse> {
const data = await apiClient.get('/plugins');
return normalizePluginList(data);
},
updateEnabled: (id: string, enabled: boolean) =>
apiClient.patch(`/plugins/${encodeURIComponent(id)}/enabled`, { enabled }),
putConfig: (id: string, config: Record<string, unknown>) =>
apiClient.put(`/plugins/${encodeURIComponent(id)}/config`, config),
patchConfig: (id: string, patch: Record<string, unknown>) =>
apiClient.patch(`/plugins/${encodeURIComponent(id)}/config`, patch),
};
+1
View File
@@ -12,3 +12,4 @@ export * from './authFile';
export * from './oauth';
export * from './log';
export * from './quota';
export * from './plugin';
+50
View File
@@ -0,0 +1,50 @@
export type PluginConfigFieldType =
| 'string'
| 'number'
| 'integer'
| 'boolean'
| 'enum'
| 'array'
| 'object';
export interface PluginConfigField {
name: string;
type: PluginConfigFieldType | string;
enumValues: string[];
description: string;
}
export interface PluginMetadata {
name: string;
version: string;
author: string;
githubRepository: string;
logo: string;
configFields: PluginConfigField[];
}
export interface PluginMenu {
path: string;
menu: string;
description: string;
}
export interface PluginListEntry {
id: string;
path: string;
configured: boolean;
registered: boolean;
enabled: boolean;
effectiveEnabled: boolean;
supportsOAuth: boolean;
logo: string;
configFields: PluginConfigField[];
menus: PluginMenu[];
metadata: PluginMetadata | null;
}
export interface PluginListResponse {
pluginsEnabled: boolean;
pluginsDir: string;
plugins: PluginListEntry[];
}