mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
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:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -12,3 +12,4 @@ export * from './authFile';
|
||||
export * from './oauth';
|
||||
export * from './log';
|
||||
export * from './quota';
|
||||
export * from './plugin';
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user