mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
feat(login): add auto-login splash UI and simplify app startup
This commit is contained in:
39
src/App.tsx
39
src/App.tsx
@@ -1,33 +1,21 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { HashRouter, Route, Routes } from 'react-router-dom';
|
import { HashRouter, Route, Routes } from 'react-router-dom';
|
||||||
import { LoginPage } from '@/pages/LoginPage';
|
import { LoginPage } from '@/pages/LoginPage';
|
||||||
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
||||||
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
|
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
|
||||||
import { SplashScreen } from '@/components/common/SplashScreen';
|
|
||||||
import { MainLayout } from '@/components/layout/MainLayout';
|
import { MainLayout } from '@/components/layout/MainLayout';
|
||||||
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
||||||
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores';
|
import { useLanguageStore, useThemeStore } from '@/stores';
|
||||||
|
|
||||||
const SPLASH_DURATION = 1500;
|
|
||||||
const SPLASH_FADE_DURATION = 400;
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
||||||
const language = useLanguageStore((state) => state.language);
|
const language = useLanguageStore((state) => state.language);
|
||||||
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
|
||||||
|
|
||||||
const [splashReadyToFade, setSplashReadyToFade] = useState(false);
|
|
||||||
const [showSplash, setShowSplash] = useState(true);
|
|
||||||
const [authReady, setAuthReady] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanupTheme = initializeTheme();
|
const cleanupTheme = initializeTheme();
|
||||||
void restoreSession().finally(() => {
|
|
||||||
setAuthReady(true);
|
|
||||||
});
|
|
||||||
return cleanupTheme;
|
return cleanupTheme;
|
||||||
}, [initializeTheme, restoreSession]);
|
}, [initializeTheme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLanguage(language);
|
setLanguage(language);
|
||||||
@@ -38,27 +26,6 @@ function App() {
|
|||||||
document.documentElement.lang = language;
|
document.documentElement.lang = language;
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setSplashReadyToFade(true);
|
|
||||||
}, SPLASH_DURATION - SPLASH_FADE_DURATION);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSplashFinish = useCallback(() => {
|
|
||||||
setShowSplash(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (showSplash) {
|
|
||||||
return (
|
|
||||||
<SplashScreen
|
|
||||||
fadeOut={splashReadyToFade && authReady}
|
|
||||||
onFinish={handleSplashFinish}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<NotificationContainer />
|
<NotificationContainer />
|
||||||
|
|||||||
@@ -37,9 +37,21 @@
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 品牌大字淡入动画
|
||||||
|
@keyframes brandFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: var(--target-opacity, 0.9);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 品牌大字
|
// 品牌大字
|
||||||
.brandWord {
|
.brandWord {
|
||||||
font-size: 13vw;
|
font-size: 14vw;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
@@ -48,18 +60,23 @@
|
|||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
|
opacity: 0;
|
||||||
|
animation: brandFadeIn 0.8s ease-out forwards;
|
||||||
|
|
||||||
// 不同字有不同的透明度形成层次感
|
// 不同字有不同的透明度和延迟,从上到下依次显现
|
||||||
&:nth-child(1) {
|
&:nth-child(1) {
|
||||||
color: rgba(255, 255, 255, 0.95);
|
--target-opacity: 0.95;
|
||||||
|
animation-delay: 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:nth-child(2) {
|
&:nth-child(2) {
|
||||||
color: rgba(255, 255, 255, 0.7);
|
--target-opacity: 0.7;
|
||||||
|
animation-delay: 0.35s;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:nth-child(3) {
|
&:nth-child(3) {
|
||||||
color: rgba(255, 255, 255, 0.45);
|
--target-opacity: 0.45;
|
||||||
|
animation-delay: 0.6s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +111,7 @@
|
|||||||
.logo {
|
.logo {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
border-radius: $radius-full;
|
border-radius: $radius-lg;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
border: 3px solid var(--border-color);
|
border: 3px solid var(--border-color);
|
||||||
@@ -211,24 +228,101 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动登录提示
|
// ========== 启动动画(右侧) ==========
|
||||||
.autoLoginBox {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px dashed var(--border-color);
|
|
||||||
border-radius: $radius-md;
|
|
||||||
padding: $spacing-md;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $spacing-xs;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
.label {
|
// 启动动画进入效果
|
||||||
color: var(--text-secondary);
|
@keyframes splashEnter {
|
||||||
font-size: 14px;
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(20px);
|
||||||
}
|
}
|
||||||
|
to {
|
||||||
.value {
|
opacity: 1;
|
||||||
font-weight: 600;
|
transform: scale(1) translateY(0);
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Logo 脉冲效果
|
||||||
|
@keyframes splashLogoPulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载条动画
|
||||||
|
@keyframes splashLoading {
|
||||||
|
0% {
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scaleX(1);
|
||||||
|
transform-origin: left;
|
||||||
|
}
|
||||||
|
50.01% {
|
||||||
|
transform-origin: right;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动动画内容容器
|
||||||
|
.splashContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
animation: splashEnter 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动动画 Logo
|
||||||
|
.splashLogo {
|
||||||
|
height: 80px;
|
||||||
|
width: auto;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
box-shadow: $shadow-lg;
|
||||||
|
animation: splashLogoPulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动动画标题
|
||||||
|
.splashTitle {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动动画副标题
|
||||||
|
.splashSubtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
margin-top: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动动画加载条容器
|
||||||
|
.splashLoader {
|
||||||
|
width: 120px;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: $radius-full;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动动画加载条
|
||||||
|
.splashLoaderBar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-radius: $radius-full;
|
||||||
|
animation: splashLoading 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function LoginPage() {
|
|||||||
const [rememberPassword, setRememberPassword] = useState(false);
|
const [rememberPassword, setRememberPassword] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [autoLoading, setAutoLoading] = useState(true);
|
const [autoLoading, setAutoLoading] = useState(true);
|
||||||
|
const [autoLoginSuccess, setAutoLoginSuccess] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
||||||
@@ -39,18 +40,28 @@ export function LoginPage() {
|
|||||||
const init = async () => {
|
const init = async () => {
|
||||||
try {
|
try {
|
||||||
const autoLoggedIn = await restoreSession();
|
const autoLoggedIn = await restoreSession();
|
||||||
if (!autoLoggedIn) {
|
if (autoLoggedIn) {
|
||||||
|
setAutoLoginSuccess(true);
|
||||||
|
// 延迟跳转,让用户看到成功动画
|
||||||
|
setTimeout(() => {
|
||||||
|
const redirect = (location.state as any)?.from?.pathname || '/';
|
||||||
|
navigate(redirect, { replace: true });
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
setApiBase(storedBase || detectedBase);
|
setApiBase(storedBase || detectedBase);
|
||||||
setManagementKey(storedKey || '');
|
setManagementKey(storedKey || '');
|
||||||
setRememberPassword(storedRememberPassword || Boolean(storedKey));
|
setRememberPassword(storedRememberPassword || Boolean(storedKey));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!autoLoginSuccess) {
|
||||||
setAutoLoading(false);
|
setAutoLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
init();
|
init();
|
||||||
}, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!managementKey.trim()) {
|
if (!managementKey.trim()) {
|
||||||
@@ -88,11 +99,14 @@ export function LoginPage() {
|
|||||||
[loading, handleSubmit]
|
[loading, handleSubmit]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated && !autoLoading && !autoLoginSuccess) {
|
||||||
const redirect = (location.state as any)?.from?.pathname || '/';
|
const redirect = (location.state as any)?.from?.pathname || '/';
|
||||||
return <Navigate to={redirect} replace />;
|
return <Navigate to={redirect} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 显示启动动画(自动登录中或自动登录成功)
|
||||||
|
const showSplash = autoLoading || autoLoginSuccess;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* 左侧品牌展示区 */}
|
{/* 左侧品牌展示区 */}
|
||||||
@@ -106,6 +120,18 @@ export function LoginPage() {
|
|||||||
|
|
||||||
{/* 右侧功能交互区 */}
|
{/* 右侧功能交互区 */}
|
||||||
<div className={styles.formPanel}>
|
<div className={styles.formPanel}>
|
||||||
|
{showSplash ? (
|
||||||
|
/* 启动动画 */
|
||||||
|
<div className={styles.splashContent}>
|
||||||
|
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.splashLogo} />
|
||||||
|
<h1 className={styles.splashTitle}>CLI Proxy API</h1>
|
||||||
|
<p className={styles.splashSubtitle}>Management Center</p>
|
||||||
|
<div className={styles.splashLoader}>
|
||||||
|
<div className={styles.splashLoaderBar} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 登录表单 */
|
||||||
<div className={styles.formContent}>
|
<div className={styles.formContent}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<img src={INLINE_LOGO_JPEG} alt="Logo" className={styles.logo} />
|
<img src={INLINE_LOGO_JPEG} alt="Logo" className={styles.logo} />
|
||||||
@@ -200,16 +226,10 @@ export function LoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{error && <div className={styles.errorBox}>{error}</div>}
|
{error && <div className={styles.errorBox}>{error}</div>}
|
||||||
|
</div>
|
||||||
{autoLoading && (
|
|
||||||
<div className={styles.autoLoginBox}>
|
|
||||||
<div className={styles.label}>{t('auto_login.title')}</div>
|
|
||||||
<div className={styles.value}>{t('auto_login.message')}</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user