feat(plugins): implement plugin resource management with dynamic routing and UI enhancements

This commit is contained in:
LTbinglingfeng
2026-06-11 22:16:51 +08:00
Unverified
parent 848eefb6e8
commit 7418fac6be
11 changed files with 586 additions and 66 deletions
+220 -25
View File
@@ -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;
}
+128
View File
@@ -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;
+4 -28
View File
@@ -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}
+52
View File
@@ -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));
});
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
+2
View File
@@ -6,6 +6,7 @@ import { AuthFilesOAuthExcludedEditPage } from '@/pages/AuthFilesOAuthExcludedEd
import { AuthFilesOAuthModelAliasEditPage } from '@/pages/AuthFilesOAuthModelAliasEditPage';
import { OAuthPage } from '@/pages/OAuthPage';
import { QuotaPage } from '@/pages/QuotaPage';
import { 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 /> },
+109
View File
@@ -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;