mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-02 19:00:49 +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 { LoginPage } from '@/pages/LoginPage';
|
||||
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
||||
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
|
||||
import { SplashScreen } from '@/components/common/SplashScreen';
|
||||
import { MainLayout } from '@/components/layout/MainLayout';
|
||||
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
||||
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores';
|
||||
|
||||
const SPLASH_DURATION = 1500;
|
||||
const SPLASH_FADE_DURATION = 400;
|
||||
import { useLanguageStore, useThemeStore } from '@/stores';
|
||||
|
||||
function App() {
|
||||
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
||||
const language = useLanguageStore((state) => state.language);
|
||||
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(() => {
|
||||
const cleanupTheme = initializeTheme();
|
||||
void restoreSession().finally(() => {
|
||||
setAuthReady(true);
|
||||
});
|
||||
return cleanupTheme;
|
||||
}, [initializeTheme, restoreSession]);
|
||||
}, [initializeTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
setLanguage(language);
|
||||
@@ -38,27 +26,6 @@ function App() {
|
||||
document.documentElement.lang = 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 (
|
||||
<HashRouter>
|
||||
<NotificationContainer />
|
||||
|
||||
@@ -37,9 +37,21 @@
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
// 品牌大字淡入动画
|
||||
@keyframes brandFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: var(--target-opacity, 0.9);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 品牌大字
|
||||
.brandWord {
|
||||
font-size: 13vw;
|
||||
font-size: 14vw;
|
||||
font-weight: 900;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
letter-spacing: -0.02em;
|
||||
@@ -48,18 +60,23 @@
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
||||
text-align: right;
|
||||
padding-right: 0;
|
||||
opacity: 0;
|
||||
animation: brandFadeIn 0.8s ease-out forwards;
|
||||
|
||||
// 不同字有不同的透明度形成层次感
|
||||
// 不同字有不同的透明度和延迟,从上到下依次显现
|
||||
&:nth-child(1) {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
--target-opacity: 0.95;
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
--target-opacity: 0.7;
|
||||
animation-delay: 0.35s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
--target-opacity: 0.45;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +111,7 @@
|
||||
.logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: $radius-full;
|
||||
border-radius: $radius-lg;
|
||||
object-fit: cover;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 3px solid var(--border-color);
|
||||
@@ -211,24 +228,101 @@
|
||||
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);
|
||||
font-size: 14px;
|
||||
// 启动动画进入效果
|
||||
@keyframes splashEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(20px);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 [loading, setLoading] = useState(false);
|
||||
const [autoLoading, setAutoLoading] = useState(true);
|
||||
const [autoLoginSuccess, setAutoLoginSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
||||
@@ -39,18 +40,28 @@ export function LoginPage() {
|
||||
const init = async () => {
|
||||
try {
|
||||
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);
|
||||
setManagementKey(storedKey || '');
|
||||
setRememberPassword(storedRememberPassword || Boolean(storedKey));
|
||||
}
|
||||
} finally {
|
||||
setAutoLoading(false);
|
||||
if (!autoLoginSuccess) {
|
||||
setAutoLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
}, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!managementKey.trim()) {
|
||||
@@ -88,11 +99,14 @@ export function LoginPage() {
|
||||
[loading, handleSubmit]
|
||||
);
|
||||
|
||||
if (isAuthenticated) {
|
||||
if (isAuthenticated && !autoLoading && !autoLoginSuccess) {
|
||||
const redirect = (location.state as any)?.from?.pathname || '/';
|
||||
return <Navigate to={redirect} replace />;
|
||||
}
|
||||
|
||||
// 显示启动动画(自动登录中或自动登录成功)
|
||||
const showSplash = autoLoading || autoLoginSuccess;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 左侧品牌展示区 */}
|
||||
@@ -106,109 +120,115 @@ export function LoginPage() {
|
||||
|
||||
{/* 右侧功能交互区 */}
|
||||
<div className={styles.formPanel}>
|
||||
<div className={styles.formContent}>
|
||||
{/* Logo */}
|
||||
<img src={INLINE_LOGO_JPEG} alt="Logo" className={styles.logo} />
|
||||
|
||||
{/* 登录表单卡片 */}
|
||||
<div className={styles.loginCard}>
|
||||
<div className={styles.loginHeader}>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.title}>{t('title.login')}</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.languageBtn}
|
||||
onClick={toggleLanguage}
|
||||
title={t('language.switch')}
|
||||
aria-label={t('language.switch')}
|
||||
>
|
||||
{nextLanguageLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.subtitle}>{t('login.subtitle')}</div>
|
||||
{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 className={styles.connectionBox}>
|
||||
<div className={styles.label}>{t('login.connection_current')}</div>
|
||||
<div className={styles.value}>{apiBase || detectedBase}</div>
|
||||
<div className={styles.hint}>{t('login.connection_auto_hint')}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.toggleAdvanced}>
|
||||
<input
|
||||
id="custom-connection-toggle"
|
||||
type="checkbox"
|
||||
checked={showCustomBase}
|
||||
onChange={(e) => setShowCustomBase(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="custom-connection-toggle">{t('login.custom_connection_label')}</label>
|
||||
</div>
|
||||
|
||||
{showCustomBase && (
|
||||
<Input
|
||||
label={t('login.custom_connection_label')}
|
||||
placeholder={t('login.custom_connection_placeholder')}
|
||||
value={apiBase}
|
||||
onChange={(e) => setApiBase(e.target.value)}
|
||||
hint={t('login.custom_connection_hint')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
autoFocus
|
||||
label={t('login.management_key_label')}
|
||||
placeholder={t('login.management_key_placeholder')}
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={managementKey}
|
||||
onChange={(e) => setManagementKey(e.target.value)}
|
||||
onKeyDown={handleSubmitKeyDown}
|
||||
rightElement={
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setShowKey((prev) => !prev)}
|
||||
aria-label={
|
||||
showKey
|
||||
? t('login.hide_key', { defaultValue: '隐藏密钥' })
|
||||
: t('login.show_key', { defaultValue: '显示密钥' })
|
||||
}
|
||||
title={
|
||||
showKey
|
||||
? t('login.hide_key', { defaultValue: '隐藏密钥' })
|
||||
: t('login.show_key', { defaultValue: '显示密钥' })
|
||||
}
|
||||
>
|
||||
{showKey ? <IconEyeOff size={16} /> : <IconEye size={16} />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={styles.toggleAdvanced}>
|
||||
<input
|
||||
id="remember-password-toggle"
|
||||
type="checkbox"
|
||||
checked={rememberPassword}
|
||||
onChange={(e) => setRememberPassword(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="remember-password-toggle">{t('login.remember_password_label')}</label>
|
||||
</div>
|
||||
|
||||
<Button fullWidth onClick={handleSubmit} loading={loading}>
|
||||
{loading ? t('login.submitting') : t('login.submit_button')}
|
||||
</Button>
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</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 className={styles.formContent}>
|
||||
{/* Logo */}
|
||||
<img src={INLINE_LOGO_JPEG} alt="Logo" className={styles.logo} />
|
||||
|
||||
{/* 登录表单卡片 */}
|
||||
<div className={styles.loginCard}>
|
||||
<div className={styles.loginHeader}>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.title}>{t('title.login')}</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={styles.languageBtn}
|
||||
onClick={toggleLanguage}
|
||||
title={t('language.switch')}
|
||||
aria-label={t('language.switch')}
|
||||
>
|
||||
{nextLanguageLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.subtitle}>{t('login.subtitle')}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.connectionBox}>
|
||||
<div className={styles.label}>{t('login.connection_current')}</div>
|
||||
<div className={styles.value}>{apiBase || detectedBase}</div>
|
||||
<div className={styles.hint}>{t('login.connection_auto_hint')}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.toggleAdvanced}>
|
||||
<input
|
||||
id="custom-connection-toggle"
|
||||
type="checkbox"
|
||||
checked={showCustomBase}
|
||||
onChange={(e) => setShowCustomBase(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="custom-connection-toggle">{t('login.custom_connection_label')}</label>
|
||||
</div>
|
||||
|
||||
{showCustomBase && (
|
||||
<Input
|
||||
label={t('login.custom_connection_label')}
|
||||
placeholder={t('login.custom_connection_placeholder')}
|
||||
value={apiBase}
|
||||
onChange={(e) => setApiBase(e.target.value)}
|
||||
hint={t('login.custom_connection_hint')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
autoFocus
|
||||
label={t('login.management_key_label')}
|
||||
placeholder={t('login.management_key_placeholder')}
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={managementKey}
|
||||
onChange={(e) => setManagementKey(e.target.value)}
|
||||
onKeyDown={handleSubmitKeyDown}
|
||||
rightElement={
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setShowKey((prev) => !prev)}
|
||||
aria-label={
|
||||
showKey
|
||||
? t('login.hide_key', { defaultValue: '隐藏密钥' })
|
||||
: t('login.show_key', { defaultValue: '显示密钥' })
|
||||
}
|
||||
title={
|
||||
showKey
|
||||
? t('login.hide_key', { defaultValue: '隐藏密钥' })
|
||||
: t('login.show_key', { defaultValue: '显示密钥' })
|
||||
}
|
||||
>
|
||||
{showKey ? <IconEyeOff size={16} /> : <IconEye size={16} />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={styles.toggleAdvanced}>
|
||||
<input
|
||||
id="remember-password-toggle"
|
||||
type="checkbox"
|
||||
checked={rememberPassword}
|
||||
onChange={(e) => setRememberPassword(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="remember-password-toggle">{t('login.remember_password_label')}</label>
|
||||
</div>
|
||||
|
||||
<Button fullWidth onClick={handleSubmit} loading={loading}>
|
||||
{loading ? t('login.submitting') : t('login.submit_button')}
|
||||
</Button>
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user