Files
Cli-Proxy-API-Management-Ce…/src/components/layout/MainLayout.tsx
T

617 lines
21 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 {
IconBot,
IconChartLine,
IconFileText,
IconInfo,
IconLayoutDashboard,
IconScrollText,
IconSettings,
IconShield,
IconTimer,
} from '@/components/ui/icons';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import {
useAuthStore,
useConfigStore,
useLanguageStore,
useNotificationStore,
useThemeStore,
} from '@/stores';
import { versionApi } from '@/services/api';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
import { isSupportedLanguage } from '@/utils/language';
const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard size={18} />,
aiProviders: <IconBot size={18} />,
authFiles: <IconFileText size={18} />,
oauth: <IconShield size={18} />,
quota: <IconTimer size={18} />,
usage: <IconChartLine size={18} />,
config: <IconSettings size={18} />,
logs: <IconScrollText size={18} />,
system: <IconInfo size={18} />,
};
// 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>
),
update: (
<svg {...headerIconProps}>
<path d="M12 19V5" />
<path d="m5 12 7-7 7 7" />
</svg>
),
menu: (
<svg {...headerIconProps}>
<path d="M4 7h16" />
<path d="M4 12h16" />
<path d="M4 17h16" />
</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>
),
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 parseVersionSegments = (version?: string | null) => {
if (!version) return null;
const cleaned = version.trim().replace(/^v/i, '');
if (!cleaned) return null;
const parts = cleaned
.split(/[^0-9]+/)
.filter(Boolean)
.map((segment) => Number.parseInt(segment, 10))
.filter(Number.isFinite);
return parts.length ? parts : null;
};
const compareVersions = (latest?: string | null, current?: string | null) => {
const latestParts = parseVersionSegments(latest);
const currentParts = parseVersionSegments(current);
if (!latestParts || !currentParts) return null;
const length = Math.max(latestParts.length, currentParts.length);
for (let i = 0; i < length; i++) {
const l = latestParts[i] || 0;
const c = currentParts[i] || 0;
if (l > c) return 1;
if (l < c) return -1;
}
return 0;
};
export function MainLayout() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const location = useLocation();
const apiBase = useAuthStore((state) => state.apiBase);
const serverVersion = useAuthStore((state) => state.serverVersion);
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const logout = useAuthStore((state) => state.logout);
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearCache = useConfigStore((state) => state.clearCache);
const theme = useThemeStore((state) => state.theme);
const cycleTheme = useThemeStore((state) => state.cycleTheme);
const language = useLanguageStore((state) => state.language);
const setLanguage = useLanguageStore((state) => state.setLanguage);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [checkingVersion, setCheckingVersion] = useState(false);
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [brandExpanded, setBrandExpanded] = useState(true);
const contentRef = useRef<HTMLDivElement | null>(null);
const languageMenuRef = useRef<HTMLDivElement | null>(null);
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | 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');
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
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);
};
}, []);
// 将主内容区的中心点写入 CSS 变量,供底部浮层(配置面板操作栏、提供商导航)对齐到内容区
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');
};
}, []);
// 5秒后自动收起品牌名称
useEffect(() => {
brandCollapseTimer.current = setTimeout(() => {
setBrandExpanded(false);
}, 5000);
return () => {
if (brandCollapseTimer.current) {
clearTimeout(brandCollapseTimer.current);
}
};
}, []);
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]);
const handleBrandClick = useCallback(() => {
if (!brandExpanded) {
setBrandExpanded(true);
// 点击展开后,5秒后再次收起
if (brandCollapseTimer.current) {
clearTimeout(brandCollapseTimer.current);
}
brandCollapseTimer.current = setTimeout(() => {
setBrandExpanded(false);
}, 5000);
}
}, [brandExpanded]);
const toggleLanguageMenu = useCallback(() => {
setLanguageMenuOpen((prev) => !prev);
}, []);
const handleLanguageSelect = useCallback(
(nextLanguage: string) => {
if (!isSupportedLanguage(nextLanguage)) {
return;
}
setLanguage(nextLanguage);
setLanguageMenuOpen(false);
},
[setLanguage]
);
useEffect(() => {
fetchConfig().catch(() => {
// ignore initial failure; login flow会提示
});
}, [fetchConfig]);
const statusClass =
connectionStatus === 'connected'
? 'success'
: connectionStatus === 'connecting'
? 'warning'
: connectionStatus === 'error'
? 'error'
: 'muted';
const navItems = [
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
...(config?.loggingToFile
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
: []),
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
];
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),
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 handleVersionCheck = async () => {
setCheckingVersion(true);
try {
const data = await versionApi.checkLatest();
const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? '');
const comparison = compareVersions(latest, serverVersion);
if (!latest) {
showNotification(t('system_info.version_check_error'), 'error');
return;
}
if (comparison === null) {
showNotification(t('system_info.version_current_missing'), 'warning');
return;
}
if (comparison > 0) {
showNotification(t('system_info.version_update_available', { version: latest }), 'warning');
} else {
showNotification(t('system_info.version_is_latest'), 'success');
}
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
const suffix = message ? `: ${message}` : '';
showNotification(`${t('system_info.version_check_error')}${suffix}`, 'error');
} finally {
setCheckingVersion(false);
}
};
return (
<div className="app-shell">
<header className="main-header" ref={headerRef}>
<div className="left">
<button
className="sidebar-toggle-header"
onClick={() => setSidebarCollapsed((prev) => !prev)}
title={
sidebarCollapsed
? t('sidebar.expand', { defaultValue: '展开' })
: t('sidebar.collapse', { defaultValue: '收起' })
}
>
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
</button>
<img src={INLINE_LOGO_JPEG} alt="CPAMC logo" className="brand-logo" />
<div
className={`brand-header ${brandExpanded ? 'expanded' : 'collapsed'}`}
onClick={handleBrandClick}
title={brandExpanded ? undefined : fullBrandName}
>
<span className="brand-full">{fullBrandName}</span>
<span className="brand-abbr">{abbrBrandName}</span>
</div>
</div>
<div className="right">
<div className="connection">
<span className={`status-badge ${statusClass}`}>
{t(
connectionStatus === 'connected'
? 'common.connected_status'
: connectionStatus === 'connecting'
? 'common.connecting_status'
: 'common.disconnected_status'
)}
</span>
<span className="base">{apiBase || '-'}</span>
</div>
<div className="header-actions">
<Button
className="mobile-menu-btn"
variant="ghost"
size="sm"
onClick={() => setSidebarOpen((prev) => !prev)}
>
{headerIcons.menu}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleRefreshAll}
title={t('header.refresh_all')}
>
{headerIcons.refresh}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleVersionCheck}
loading={checkingVersion}
title={t('system_info.version_check_button')}
>
{headerIcons.update}
</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>
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
{theme === 'auto'
? headerIcons.autoTheme
: theme === 'dark'
? headerIcons.moon
: headerIcons.sun}
</Button>
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
{headerIcons.logout}
</Button>
</div>
</div>
</header>
<div className="main-body">
<aside
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
>
<div className="nav-section">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
onClick={() => setSidebarOpen(false)}
title={sidebarCollapsed ? item.label : undefined}
>
<span className="nav-icon">{item.icon}</span>
{!sidebarCollapsed && <span className="nav-label">{item.label}</span>}
</NavLink>
))}
</div>
</aside>
<div className={`content${isLogsPage ? ' content-logs' : ''}`} ref={contentRef}>
<main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
<PageTransition
render={(location) => <MainRoutes location={location} />}
getRouteOrder={getRouteOrder}
getTransitionVariant={getTransitionVariant}
scrollContainerRef={contentRef}
/>
</main>
</div>
</div>
</div>
);
}