import { ReactNode, SVGProps, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { NavLink, Outlet } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; import { Modal } from '@/components/ui/Modal'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { IconBot, IconChartLine, IconFileText, IconInfo, IconKey, IconLayoutDashboard, IconScrollText, IconSettings, IconShield, IconSlidersHorizontal } from '@/components/ui/icons'; import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, useThemeStore } from '@/stores'; import { configApi, versionApi } from '@/services/api'; const sidebarIcons: Record = { dashboard: , settings: , apiKeys: , aiProviders: , authFiles: , oauth: , usage: , config: , logs: , system: }; // Header action icons - smaller size for header buttons const headerIconProps: SVGProps = { 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: ( ), update: ( ), menu: ( ), chevronLeft: ( ), chevronRight: ( ), language: ( ), sun: ( ), moon: ( ), logout: ( ) }; 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, i18n } = useTranslation(); const { showNotification } = useNotificationStore(); const apiBase = useAuthStore((state) => state.apiBase); const serverVersion = useAuthStore((state) => state.serverVersion); const serverBuildDate = useAuthStore((state) => state.serverBuildDate); 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 updateConfigValue = useConfigStore((state) => state.updateConfigValue); const theme = useThemeStore((state) => state.theme); const toggleTheme = useThemeStore((state) => state.toggleTheme); const toggleLanguage = useLanguageStore((state) => state.toggleLanguage); const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [checkingVersion, setCheckingVersion] = useState(false); const [brandExpanded, setBrandExpanded] = useState(true); const [requestLogModalOpen, setRequestLogModalOpen] = useState(false); const [requestLogDraft, setRequestLogDraft] = useState(false); const [requestLogTouched, setRequestLogTouched] = useState(false); const [requestLogSaving, setRequestLogSaving] = useState(false); const brandCollapseTimer = useRef | null>(null); const headerRef = useRef(null); const versionTapCount = useRef(0); const versionTapTimer = useRef | null>(null); const fullBrandName = 'CLI Proxy API Management Center'; const abbrBrandName = t('title.abbr'); const requestLogEnabled = config?.requestLog ?? false; const requestLogDirty = requestLogDraft !== requestLogEnabled; const canEditRequestLog = connectionStatus === 'connected' && Boolean(config); // 将顶栏高度写入 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); }; }, []); // 5秒后自动收起品牌名称 useEffect(() => { brandCollapseTimer.current = setTimeout(() => { setBrandExpanded(false); }, 5000); return () => { if (brandCollapseTimer.current) { clearTimeout(brandCollapseTimer.current); } }; }, []); useEffect(() => { if (requestLogModalOpen && !requestLogTouched) { setRequestLogDraft(requestLogEnabled); } }, [requestLogModalOpen, requestLogTouched, requestLogEnabled]); useEffect(() => { return () => { if (versionTapTimer.current) { clearTimeout(versionTapTimer.current); } }; }, []); const handleBrandClick = useCallback(() => { if (!brandExpanded) { setBrandExpanded(true); // 点击展开后,5秒后再次收起 if (brandCollapseTimer.current) { clearTimeout(brandCollapseTimer.current); } brandCollapseTimer.current = setTimeout(() => { setBrandExpanded(false); }, 5000); } }, [brandExpanded]); const openRequestLogModal = useCallback(() => { setRequestLogTouched(false); setRequestLogDraft(requestLogEnabled); setRequestLogModalOpen(true); }, [requestLogEnabled]); const handleRequestLogClose = useCallback(() => { setRequestLogModalOpen(false); setRequestLogTouched(false); }, []); const handleVersionTap = useCallback(() => { versionTapCount.current += 1; if (versionTapTimer.current) { clearTimeout(versionTapTimer.current); } versionTapTimer.current = setTimeout(() => { versionTapCount.current = 0; }, 1500); if (versionTapCount.current >= 7) { versionTapCount.current = 0; if (versionTapTimer.current) { clearTimeout(versionTapTimer.current); versionTapTimer.current = null; } openRequestLogModal(); } }, [openRequestLogModal]); const handleRequestLogSave = async () => { if (!canEditRequestLog) return; if (!requestLogDirty) { setRequestLogModalOpen(false); return; } const previous = requestLogEnabled; setRequestLogSaving(true); updateConfigValue('request-log', requestLogDraft); try { await configApi.updateRequestLog(requestLogDraft); clearCache('request-log'); showNotification(t('notification.request_log_updated'), 'success'); setRequestLogModalOpen(false); } catch (error: any) { updateConfigValue('request-log', previous); showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error'); } finally { setRequestLogSaving(false); } }; 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: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings }, { path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys }, { 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: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage }, { path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config }, ...(config?.loggingToFile ? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }] : []), { path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system } ]; const handleRefreshAll = async () => { clearCache(); try { await fetchConfig(undefined, true); showNotification(t('notification.data_refreshed'), 'success'); } catch (error: any) { showNotification(`${t('notification.refresh_failed')}: ${error?.message || ''}`, 'error'); } }; const handleVersionCheck = async () => { setCheckingVersion(true); try { const data = await versionApi.checkLatest(); const latest = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? ''; 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: any) { showNotification(`${t('system_info.version_check_error')}: ${error?.message || ''}`, 'error'); } finally { setCheckingVersion(false); } }; return (
CPAMC logo
{fullBrandName} {abbrBrandName}
{t( connectionStatus === 'connected' ? 'common.connected_status' : connectionStatus === 'connecting' ? 'common.connecting_status' : 'common.disconnected_status' )} {apiBase || '-'}
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')} {t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')} {t('footer.build_date')}:{' '} {serverBuildDate ? new Date(serverBuildDate).toLocaleString(i18n.language) : t('system_info.version_unknown')}
} >
{t('basic_settings.request_log_warning')}
{ setRequestLogDraft(value); setRequestLogTouched(true); }} />
); }