mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-09-24 16:04:35 +08:00
964 lines
30 KiB
TypeScript
964 lines
30 KiB
TypeScript
import {
|
|
ReactNode,
|
|
SVGProps,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { NavLink, useLocation } from 'react-router-dom';
|
|
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,
|
|
IconSidebarDashboard,
|
|
IconSidebarLogs,
|
|
IconSidebarOauth,
|
|
IconSidebarPlugins,
|
|
IconSidebarProviders,
|
|
IconSidebarQuota,
|
|
IconSidebarSystem,
|
|
IconChevronDown,
|
|
} from '@/components/ui/icons';
|
|
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
|
import {
|
|
useAuthStore,
|
|
useConfigStore,
|
|
useLanguageStore,
|
|
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';
|
|
import type { Theme } from '@/types';
|
|
|
|
const sidebarIcons: Record<string, ReactNode> = {
|
|
dashboard: <IconSidebarDashboard size={18} />,
|
|
aiProviders: <IconSidebarProviders size={18} />,
|
|
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} />,
|
|
};
|
|
|
|
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,
|
|
height: 16,
|
|
viewBox: '0 0 24 24',
|
|
fill: 'none',
|
|
stroke: 'currentColor',
|
|
strokeWidth: 2,
|
|
strokeLinecap: 'round',
|
|
strokeLinejoin: 'round',
|
|
'aria-hidden': 'true',
|
|
focusable: 'false',
|
|
};
|
|
|
|
const headerIcons = {
|
|
refresh: (
|
|
<svg {...headerIconProps}>
|
|
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
|
|
<path d="M21 3v5h-5" />
|
|
</svg>
|
|
),
|
|
menu: (
|
|
<svg {...headerIconProps}>
|
|
<path d="M4 7h16" />
|
|
<path d="M4 12h16" />
|
|
<path d="M4 17h16" />
|
|
</svg>
|
|
),
|
|
close: (
|
|
<svg {...headerIconProps}>
|
|
<path d="M18 6 6 18" />
|
|
<path d="m6 6 12 12" />
|
|
</svg>
|
|
),
|
|
chevronLeft: (
|
|
<svg {...headerIconProps}>
|
|
<path d="m14 18-6-6 6-6" />
|
|
</svg>
|
|
),
|
|
chevronRight: (
|
|
<svg {...headerIconProps}>
|
|
<path d="m10 6 6 6-6 6" />
|
|
</svg>
|
|
),
|
|
language: (
|
|
<svg {...headerIconProps}>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<path d="M2 12h20" />
|
|
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
|
</svg>
|
|
),
|
|
sun: (
|
|
<svg {...headerIconProps}>
|
|
<circle cx="12" cy="12" r="4" />
|
|
<path d="M12 2v2" />
|
|
<path d="M12 20v2" />
|
|
<path d="m4.93 4.93 1.41 1.41" />
|
|
<path d="m17.66 17.66 1.41 1.41" />
|
|
<path d="M2 12h2" />
|
|
<path d="M20 12h2" />
|
|
<path d="m6.34 17.66-1.41 1.41" />
|
|
<path d="m19.07 4.93-1.41 1.41" />
|
|
</svg>
|
|
),
|
|
moon: (
|
|
<svg {...headerIconProps}>
|
|
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
|
|
</svg>
|
|
),
|
|
whiteTheme: (
|
|
<svg {...headerIconProps}>
|
|
<circle cx="12" cy="12" r="7" />
|
|
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none" />
|
|
</svg>
|
|
),
|
|
autoTheme: (
|
|
<svg {...headerIconProps}>
|
|
<defs>
|
|
<clipPath id="mainLayoutAutoThemeSunLeftHalf">
|
|
<rect x="0" y="0" width="12" height="24" />
|
|
</clipPath>
|
|
</defs>
|
|
<circle cx="12" cy="12" r="4" />
|
|
<circle
|
|
cx="12"
|
|
cy="12"
|
|
r="4"
|
|
clipPath="url(#mainLayoutAutoThemeSunLeftHalf)"
|
|
fill="currentColor"
|
|
/>
|
|
<path d="M12 2v2" />
|
|
<path d="M12 20v2" />
|
|
<path d="M4.93 4.93l1.41 1.41" />
|
|
<path d="M17.66 17.66l1.41 1.41" />
|
|
<path d="M2 12h2" />
|
|
<path d="M20 12h2" />
|
|
<path d="M6.34 17.66l-1.41 1.41" />
|
|
<path d="M19.07 4.93l-1.41 1.41" />
|
|
</svg>
|
|
),
|
|
logout: (
|
|
<svg {...headerIconProps}>
|
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
|
<path d="m16 17 5-5-5-5" />
|
|
<path d="M21 12H9" />
|
|
</svg>
|
|
),
|
|
};
|
|
|
|
const THEME_CARDS: Array<{
|
|
key: Theme;
|
|
labelKey: string;
|
|
colors: { bg: string; card: string; border: string; text: string; textMuted: string };
|
|
}> = [
|
|
{
|
|
key: 'auto',
|
|
labelKey: 'theme.auto',
|
|
colors: {
|
|
bg: 'linear-gradient(135deg, #ffffff 0 50%, #111111 50% 100%)',
|
|
card: 'linear-gradient(135deg, #ffffff 0 50%, #1a1a1a 50% 100%)',
|
|
border: '#bdbdbd',
|
|
text: '#2d2a26',
|
|
textMuted: 'linear-gradient(135deg, #c9c9c9 0 50%, #5a5a5a 50% 100%)',
|
|
},
|
|
},
|
|
{
|
|
key: 'white',
|
|
labelKey: 'theme.white',
|
|
colors: {
|
|
bg: '#ffffff',
|
|
card: '#ffffff',
|
|
border: '#e5e5e5',
|
|
text: '#2d2a26',
|
|
textMuted: '#a29c95',
|
|
},
|
|
},
|
|
{
|
|
key: 'light',
|
|
labelKey: 'theme.light',
|
|
colors: {
|
|
bg: '#faf9f5',
|
|
card: '#f0eee8',
|
|
border: '#e3e1db',
|
|
text: '#2d2a26',
|
|
textMuted: '#a29c95',
|
|
},
|
|
},
|
|
{
|
|
key: 'dark',
|
|
labelKey: 'theme.dark',
|
|
colors: {
|
|
bg: '#151412',
|
|
card: '#1d1b18',
|
|
border: '#3a3530',
|
|
text: '#f6f4f1',
|
|
textMuted: '#9c958d',
|
|
},
|
|
},
|
|
];
|
|
|
|
export function MainLayout() {
|
|
const { t } = useTranslation();
|
|
const { showNotification } = useNotificationStore();
|
|
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);
|
|
|
|
const theme = useThemeStore((state) => state.theme);
|
|
const setTheme = useThemeStore((state) => state.setTheme);
|
|
const language = useLanguageStore((state) => state.language);
|
|
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
|
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
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);
|
|
const headerRef = useRef<HTMLElement | null>(null);
|
|
|
|
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.
|
|
useLayoutEffect(() => {
|
|
const updateHeaderHeight = () => {
|
|
const height = headerRef.current?.offsetHeight;
|
|
if (height) {
|
|
document.documentElement.style.setProperty('--header-height', `${height}px`);
|
|
}
|
|
};
|
|
|
|
updateHeaderHeight();
|
|
|
|
const resizeObserver =
|
|
typeof ResizeObserver !== 'undefined' && headerRef.current
|
|
? new ResizeObserver(updateHeaderHeight)
|
|
: null;
|
|
if (resizeObserver && headerRef.current) {
|
|
resizeObserver.observe(headerRef.current);
|
|
}
|
|
|
|
window.addEventListener('resize', updateHeaderHeight);
|
|
|
|
return () => {
|
|
if (resizeObserver) {
|
|
resizeObserver.disconnect();
|
|
}
|
|
window.removeEventListener('resize', updateHeaderHeight);
|
|
};
|
|
}, []);
|
|
|
|
// Keep the content center available to bottom overlays that align with the main area.
|
|
useLayoutEffect(() => {
|
|
const updateContentCenter = () => {
|
|
const el = contentRef.current;
|
|
if (!el) return;
|
|
const rect = el.getBoundingClientRect();
|
|
const centerX = rect.left + rect.width / 2;
|
|
document.documentElement.style.setProperty('--content-center-x', `${centerX}px`);
|
|
};
|
|
|
|
updateContentCenter();
|
|
|
|
const resizeObserver =
|
|
typeof ResizeObserver !== 'undefined' && contentRef.current
|
|
? new ResizeObserver(updateContentCenter)
|
|
: null;
|
|
|
|
if (resizeObserver && contentRef.current) {
|
|
resizeObserver.observe(contentRef.current);
|
|
}
|
|
|
|
window.addEventListener('resize', updateContentCenter);
|
|
|
|
return () => {
|
|
if (resizeObserver) {
|
|
resizeObserver.disconnect();
|
|
}
|
|
window.removeEventListener('resize', updateContentCenter);
|
|
document.documentElement.style.removeProperty('--content-center-x');
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!languageMenuOpen) {
|
|
return;
|
|
}
|
|
|
|
const handlePointerDown = (event: MouseEvent) => {
|
|
if (!languageMenuRef.current?.contains(event.target as Node)) {
|
|
setLanguageMenuOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleEscape = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
setLanguageMenuOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousedown', handlePointerDown);
|
|
document.addEventListener('keydown', handleEscape);
|
|
|
|
return () => {
|
|
document.removeEventListener('mousedown', handlePointerDown);
|
|
document.removeEventListener('keydown', handleEscape);
|
|
};
|
|
}, [languageMenuOpen]);
|
|
|
|
useEffect(() => {
|
|
if (!themeMenuOpen) {
|
|
return;
|
|
}
|
|
|
|
const handlePointerDown = (event: MouseEvent) => {
|
|
if (!themeMenuRef.current?.contains(event.target as Node)) {
|
|
setThemeMenuOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleEscape = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
setThemeMenuOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousedown', handlePointerDown);
|
|
document.addEventListener('keydown', handleEscape);
|
|
|
|
return () => {
|
|
document.removeEventListener('mousedown', handlePointerDown);
|
|
document.removeEventListener('keydown', handleEscape);
|
|
};
|
|
}, [themeMenuOpen]);
|
|
|
|
const toggleLanguageMenu = useCallback(() => {
|
|
setLanguageMenuOpen((prev) => !prev);
|
|
setThemeMenuOpen(false);
|
|
}, []);
|
|
|
|
const toggleThemeMenu = useCallback(() => {
|
|
setThemeMenuOpen((prev) => !prev);
|
|
setLanguageMenuOpen(false);
|
|
}, []);
|
|
|
|
const handleThemeSelect = useCallback(
|
|
(nextTheme: Theme) => {
|
|
setTheme(nextTheme);
|
|
setThemeMenuOpen(false);
|
|
},
|
|
[setTheme]
|
|
);
|
|
|
|
const handleLanguageSelect = useCallback(
|
|
(nextLanguage: string) => {
|
|
if (!isSupportedLanguage(nextLanguage)) {
|
|
return;
|
|
}
|
|
setLanguage(nextLanguage);
|
|
setLanguageMenuOpen(false);
|
|
},
|
|
[setLanguage]
|
|
);
|
|
|
|
useEffect(() => {
|
|
fetchConfig().catch(() => {
|
|
// Ignore the initial failure; the login flow shows the user-facing prompt.
|
|
});
|
|
}, [fetchConfig]);
|
|
|
|
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',
|
|
items: [
|
|
{
|
|
path: '/',
|
|
labelKey: 'nav.dashboard',
|
|
metaKey: 'nav_meta.dashboard',
|
|
icon: sidebarIcons.dashboard,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'gateway',
|
|
labelKey: 'nav_groups.gateway',
|
|
items: [
|
|
{
|
|
path: '/ai-providers',
|
|
labelKey: 'nav.ai_providers',
|
|
metaKey: 'nav_meta.ai_providers',
|
|
icon: sidebarIcons.aiProviders,
|
|
},
|
|
{
|
|
path: '/auth-files',
|
|
labelKey: 'nav.auth_files',
|
|
metaKey: 'nav_meta.auth_files',
|
|
icon: sidebarIcons.authFiles,
|
|
},
|
|
{
|
|
path: '/oauth',
|
|
labelKey: 'nav.oauth',
|
|
metaKey: 'nav_meta.oauth',
|
|
icon: sidebarIcons.oauth,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'observe',
|
|
labelKey: 'nav_groups.observe',
|
|
items: [
|
|
{
|
|
path: '/quota',
|
|
labelKey: 'nav.quota_management',
|
|
metaKey: 'nav_meta.quota_management',
|
|
icon: sidebarIcons.quota,
|
|
},
|
|
{
|
|
path: '/logs',
|
|
labelKey: 'nav.logs',
|
|
metaKey: 'nav_meta.logs',
|
|
icon: sidebarIcons.logs,
|
|
},
|
|
],
|
|
},
|
|
...(pluginPageNavItems.length > 0
|
|
? [
|
|
{
|
|
id: 'plugin-pages',
|
|
labelKey: 'nav_groups.plugin_pages',
|
|
items: pluginPageNavItems,
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
id: 'control',
|
|
labelKey: 'nav_groups.control',
|
|
items: [
|
|
{
|
|
path: '/config',
|
|
labelKey: 'nav.config_management',
|
|
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',
|
|
metaKey: 'nav_meta.system_info',
|
|
icon: sidebarIcons.system,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
const navItems = navGroups.flatMap((group) => flattenNavItems(group.items));
|
|
const navOrder = navItems.map((item) => item.path);
|
|
const getRouteOrder = (pathname: string) => {
|
|
const trimmedPath =
|
|
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
|
const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath;
|
|
|
|
const aiProvidersIndex = navOrder.indexOf('/ai-providers');
|
|
if (aiProvidersIndex !== -1) {
|
|
if (normalizedPath === '/ai-providers') return aiProvidersIndex;
|
|
if (normalizedPath.startsWith('/ai-providers/')) {
|
|
if (normalizedPath.startsWith('/ai-providers/gemini')) return aiProvidersIndex + 0.1;
|
|
if (normalizedPath.startsWith('/ai-providers/codex')) return aiProvidersIndex + 0.2;
|
|
if (normalizedPath.startsWith('/ai-providers/claude')) return aiProvidersIndex + 0.3;
|
|
if (normalizedPath.startsWith('/ai-providers/vertex')) return aiProvidersIndex + 0.4;
|
|
if (normalizedPath.startsWith('/ai-providers/ampcode')) return aiProvidersIndex + 0.5;
|
|
if (normalizedPath.startsWith('/ai-providers/openai')) return aiProvidersIndex + 0.6;
|
|
return aiProvidersIndex + 0.05;
|
|
}
|
|
}
|
|
|
|
const authFilesIndex = navOrder.indexOf('/auth-files');
|
|
if (authFilesIndex !== -1) {
|
|
if (normalizedPath === '/auth-files') return authFilesIndex;
|
|
if (normalizedPath.startsWith('/auth-files/')) {
|
|
if (normalizedPath.startsWith('/auth-files/oauth-excluded')) return authFilesIndex + 0.1;
|
|
if (normalizedPath.startsWith('/auth-files/oauth-model-alias')) return authFilesIndex + 0.2;
|
|
return authFilesIndex + 0.05;
|
|
}
|
|
}
|
|
|
|
const exactIndex = navOrder.indexOf(normalizedPath);
|
|
if (exactIndex !== -1) return exactIndex;
|
|
const nestedIndex = navOrder.findIndex(
|
|
(path) => path !== '/' && normalizedPath.startsWith(`${path}/`)
|
|
);
|
|
return nestedIndex === -1 ? null : nestedIndex;
|
|
};
|
|
|
|
const getTransitionVariant = useCallback((fromPathname: string, toPathname: string) => {
|
|
const normalize = (pathname: string) => {
|
|
const trimmed =
|
|
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
|
return trimmed === '/dashboard' ? '/' : trimmed;
|
|
};
|
|
|
|
const from = normalize(fromPathname);
|
|
const to = normalize(toPathname);
|
|
const isAuthFiles = (pathname: string) =>
|
|
pathname === '/auth-files' || pathname.startsWith('/auth-files/');
|
|
const isAiProviders = (pathname: string) =>
|
|
pathname === '/ai-providers' || pathname.startsWith('/ai-providers/');
|
|
if (isAuthFiles(from) && isAuthFiles(to)) return 'ios';
|
|
if (isAiProviders(from) && isAiProviders(to)) return 'ios';
|
|
return 'vertical';
|
|
}, []);
|
|
|
|
const handleRefreshAll = async () => {
|
|
clearCache();
|
|
const results = await Promise.allSettled([
|
|
fetchConfig(undefined, true),
|
|
loadPluginResources(),
|
|
triggerHeaderRefresh(),
|
|
]);
|
|
const rejected = results.find((result) => result.status === 'rejected');
|
|
if (rejected && rejected.status === 'rejected') {
|
|
const reason = rejected.reason;
|
|
const message =
|
|
typeof reason === 'string' ? reason : reason instanceof Error ? reason.message : '';
|
|
showNotification(
|
|
`${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
|
|
'error'
|
|
);
|
|
return;
|
|
}
|
|
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' : ''} ${
|
|
isPluginResourcePage ? 'plugin-resource-shell' : ''
|
|
}`}
|
|
>
|
|
<div className="top-gradient-blur" aria-hidden="true" />
|
|
|
|
<header className="main-header" ref={headerRef}>
|
|
<button
|
|
type="button"
|
|
className="sidebar-toggle-floating"
|
|
onClick={() => setSidebarCollapsed((prev) => !prev)}
|
|
title={
|
|
sidebarCollapsed
|
|
? t('sidebar.expand', { defaultValue: '展开' })
|
|
: t('sidebar.collapse', { defaultValue: '收起' })
|
|
}
|
|
aria-label={
|
|
sidebarCollapsed
|
|
? t('sidebar.expand', { defaultValue: '展开' })
|
|
: t('sidebar.collapse', { defaultValue: '收起' })
|
|
}
|
|
>
|
|
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
|
|
</button>
|
|
|
|
<div className="mobile-sidebar-actions">
|
|
<Button
|
|
className="mobile-menu-btn"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setSidebarOpen((prev) => !prev)}
|
|
title={mobileSidebarToggleLabel}
|
|
aria-label={mobileSidebarToggleLabel}
|
|
>
|
|
{sidebarOpen ? headerIcons.close : headerIcons.menu}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="header-actions floating-actions">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleRefreshAll}
|
|
title={t('header.refresh_all')}
|
|
>
|
|
{headerIcons.refresh}
|
|
</Button>
|
|
<div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={toggleLanguageMenu}
|
|
title={t('language.switch')}
|
|
aria-label={t('language.switch')}
|
|
aria-haspopup="menu"
|
|
aria-expanded={languageMenuOpen}
|
|
>
|
|
{headerIcons.language}
|
|
</Button>
|
|
{languageMenuOpen && (
|
|
<div
|
|
className="notification entering language-menu-popover"
|
|
role="menu"
|
|
aria-label={t('language.switch')}
|
|
>
|
|
{LANGUAGE_ORDER.map((lang) => (
|
|
<button
|
|
key={lang}
|
|
type="button"
|
|
className={`language-menu-option ${language === lang ? 'active' : ''}`}
|
|
onClick={() => handleLanguageSelect(lang)}
|
|
role="menuitemradio"
|
|
aria-checked={language === lang}
|
|
>
|
|
<span>{t(LANGUAGE_LABEL_KEYS[lang])}</span>
|
|
{language === lang ? <span className="language-menu-check">✓</span> : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className={`theme-menu ${themeMenuOpen ? 'open' : ''}`} ref={themeMenuRef}>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={toggleThemeMenu}
|
|
title={t('theme.switch')}
|
|
aria-label={t('theme.switch')}
|
|
aria-haspopup="menu"
|
|
aria-expanded={themeMenuOpen}
|
|
>
|
|
{theme === 'auto'
|
|
? headerIcons.autoTheme
|
|
: theme === 'dark'
|
|
? headerIcons.moon
|
|
: theme === 'white'
|
|
? headerIcons.whiteTheme
|
|
: headerIcons.sun}
|
|
</Button>
|
|
{themeMenuOpen && (
|
|
<div
|
|
className="notification entering theme-menu-popover"
|
|
role="menu"
|
|
aria-label={t('theme.switch')}
|
|
>
|
|
{THEME_CARDS.map((tc) => (
|
|
<button
|
|
key={tc.key}
|
|
type="button"
|
|
className={`theme-card ${theme === tc.key ? 'active' : ''}`}
|
|
onClick={() => handleThemeSelect(tc.key)}
|
|
role="menuitemradio"
|
|
aria-checked={theme === tc.key}
|
|
>
|
|
<div
|
|
className="theme-card-preview"
|
|
style={{
|
|
background: tc.colors.bg,
|
|
border: `1px solid ${tc.colors.border}`,
|
|
}}
|
|
>
|
|
<div
|
|
className="theme-card-header"
|
|
style={{
|
|
background: tc.colors.card,
|
|
borderBottom: `1px solid ${tc.colors.border}`,
|
|
}}
|
|
/>
|
|
<div className="theme-card-body">
|
|
<div
|
|
className="theme-card-sidebar"
|
|
style={{
|
|
background: tc.colors.card,
|
|
borderRight: `1px solid ${tc.colors.border}`,
|
|
}}
|
|
/>
|
|
<div className="theme-card-content" style={{ background: tc.colors.bg }}>
|
|
<div
|
|
className="theme-card-line"
|
|
style={{ background: tc.colors.textMuted }}
|
|
/>
|
|
<div
|
|
className="theme-card-line short"
|
|
style={{ background: tc.colors.textMuted }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span className="theme-card-label">{t(tc.labelKey)}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
|
|
{headerIcons.logout}
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="main-body">
|
|
<button
|
|
type="button"
|
|
className={`sidebar-backdrop ${sidebarOpen ? 'visible' : ''}`}
|
|
onClick={() => setSidebarOpen(false)}
|
|
aria-label={t('common.close')}
|
|
aria-hidden={!sidebarOpen}
|
|
tabIndex={sidebarOpen ? 0 : -1}
|
|
/>
|
|
|
|
<aside
|
|
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
|
|
>
|
|
<div className="sidebar-brand" title={fullBrandName}>
|
|
<img src={INLINE_LOGO_JPEG} alt="CPAMC logo" className="sidebar-brand-logo" />
|
|
{showSidebarLabels && <span className="sidebar-brand-title">{abbrBrandName}</span>}
|
|
</div>
|
|
|
|
<div className="nav-section">
|
|
{navGroups.map((group, idx) => (
|
|
<div className="nav-group" key={group.id}>
|
|
{showSidebarLabels
|
|
? <div className="nav-group-label">{t(group.labelKey)}</div>
|
|
: idx > 0 && <div className="nav-group-divider" aria-hidden="true" />}
|
|
{group.items.map((item) => renderNavItem(item))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
|
|
<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}
|
|
getTransitionVariant={getTransitionVariant}
|
|
scrollContainerRef={contentRef}
|
|
/>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|