mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-02 19:00:49 +08:00
280 lines
10 KiB
TypeScript
280 lines
10 KiB
TypeScript
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
|
import { Navigate, useNavigate, useLocation } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Input } from '@/components/ui/Input';
|
|
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
|
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
|
|
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
|
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
|
import type { ApiError } from '@/types';
|
|
import styles from './LoginPage.module.scss';
|
|
|
|
/**
|
|
* 将 API 错误转换为本地化的用户友好消息
|
|
*/
|
|
function getLocalizedErrorMessage(error: any, t: (key: string) => string): string {
|
|
const apiError = error as ApiError;
|
|
const status = apiError?.status;
|
|
const code = apiError?.code;
|
|
const message = apiError?.message || '';
|
|
|
|
// 根据 HTTP 状态码判断
|
|
if (status === 401) {
|
|
return t('login.error_unauthorized');
|
|
}
|
|
if (status === 403) {
|
|
return t('login.error_forbidden');
|
|
}
|
|
if (status === 404) {
|
|
return t('login.error_not_found');
|
|
}
|
|
if (status && status >= 500) {
|
|
return t('login.error_server');
|
|
}
|
|
|
|
// 根据 axios 错误码判断
|
|
if (code === 'ECONNABORTED' || message.toLowerCase().includes('timeout')) {
|
|
return t('login.error_timeout');
|
|
}
|
|
if (code === 'ERR_NETWORK' || message.toLowerCase().includes('network error')) {
|
|
return t('login.error_network');
|
|
}
|
|
if (code === 'ERR_CERT_AUTHORITY_INVALID' || message.toLowerCase().includes('certificate')) {
|
|
return t('login.error_ssl');
|
|
}
|
|
|
|
// 检查 CORS 错误
|
|
if (message.toLowerCase().includes('cors') || message.toLowerCase().includes('cross-origin')) {
|
|
return t('login.error_cors');
|
|
}
|
|
|
|
// 默认错误消息
|
|
return t('login.error_invalid');
|
|
}
|
|
|
|
export function LoginPage() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { showNotification } = useNotificationStore();
|
|
const language = useLanguageStore((state) => state.language);
|
|
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
|
const login = useAuthStore((state) => state.login);
|
|
const restoreSession = useAuthStore((state) => state.restoreSession);
|
|
const storedBase = useAuthStore((state) => state.apiBase);
|
|
const storedKey = useAuthStore((state) => state.managementKey);
|
|
const storedRememberPassword = useAuthStore((state) => state.rememberPassword);
|
|
|
|
const [apiBase, setApiBase] = useState('');
|
|
const [managementKey, setManagementKey] = useState('');
|
|
const [showCustomBase, setShowCustomBase] = useState(false);
|
|
const [showKey, setShowKey] = useState(false);
|
|
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(), []);
|
|
const nextLanguageLabel = language === 'zh-CN' ? t('language.english') : t('language.chinese');
|
|
|
|
useEffect(() => {
|
|
const init = async () => {
|
|
try {
|
|
const autoLoggedIn = await restoreSession();
|
|
if (autoLoggedIn) {
|
|
setAutoLoginSuccess(true);
|
|
// 延迟跳转,让用户看到成功动画
|
|
setTimeout(() => {
|
|
const redirect = (location.state as any)?.from?.pathname || '/';
|
|
navigate(redirect, { replace: true });
|
|
}, 1300);
|
|
} else {
|
|
setApiBase(storedBase || detectedBase);
|
|
setManagementKey(storedKey || '');
|
|
setRememberPassword(storedRememberPassword || Boolean(storedKey));
|
|
}
|
|
} finally {
|
|
if (!autoLoginSuccess) {
|
|
setAutoLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
init();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!managementKey.trim()) {
|
|
setError(t('login.error_required'));
|
|
return;
|
|
}
|
|
|
|
const baseToUse = apiBase ? normalizeApiBase(apiBase) : detectedBase;
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
await login({
|
|
apiBase: baseToUse,
|
|
managementKey: managementKey.trim(),
|
|
rememberPassword
|
|
});
|
|
showNotification(t('common.connected_status'), 'success');
|
|
navigate('/', { replace: true });
|
|
} catch (err: any) {
|
|
const message = getLocalizedErrorMessage(err, t);
|
|
setError(message);
|
|
showNotification(`${t('notification.login_failed')}: ${message}`, 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [apiBase, detectedBase, login, managementKey, navigate, rememberPassword, showNotification, t]);
|
|
|
|
const handleSubmitKeyDown = useCallback(
|
|
(event: React.KeyboardEvent) => {
|
|
if (event.key === 'Enter' && !loading) {
|
|
event.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
},
|
|
[loading, handleSubmit]
|
|
);
|
|
|
|
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}>
|
|
{/* 左侧品牌展示区 */}
|
|
<div className={styles.brandPanel}>
|
|
<div className={styles.brandContent}>
|
|
<span className={styles.brandWord}>CLI</span>
|
|
<span className={styles.brandWord}>PROXY</span>
|
|
<span className={styles.brandWord}>API</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 右侧功能交互区 */}
|
|
<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}>
|
|
{/* 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>
|
|
);
|
|
}
|