diff --git a/src/App.tsx b/src/App.tsx index d42b409..97f2e10 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { HashRouter, Navigate, Route, Routes } from 'react-router-dom'; import { LoginPage } from '@/pages/LoginPage'; +import { DashboardPage } from '@/pages/DashboardPage'; import { SettingsPage } from '@/pages/SettingsPage'; import { ApiKeysPage } from '@/pages/ApiKeysPage'; import { AiProvidersPage } from '@/pages/AiProvidersPage'; @@ -11,6 +12,7 @@ import { ConfigPage } from '@/pages/ConfigPage'; import { LogsPage } from '@/pages/LogsPage'; import { SystemPage } from '@/pages/SystemPage'; import { NotificationContainer } from '@/components/common/NotificationContainer'; +import { SplashScreen } from '@/components/common/SplashScreen'; import { MainLayout } from '@/components/layout/MainLayout'; import { ProtectedRoute } from '@/router/ProtectedRoute'; import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores'; @@ -20,6 +22,9 @@ function App() { const language = useLanguageStore((state) => state.language); const setLanguage = useLanguageStore((state) => state.setLanguage); const restoreSession = useAuthStore((state) => state.restoreSession); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + + const [showSplash, setShowSplash] = useState(true); useEffect(() => { initializeTheme(); @@ -31,6 +36,15 @@ function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 仅用于首屏同步 i18n 语言 + const handleSplashFinish = useCallback(() => { + setShowSplash(false); + }, []); + + // 仅在已认证时显示闪屏 + if (showSplash && isAuthenticated) { + return ; + } + return ( @@ -44,7 +58,8 @@ function App() { } > - } /> + } /> + } /> } /> } /> } /> @@ -54,7 +69,7 @@ function App() { } /> } /> } /> - } /> + } /> diff --git a/src/components/common/SplashScreen.scss b/src/components/common/SplashScreen.scss new file mode 100644 index 0000000..f66e1b1 --- /dev/null +++ b/src/components/common/SplashScreen.scss @@ -0,0 +1,106 @@ +@use 'sass:color'; +@use '../../styles/variables.scss' as *; + +.splash-screen { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-primary); + opacity: 1; + transition: opacity 0.4s ease-out; + + &.fade-out { + opacity: 0; + pointer-events: none; + } +} + +.splash-content { + display: flex; + flex-direction: column; + align-items: center; + gap: $spacing-md; + animation: splash-enter 0.6s ease-out; +} + +@keyframes splash-enter { + from { + opacity: 0; + transform: scale(0.9) translateY(20px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.splash-logo { + width: 80px; + height: 80px; + border-radius: $radius-lg; + box-shadow: $shadow-lg; + animation: splash-logo-pulse 1.5s ease-in-out infinite; +} + +@keyframes splash-logo-pulse { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } +} + +.splash-title { + font-size: 28px; + font-weight: 800; + color: var(--text-primary); + margin: 0; + letter-spacing: -0.5px; +} + +.splash-subtitle { + font-size: 16px; + font-weight: 500; + color: var(--text-secondary); + margin: 0; + margin-top: -8px; +} + +.splash-loader { + width: 120px; + height: 3px; + background: var(--border-color); + border-radius: $radius-full; + overflow: hidden; + margin-top: $spacing-md; +} + +.splash-loader-bar { + width: 100%; + height: 100%; + background: var(--primary-color); + border-radius: $radius-full; + animation: splash-loading 1.2s ease-in-out infinite; + transform-origin: left; +} + +@keyframes splash-loading { + 0% { + transform: scaleX(0); + } + 50% { + transform: scaleX(1); + transform-origin: left; + } + 50.01% { + transform-origin: right; + } + 100% { + transform: scaleX(0); + transform-origin: right; + } +} diff --git a/src/components/common/SplashScreen.tsx b/src/components/common/SplashScreen.tsx new file mode 100644 index 0000000..1233d8e --- /dev/null +++ b/src/components/common/SplashScreen.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; +import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; +import './SplashScreen.scss'; + +interface SplashScreenProps { + onFinish: () => void; + duration?: number; +} + +export function SplashScreen({ onFinish, duration = 1500 }: SplashScreenProps) { + const [fadeOut, setFadeOut] = useState(false); + + useEffect(() => { + const fadeTimer = setTimeout(() => { + setFadeOut(true); + }, duration - 400); + + const finishTimer = setTimeout(() => { + onFinish(); + }, duration); + + return () => { + clearTimeout(fadeTimer); + clearTimeout(finishTimer); + }; + }, [duration, onFinish]); + + return ( +
+
+ CPAMC +

CLI Proxy API

+

Management Center

