mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
feat(plugins): implement plugin resource management with dynamic routing and UI enhancements
This commit is contained in:
@@ -12,6 +12,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { PageTransition } from '@/components/common/PageTransition';
|
||||
import { MainRoutes } from '@/router/MainRoutes';
|
||||
import { pluginsApi } from '@/services/api';
|
||||
import {
|
||||
IconSidebarAuthFiles,
|
||||
IconSidebarConfig,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
IconSidebarProviders,
|
||||
IconSidebarQuota,
|
||||
IconSidebarSystem,
|
||||
IconChevronDown,
|
||||
} from '@/components/ui/icons';
|
||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||
import {
|
||||
@@ -31,6 +33,10 @@ import {
|
||||
useNotificationStore,
|
||||
useThemeStore,
|
||||
} from '@/stores';
|
||||
import {
|
||||
collectPluginResourceEntries,
|
||||
type PluginResourceEntry,
|
||||
} from '@/features/plugins/pluginResources';
|
||||
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
|
||||
import { isSupportedLanguage } from '@/utils/language';
|
||||
@@ -48,6 +54,36 @@ const sidebarIcons: Record<string, ReactNode> = {
|
||||
system: <IconSidebarSystem size={18} />,
|
||||
};
|
||||
|
||||
interface SidebarNavLinkItem {
|
||||
kind?: 'link';
|
||||
path: string;
|
||||
labelKey?: string;
|
||||
metaKey?: string;
|
||||
label?: string;
|
||||
meta?: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
interface SidebarNavDrawerItem {
|
||||
kind: 'drawer';
|
||||
id: string;
|
||||
label: string;
|
||||
meta?: string;
|
||||
icon: ReactNode;
|
||||
children: SidebarNavLinkItem[];
|
||||
}
|
||||
|
||||
type SidebarNavItem = SidebarNavLinkItem | SidebarNavDrawerItem;
|
||||
|
||||
interface SidebarNavGroup {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
items: SidebarNavItem[];
|
||||
}
|
||||
|
||||
const flattenNavItems = (items: SidebarNavItem[]): SidebarNavLinkItem[] =>
|
||||
items.flatMap((item) => item.kind === 'drawer' ? item.children : [item]);
|
||||
|
||||
// Header action icons - smaller size for header buttons
|
||||
const headerIconProps: SVGProps<SVGSVGElement> = {
|
||||
width: 16,
|
||||
@@ -214,6 +250,8 @@ export function MainLayout() {
|
||||
const location = useLocation();
|
||||
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const apiBase = useAuthStore((state) => state.apiBase);
|
||||
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
@@ -227,6 +265,10 @@ export function MainLayout() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
|
||||
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
|
||||
const [pluginResources, setPluginResources] = useState<PluginResourceEntry[]>([]);
|
||||
const [expandedPluginResourceIDs, setExpandedPluginResourceIDs] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const languageMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const themeMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -235,6 +277,7 @@ export function MainLayout() {
|
||||
const fullBrandName = 'CLI Proxy API Management Center';
|
||||
const abbrBrandName = t('title.abbr');
|
||||
const isLogsPage = location.pathname.startsWith('/logs');
|
||||
const isPluginResourcePage = location.pathname.startsWith('/plugin-pages');
|
||||
const showSidebarLabels = !sidebarCollapsed || sidebarOpen;
|
||||
|
||||
// Keep floating header height available to sticky mobile elements and overlays.
|
||||
@@ -385,7 +428,78 @@ export function MainLayout() {
|
||||
});
|
||||
}, [fetchConfig]);
|
||||
|
||||
const navGroups = [
|
||||
const loadPluginResources = useCallback(async () => {
|
||||
if (connectionStatus !== 'connected') {
|
||||
setPluginResources([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const plugins = await pluginsApi.list();
|
||||
setPluginResources(collectPluginResourceEntries(plugins.plugins));
|
||||
} catch {
|
||||
setPluginResources([]);
|
||||
}
|
||||
}, [connectionStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void loadPluginResources();
|
||||
}, 0);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [apiBase, loadPluginResources]);
|
||||
|
||||
const pluginResourceGroups = pluginResources.reduce<
|
||||
Array<{ pluginID: string; pluginTitle: string; entries: PluginResourceEntry[] }>
|
||||
>((groups, resource) => {
|
||||
const group = groups.find((item) => item.pluginID === resource.pluginID);
|
||||
if (group) {
|
||||
group.entries.push(resource);
|
||||
return groups;
|
||||
}
|
||||
|
||||
groups.push({
|
||||
pluginID: resource.pluginID,
|
||||
pluginTitle: resource.pluginTitle,
|
||||
entries: [resource],
|
||||
});
|
||||
return groups;
|
||||
}, []);
|
||||
|
||||
const pluginPageNavItems: SidebarNavItem[] = pluginResourceGroups.flatMap(
|
||||
(group): SidebarNavItem[] => {
|
||||
if (group.entries.length === 1) {
|
||||
const resource = group.entries[0];
|
||||
return [
|
||||
{
|
||||
path: resource.route,
|
||||
label: resource.label,
|
||||
meta: resource.description,
|
||||
icon: sidebarIcons.plugins,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
kind: 'drawer',
|
||||
id: `plugin-pages-${group.pluginID}`,
|
||||
label: group.pluginTitle,
|
||||
meta: t('plugin_resource.page_count', { count: group.entries.length }),
|
||||
icon: sidebarIcons.plugins,
|
||||
children: group.entries.map((resource) => ({
|
||||
path: resource.route,
|
||||
label: resource.label,
|
||||
meta: resource.description,
|
||||
icon: <span className="nav-sub-dot" aria-hidden="true" />,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
const navGroups: SidebarNavGroup[] = [
|
||||
{
|
||||
id: 'operate',
|
||||
labelKey: 'nav_groups.operate',
|
||||
@@ -440,6 +554,15 @@ export function MainLayout() {
|
||||
},
|
||||
],
|
||||
},
|
||||
...(pluginPageNavItems.length > 0
|
||||
? [
|
||||
{
|
||||
id: 'plugin-pages',
|
||||
labelKey: 'nav_groups.plugin_pages',
|
||||
items: pluginPageNavItems,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'control',
|
||||
labelKey: 'nav_groups.control',
|
||||
@@ -465,7 +588,7 @@ export function MainLayout() {
|
||||
],
|
||||
},
|
||||
];
|
||||
const navItems = navGroups.flatMap((group) => group.items);
|
||||
const navItems = navGroups.flatMap((group) => flattenNavItems(group.items));
|
||||
const navOrder = navItems.map((item) => item.path);
|
||||
const getRouteOrder = (pathname: string) => {
|
||||
const trimmedPath =
|
||||
@@ -526,6 +649,7 @@ export function MainLayout() {
|
||||
clearCache();
|
||||
const results = await Promise.allSettled([
|
||||
fetchConfig(undefined, true),
|
||||
loadPluginResources(),
|
||||
triggerHeaderRefresh(),
|
||||
]);
|
||||
const rejected = results.find((result) => result.status === 'rejected');
|
||||
@@ -541,12 +665,93 @@ export function MainLayout() {
|
||||
}
|
||||
showNotification(t('notification.data_refreshed'), 'success');
|
||||
};
|
||||
|
||||
const togglePluginResourceDrawer = useCallback((drawerID: string) => {
|
||||
setExpandedPluginResourceIDs((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(drawerID)) {
|
||||
next.delete(drawerID);
|
||||
} else {
|
||||
next.add(drawerID);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderNavLink = (item: SidebarNavLinkItem, className = 'nav-item') => {
|
||||
const itemLabel = item.label ?? (item.labelKey ? t(item.labelKey) : '');
|
||||
const itemMeta = item.meta ?? (item.metaKey ? t(item.metaKey) : '');
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) => `${className} ${isActive ? 'active' : ''}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
title={showSidebarLabels ? undefined : itemLabel}
|
||||
>
|
||||
<span className="nav-icon">{item.icon}</span>
|
||||
{showSidebarLabels && (
|
||||
<span className="nav-text">
|
||||
<span className="nav-label">{itemLabel}</span>
|
||||
{itemMeta ? <span className="nav-meta">{itemMeta}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNavItem = (item: SidebarNavItem) => {
|
||||
if (item.kind !== 'drawer') {
|
||||
return renderNavLink(item);
|
||||
}
|
||||
|
||||
const isActive = item.children.some((child) => child.path === location.pathname);
|
||||
const isOpen = isActive || expandedPluginResourceIDs.has(item.id);
|
||||
|
||||
return (
|
||||
<div className={`nav-drawer ${isOpen ? 'open' : ''}`} key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-item nav-drawer-toggle ${isActive ? 'active' : ''} ${
|
||||
isOpen ? 'open' : ''
|
||||
}`}
|
||||
onClick={() => togglePluginResourceDrawer(item.id)}
|
||||
title={showSidebarLabels ? undefined : item.label}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="nav-icon">{item.icon}</span>
|
||||
{showSidebarLabels && (
|
||||
<>
|
||||
<span className="nav-text">
|
||||
<span className="nav-label">{item.label}</span>
|
||||
{item.meta ? <span className="nav-meta">{item.meta}</span> : null}
|
||||
</span>
|
||||
<span className="nav-drawer-caret" aria-hidden="true">
|
||||
<IconChevronDown size={14} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="nav-sub-list">
|
||||
{item.children.map((child) => renderNavLink(child, 'nav-item nav-sub-item'))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mobileSidebarToggleLabel = sidebarOpen
|
||||
? t('sidebar.toggle_collapse', { defaultValue: 'Close navigation' })
|
||||
: t('sidebar.toggle_expand', { defaultValue: 'Open navigation' });
|
||||
|
||||
return (
|
||||
<div className={`app-shell ${sidebarCollapsed ? 'sidebar-is-collapsed' : ''}`}>
|
||||
<div
|
||||
className={`app-shell ${sidebarCollapsed ? 'sidebar-is-collapsed' : ''} ${
|
||||
isPluginResourcePage ? 'plugin-resource-shell' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="top-gradient-blur" aria-hidden="true" />
|
||||
|
||||
<header className="main-header" ref={headerRef}>
|
||||
@@ -727,33 +932,23 @@ export function MainLayout() {
|
||||
{showSidebarLabels
|
||||
? <div className="nav-group-label">{t(group.labelKey)}</div>
|
||||
: idx > 0 && <div className="nav-group-divider" aria-hidden="true" />}
|
||||
{group.items.map((item) => {
|
||||
const itemLabel = t(item.labelKey);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
title={showSidebarLabels ? undefined : itemLabel}
|
||||
>
|
||||
<span className="nav-icon">{item.icon}</span>
|
||||
{showSidebarLabels && (
|
||||
<span className="nav-text">
|
||||
<span className="nav-label">{itemLabel}</span>
|
||||
<span className="nav-meta">{t(item.metaKey)}</span>
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
{group.items.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className={`content${isLogsPage ? ' content-logs' : ''}`} ref={contentRef}>
|
||||
<main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
|
||||
<div
|
||||
className={`content${isLogsPage ? ' content-logs' : ''}${
|
||||
isPluginResourcePage ? ' content-plugin-resource' : ''
|
||||
}`}
|
||||
ref={contentRef}
|
||||
>
|
||||
<main
|
||||
className={`main-content${isLogsPage ? ' main-content-logs' : ''}${
|
||||
isPluginResourcePage ? ' main-content-plugin-resource' : ''
|
||||
}`}
|
||||
>
|
||||
<PageTransition
|
||||
render={(location) => <MainRoutes location={location} />}
|
||||
getRouteOrder={getRouteOrder}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
.page {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.frame {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.stateShell {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 70px clamp(20px, 3vw, 48px) 40px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.statusPanel {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { pluginsApi } from '@/services/api';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import type { PluginListResponse } from '@/types';
|
||||
import {
|
||||
collectPluginResourceEntries,
|
||||
resolvePluginAssetURL,
|
||||
} from './pluginResources';
|
||||
import styles from './PluginResourcePage.module.scss';
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const hasStatus = (error: unknown, status: number) =>
|
||||
isRecord(error) && error.status === status;
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string) =>
|
||||
error instanceof Error ? error.message : typeof error === 'string' ? error : fallback;
|
||||
|
||||
const safeDecodeURIComponent = (value = '') => {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const parseMenuIndex = (value = '') => {
|
||||
const index = Number.parseInt(value, 10);
|
||||
return Number.isInteger(index) && index >= 0 ? index : -1;
|
||||
};
|
||||
|
||||
export function PluginResourcePage() {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams<{ pluginId: string; menuIndex: string }>();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const apiBase = useAuthStore((state) => state.apiBase);
|
||||
|
||||
const [data, setData] = useState<PluginListResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const connected = connectionStatus === 'connected';
|
||||
const pluginID = useMemo(
|
||||
() => safeDecodeURIComponent(params.pluginId),
|
||||
[params.pluginId]
|
||||
);
|
||||
const menuIndex = useMemo(
|
||||
() => parseMenuIndex(params.menuIndex),
|
||||
[params.menuIndex]
|
||||
);
|
||||
|
||||
const loadResource = useCallback(async () => {
|
||||
if (!connected) {
|
||||
setLoading(false);
|
||||
setError(t('notification.connection_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const plugins = await pluginsApi.list();
|
||||
setData(plugins);
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
hasStatus(err, 404)
|
||||
? t('plugin_management.unsupported_backend')
|
||||
: getErrorMessage(err, t('plugin_resource.load_failed'))
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [connected, t]);
|
||||
|
||||
useHeaderRefresh(loadResource, connected);
|
||||
|
||||
useEffect(() => {
|
||||
void loadResource();
|
||||
}, [loadResource]);
|
||||
|
||||
const resource = useMemo(() => {
|
||||
const entries = collectPluginResourceEntries(data?.plugins ?? []);
|
||||
return entries.find((entry) => entry.pluginID === pluginID && entry.menuIndex === menuIndex);
|
||||
}, [data?.plugins, menuIndex, pluginID]);
|
||||
|
||||
const iframeSrc = resource ? resolvePluginAssetURL(resource.menu.path, apiBase) : '';
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
{loading ? (
|
||||
<div className={styles.stateShell}>
|
||||
<div className={styles.statusPanel}>{t('common.loading')}</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className={styles.stateShell}>
|
||||
<EmptyState title={t('plugin_resource.unavailable')} description={error} />
|
||||
</div>
|
||||
) : !resource ? (
|
||||
<div className={styles.stateShell}>
|
||||
<EmptyState
|
||||
title={t('plugin_resource.not_found')}
|
||||
description={t('plugin_resource.not_found_desc')}
|
||||
/>
|
||||
</div>
|
||||
) : !iframeSrc ? (
|
||||
<div className={styles.stateShell}>
|
||||
<EmptyState
|
||||
title={t('plugin_resource.empty_src')}
|
||||
description={t('plugin_resource.empty_src_desc')}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
className={styles.frame}
|
||||
src={iframeSrc}
|
||||
title={resource.label}
|
||||
referrerPolicy="no-referrer"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -362,7 +362,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.linkButton,
|
||||
.iconLink {
|
||||
display: inline-flex;
|
||||
min-height: 30px;
|
||||
@@ -387,15 +386,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.linkButton {
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
max-width: min(180px, 100%);
|
||||
padding: 0 10px;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.iconLink {
|
||||
width: 30px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Select } from '@/components/ui/Select';
|
||||
import { Sheet } from '@/components/ui/Sheet';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import {
|
||||
IconExternalLink,
|
||||
IconGithub,
|
||||
IconPlug,
|
||||
IconPlus,
|
||||
@@ -20,7 +19,7 @@ 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 { getPluginTitle, resolvePluginAssetURL } from './pluginResources';
|
||||
import styles from './PluginsPage.module.scss';
|
||||
|
||||
type PluginDraftValue = string | boolean | string[];
|
||||
@@ -61,9 +60,6 @@ const getPluginRawConfig = (
|
||||
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;
|
||||
@@ -310,15 +306,8 @@ export function PluginsPage() {
|
||||
});
|
||||
}, [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;
|
||||
},
|
||||
const resolvePluginAsset = useCallback(
|
||||
(value: string) => resolvePluginAssetURL(value, apiBase),
|
||||
[apiBase]
|
||||
);
|
||||
|
||||
@@ -702,7 +691,7 @@ export function PluginsPage() {
|
||||
) : (
|
||||
<div className={styles.pluginList}>
|
||||
{visiblePlugins.map((plugin) => {
|
||||
const logo = resolvePluginAssetURL(plugin.logo || plugin.metadata?.logo || '');
|
||||
const logo = resolvePluginAsset(plugin.logo || plugin.metadata?.logo || '');
|
||||
const github = plugin.metadata?.githubRepository.trim();
|
||||
const mutating = mutatingID === plugin.id;
|
||||
const version = plugin.metadata?.version;
|
||||
@@ -790,19 +779,6 @@ export function PluginsPage() {
|
||||
<IconSettings size={14} />
|
||||
{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={12} />
|
||||
{menu.menu || t('plugin_management.open_resource')}
|
||||
</a>
|
||||
))}
|
||||
{github ? (
|
||||
<a
|
||||
className={styles.iconLink}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { PluginListEntry, PluginMenu } from '@/types';
|
||||
import { normalizeApiBase } from '@/utils/connection';
|
||||
|
||||
export interface PluginResourceEntry {
|
||||
pluginID: string;
|
||||
pluginTitle: string;
|
||||
menuIndex: number;
|
||||
menu: PluginMenu;
|
||||
label: string;
|
||||
description: string;
|
||||
route: string;
|
||||
}
|
||||
|
||||
export const getPluginTitle = (plugin: PluginListEntry) =>
|
||||
plugin.metadata?.name.trim() || plugin.id;
|
||||
|
||||
export const buildPluginResourceRoute = (pluginID: string, menuIndex: number) =>
|
||||
`/plugin-pages/${encodeURIComponent(pluginID)}/${menuIndex}`;
|
||||
|
||||
export const resolvePluginAssetURL = (value: string, apiBase: 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;
|
||||
};
|
||||
|
||||
export const collectPluginResourceEntries = (
|
||||
plugins: PluginListEntry[]
|
||||
): PluginResourceEntry[] =>
|
||||
plugins.flatMap((plugin) => {
|
||||
const pluginTitle = getPluginTitle(plugin);
|
||||
|
||||
return plugin.menus
|
||||
.map((menu, menuIndex): PluginResourceEntry | null => {
|
||||
const path = menu.path.trim();
|
||||
if (!path) return null;
|
||||
|
||||
const menuLabel = menu.menu.trim();
|
||||
return {
|
||||
pluginID: plugin.id,
|
||||
pluginTitle,
|
||||
menuIndex,
|
||||
menu: { ...menu, path },
|
||||
label: menuLabel || pluginTitle,
|
||||
description: menu.description.trim() || pluginTitle,
|
||||
route: buildPluginResourceRoute(plugin.id, menuIndex),
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is PluginResourceEntry => Boolean(entry));
|
||||
});
|
||||
@@ -125,6 +125,7 @@
|
||||
"operate": "Operate",
|
||||
"gateway": "Gateway",
|
||||
"observe": "Observe",
|
||||
"plugin_pages": "Plugins",
|
||||
"control": "Control"
|
||||
},
|
||||
"nav_meta": {
|
||||
@@ -133,7 +134,7 @@
|
||||
"auth_files": "Auth files & credentials",
|
||||
"oauth": "OAuth authorization",
|
||||
"quota_management": "API keys & limits",
|
||||
"plugins": "Plugin toggles & resources",
|
||||
"plugins": "Plugin toggles & config",
|
||||
"logs": "Request tracing",
|
||||
"config_management": "Gateway configuration",
|
||||
"system_info": "Runtime & diagnostics"
|
||||
@@ -1140,6 +1141,16 @@
|
||||
"expected_object": "Enter a JSON object",
|
||||
"invalid_enum": "Choose one of the declared enum values"
|
||||
},
|
||||
"plugin_resource": {
|
||||
"title": "Plugin Page",
|
||||
"page_count": "{{count}} pages",
|
||||
"load_failed": "Failed to load plugin page",
|
||||
"unavailable": "Plugin page unavailable",
|
||||
"not_found": "Plugin page not found",
|
||||
"not_found_desc": "This plugin page is not declared by the current backend plugin list.",
|
||||
"empty_src": "Plugin page URL is empty",
|
||||
"empty_src_desc": "This plugin menu does not provide an embeddable page URL."
|
||||
},
|
||||
"system_info": {
|
||||
"title": "Management Center Info",
|
||||
"about_title": "CLI Proxy API Management Center",
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"operate": "运行",
|
||||
"gateway": "网关",
|
||||
"observe": "观测",
|
||||
"plugin_pages": "插件",
|
||||
"control": "控制"
|
||||
},
|
||||
"nav_meta": {
|
||||
@@ -133,7 +134,7 @@
|
||||
"auth_files": "Auth 文件与凭证",
|
||||
"oauth": "OAuth 授权登录",
|
||||
"quota_management": "API Key 与限额",
|
||||
"plugins": "插件启停与资源",
|
||||
"plugins": "插件启停与配置",
|
||||
"logs": "请求追踪与排查",
|
||||
"config_management": "网关基础配置",
|
||||
"system_info": "运行与诊断信息"
|
||||
@@ -1140,6 +1141,16 @@
|
||||
"expected_object": "请输入 JSON 对象",
|
||||
"invalid_enum": "请选择声明的枚举值"
|
||||
},
|
||||
"plugin_resource": {
|
||||
"title": "插件页面",
|
||||
"page_count": "{{count}} 个页面",
|
||||
"load_failed": "加载插件页面失败",
|
||||
"unavailable": "插件页面不可用",
|
||||
"not_found": "未找到插件页面",
|
||||
"not_found_desc": "该插件页面未在当前后端插件列表中声明。",
|
||||
"empty_src": "插件页面地址为空",
|
||||
"empty_src_desc": "该插件菜单未提供可嵌入的页面地址。"
|
||||
},
|
||||
"system_info": {
|
||||
"title": "管理中心信息",
|
||||
"about_title": "CLI Proxy API Management Center",
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"operate": "運行",
|
||||
"gateway": "閘道",
|
||||
"observe": "觀測",
|
||||
"plugin_pages": "插件",
|
||||
"control": "控制"
|
||||
},
|
||||
"nav_meta": {
|
||||
@@ -133,7 +134,7 @@
|
||||
"auth_files": "Auth 檔案與憑證",
|
||||
"oauth": "OAuth 授權登入",
|
||||
"quota_management": "API Key 與限額",
|
||||
"plugins": "插件啟停與資源",
|
||||
"plugins": "插件啟停與設定",
|
||||
"logs": "請求追蹤與排查",
|
||||
"config_management": "閘道基礎設定",
|
||||
"system_info": "運行與診斷資訊"
|
||||
@@ -1166,6 +1167,16 @@
|
||||
"expected_object": "請輸入 JSON 物件",
|
||||
"invalid_enum": "請選擇宣告的枚舉值"
|
||||
},
|
||||
"plugin_resource": {
|
||||
"title": "插件頁面",
|
||||
"page_count": "{{count}} 個頁面",
|
||||
"load_failed": "載入插件頁面失敗",
|
||||
"unavailable": "插件頁面不可用",
|
||||
"not_found": "找不到插件頁面",
|
||||
"not_found_desc": "目前後端插件清單未宣告此插件頁面。",
|
||||
"empty_src": "插件頁面位址為空",
|
||||
"empty_src_desc": "此插件選單未提供可嵌入的頁面位址。"
|
||||
},
|
||||
"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 { PluginResourcePage } from '@/features/plugins/PluginResourcePage';
|
||||
import { PluginsPage } from '@/features/plugins/PluginsPage';
|
||||
import { ConfigPage } from '@/pages/ConfigPage';
|
||||
import { LogsPage } from '@/pages/LogsPage';
|
||||
@@ -23,6 +24,7 @@ const mainRoutes = [
|
||||
{ path: '/auth-files/oauth-model-alias', element: <AuthFilesOAuthModelAliasEditPage /> },
|
||||
{ path: '/oauth', element: <OAuthPage /> },
|
||||
{ path: '/quota', element: <QuotaPage /> },
|
||||
{ path: '/plugin-pages/:pluginId/:menuIndex', element: <PluginResourcePage /> },
|
||||
{ path: '/plugins', element: <PluginsPage /> },
|
||||
{ path: '/plugins/*', element: <Navigate to="/plugins" replace /> },
|
||||
{ path: '/config', element: <ConfigPage /> },
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
overflow: visible;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&.plugin-resource-shell {
|
||||
.top-gradient-blur {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.top-gradient-blur {
|
||||
@@ -436,6 +442,16 @@
|
||||
.nav-group-divider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-drawer-caret {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-sub-list {
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
@@ -500,6 +516,39 @@
|
||||
background: color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
}
|
||||
|
||||
.nav-drawer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-drawer-toggle {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-drawer-caret {
|
||||
margin-left: auto;
|
||||
color: color-mix(in srgb, var(--text-primary) 46%, transparent);
|
||||
line-height: 0;
|
||||
transition: transform $transition-fast;
|
||||
}
|
||||
|
||||
.nav-drawer-toggle.open .nav-drawer-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.nav-sub-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-left: 21px;
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 9px 12px;
|
||||
border-radius: 11px;
|
||||
@@ -602,6 +651,28 @@
|
||||
color: color-mix(in srgb, var(--text-primary) 68%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
&.nav-sub-item {
|
||||
min-height: 34px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
.nav-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-sub-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: $radius-full;
|
||||
background: currentColor;
|
||||
opacity: 0.62;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,6 +692,11 @@
|
||||
&.content-logs {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&.content-plugin-resource {
|
||||
overflow: hidden;
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
@@ -637,6 +713,22 @@
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&.main-content-plugin-resource {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
|
||||
.page-transition,
|
||||
.page-transition__layer {
|
||||
height: 100%;
|
||||
gap: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.grid {
|
||||
@@ -783,11 +875,28 @@
|
||||
overflow-y: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&.content-plugin-resource {
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
height: 100dvh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: calc(var(--header-height) + 16px) $spacing-md $spacing-lg;
|
||||
|
||||
&.main-content-plugin-resource {
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&.main-content-logs {
|
||||
flex: 0 0 auto;
|
||||
min-height: auto;
|
||||
|
||||
Reference in New Issue
Block a user