+
+
+
+
+
+ ); +} diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 9410ce0..a8474e3 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -8,6 +8,7 @@ import { IconFileText, IconInfo, IconKey, + IconLayoutDashboard, IconScrollText, IconSettings, IconShield, @@ -18,6 +19,7 @@ import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, u import { versionApi } from '@/services/api'; const sidebarIcons: Record = { + dashboard: , settings: , apiKeys: , aiProviders: , @@ -230,6 +232,7 @@ export function MainLayout() { : '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 }, diff --git a/src/components/ui/icons.tsx b/src/components/ui/icons.tsx index f762c7e..de658a8 100644 --- a/src/components/ui/icons.tsx +++ b/src/components/ui/icons.tsx @@ -303,3 +303,14 @@ export function IconCode({ size = 20, ...props }: IconProps) { ); } + +export function IconLayoutDashboard({ size = 20, ...props }: IconProps) { + return ( + + + + + + + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6aa8914..51b0159 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -81,6 +81,7 @@ "status": "Connection Status:" }, "nav": { + "dashboard": "Dashboard", "basic_settings": "Basic Settings", "api_keys": "API Keys", "ai_providers": "AI Providers", @@ -91,6 +92,13 @@ "logs": "Logs Viewer", "system_info": "Management Center Info" }, + "dashboard": { + "title": "Dashboard", + "subtitle": "Welcome to CLI Proxy API Management Center", + "openai_providers": "OpenAI Providers", + "quick_actions": "Quick Actions", + "current_config": "Current Configuration" + }, "basic_settings": { "title": "Basic Settings", "debug_title": "Debug Mode", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index d9e8bb8..17cc218 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -81,6 +81,7 @@ "status": "连接状态:" }, "nav": { + "dashboard": "仪表盘", "basic_settings": "基础设置", "api_keys": "API 密钥", "ai_providers": "AI 提供商", @@ -91,6 +92,13 @@ "logs": "日志查看", "system_info": "中心信息" }, + "dashboard": { + "title": "仪表盘", + "subtitle": "欢迎使用 CLI Proxy API 管理中心", + "openai_providers": "OpenAI 提供商", + "quick_actions": "快捷操作", + "current_config": "当前配置" + }, "basic_settings": { "title": "基础设置", "debug_title": "调试模式", diff --git a/src/pages/DashboardPage.module.scss b/src/pages/DashboardPage.module.scss new file mode 100644 index 0000000..b9f6b38 --- /dev/null +++ b/src/pages/DashboardPage.module.scss @@ -0,0 +1,223 @@ +@use 'sass:color'; +@use '../styles/variables.scss' as *; + +.dashboard { + display: flex; + flex-direction: column; + gap: $spacing-lg; + max-width: 1000px; + margin: 0 auto; +} + +.header { + margin-bottom: $spacing-sm; +} + +.title { + font-size: 26px; + font-weight: 800; + color: var(--text-primary); + margin: 0; +} + +.subtitle { + font-size: 15px; + color: var(--text-secondary); + margin: $spacing-xs 0 0 0; +} + +.connectionCard { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-md; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: $radius-lg; + padding: $spacing-md $spacing-lg; + flex-wrap: wrap; +} + +.connectionStatus { + display: flex; + align-items: center; + gap: $spacing-sm; +} + +.statusDot { + width: 10px; + height: 10px; + border-radius: 50%; + background: $gray-400; + + &.connected { + background: $success-color; + box-shadow: 0 0 8px rgba($success-color, 0.5); + } + + &.connecting { + background: $warning-color; + animation: pulse 1s ease-in-out infinite; + } + + &.disconnected { + background: $error-color; + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.statusText { + font-weight: 600; + color: var(--text-primary); +} + +.connectionInfo { + display: flex; + align-items: center; + gap: $spacing-md; + flex-wrap: wrap; +} + +.serverUrl { + font-family: $font-mono; + font-size: 13px; + color: var(--text-secondary); + background: var(--bg-primary); + padding: 4px 10px; + border-radius: $radius-md; + border: 1px solid var(--border-color); +} + +.serverVersion { + font-size: 13px; + font-weight: 600; + color: var(--primary-color); + background: rgba($primary-color, 0.1); + padding: 4px 10px; + border-radius: $radius-full; +} + +.statsGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: $spacing-md; +} + +.statCard { + display: flex; + align-items: center; + gap: $spacing-md; + padding: $spacing-lg; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: $radius-lg; + text-decoration: none; + transition: all $transition-fast; + + &:hover { + border-color: var(--primary-color); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + transform: translateY(-2px); + } +} + +.statIcon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: $radius-md; + background: var(--bg-secondary); + color: var(--primary-color); +} + +.statContent { + display: flex; + flex-direction: column; + gap: 2px; +} + +.statValue { + font-size: 24px; + font-weight: 800; + color: var(--text-primary); +} + +.statLabel { + font-size: 13px; + color: var(--text-secondary); +} + +.section { + display: flex; + flex-direction: column; + gap: $spacing-md; +} + +.sectionTitle { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + margin: 0; +} + +.actionsGrid { + display: flex; + flex-wrap: wrap; + gap: $spacing-sm; + + a { + text-decoration: none; + } +} + +.actionButton { + display: inline-flex; + align-items: center; + gap: $spacing-sm; +} + +.configGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: $spacing-sm; +} + +.configItem { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-sm; + padding: $spacing-sm $spacing-md; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: $radius-md; +} + +.configLabel { + font-size: 13px; + color: var(--text-secondary); +} + +.configValue { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + + &.enabled { + color: $success-color; + } + + &.disabled { + color: var(--text-secondary); + } +} diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..87dad54 --- /dev/null +++ b/src/pages/DashboardPage.tsx @@ -0,0 +1,184 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/Button'; +import { IconKey, IconBot, IconFileText, IconChartLine, IconSettings, IconShield } from '@/components/ui/icons'; +import { useAuthStore, useConfigStore } from '@/stores'; +import { apiKeysApi, providersApi, authFilesApi } from '@/services/api'; +import styles from './DashboardPage.module.scss'; + +interface QuickStat { + label: string; + value: number | string; + icon: React.ReactNode; + path: string; + loading?: boolean; +} + +export function DashboardPage() { + const { t } = useTranslation(); + const connectionStatus = useAuthStore((state) => state.connectionStatus); + const serverVersion = useAuthStore((state) => state.serverVersion); + const apiBase = useAuthStore((state) => state.apiBase); + const config = useConfigStore((state) => state.config); + + const [stats, setStats] = useState<{ + apiKeys: number | null; + providers: number | null; + authFiles: number | null; + }>({ + apiKeys: null, + providers: null, + authFiles: null + }); + + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchStats = async () => { + setLoading(true); + try { + const [keysRes, providersRes, filesRes] = await Promise.allSettled([ + apiKeysApi.list(), + providersApi.getOpenAIProviders(), + authFilesApi.list() + ]); + + setStats({ + apiKeys: keysRes.status === 'fulfilled' ? keysRes.value.length : null, + providers: providersRes.status === 'fulfilled' ? providersRes.value.length : null, + authFiles: filesRes.status === 'fulfilled' ? filesRes.value.files.length : null + }); + } finally { + setLoading(false); + } + }; + + if (connectionStatus === 'connected') { + fetchStats(); + } + }, [connectionStatus]); + + const quickStats: QuickStat[] = [ + { + label: t('nav.api_keys'), + value: stats.apiKeys ?? '-', + icon: , + path: '/api-keys', + loading: loading && stats.apiKeys === null + }, + { + label: t('dashboard.openai_providers'), + value: stats.providers ?? '-', + icon: , + path: '/ai-providers', + loading: loading && stats.providers === null + }, + { + label: t('nav.auth_files'), + value: stats.authFiles ?? '-', + icon: , + path: '/auth-files', + loading: loading && stats.authFiles === null + } + ]; + + const quickActions = [ + { label: t('nav.basic_settings'), icon: , path: '/settings' }, + { label: t('nav.ai_providers'), icon: , path: '/ai-providers' }, + { label: t('nav.oauth'), icon: , path: '/oauth' }, + { label: t('nav.usage_stats'), icon: , path: '/usage' } + ]; + + return ( +
+
+

{t('dashboard.title')}

+

{t('dashboard.subtitle')}

+
+ +
+
+ + + {t( + connectionStatus === 'connected' + ? 'common.connected' + : connectionStatus === 'connecting' + ? 'common.connecting' + : 'common.disconnected' + )} + +
+
+ {apiBase || '-'} + {serverVersion && v{serverVersion}} +
+
+ +
+ {quickStats.map((stat) => ( + +
{stat.icon}
+
+ {stat.loading ? '...' : stat.value} + {stat.label} +
+ + ))} +
+ +
+

{t('dashboard.quick_actions')}

+
+ {quickActions.map((action) => ( + + + + ))} +
+
+ + {config && ( +
+

{t('dashboard.current_config')}

+
+
+ {t('basic_settings.debug_enable')} + + {config.debug ? t('common.yes') : t('common.no')} + +
+
+ {t('basic_settings.usage_statistics_enable')} + + {config.usageStatisticsEnabled ? t('common.yes') : t('common.no')} + +
+
+ {t('basic_settings.logging_to_file_enable')} + + {config.loggingToFile ? t('common.yes') : t('common.no')} + +
+
+ {t('basic_settings.retry_count_label')} + {config.requestRetry ?? 0} +
+
+
+ )} +
+ ); +}