feat(login): redesign login page with split layout

This commit is contained in:
Supra4E8C
2026-01-31 14:59:00 +08:00
parent 96307873c5
commit c93030370e
3 changed files with 350 additions and 188 deletions

View File

@@ -0,0 +1,234 @@
@use '../styles/variables.scss' as *;
// 主容器 - 左右分栏布局
.container {
min-height: 100vh;
display: flex;
background: var(--bg-primary);
}
// 左侧品牌展示区
.brandPanel {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: #000000;
padding: $spacing-2xl;
position: relative;
overflow: hidden;
// 移动端隐藏
@media (max-width: $breakpoint-mobile) {
display: none;
}
}
// 品牌文字容器
.brandContent {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: flex-end;
width: 100%;
padding: 0;
gap: 0;
}
// 品牌大字
.brandWord {
font-size: 13vw;
font-weight: 900;
color: rgba(255, 255, 255, 0.9);
letter-spacing: -0.02em;
line-height: 0.85;
text-transform: uppercase;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
text-align: right;
padding-right: 0;
// 不同字有不同的透明度形成层次感
&:nth-child(1) {
color: rgba(255, 255, 255, 0.95);
}
&:nth-child(2) {
color: rgba(255, 255, 255, 0.7);
}
&:nth-child(3) {
color: rgba(255, 255, 255, 0.45);
}
}
// 右侧功能交互区
.formPanel {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: $spacing-2xl;
background: var(--bg-primary);
position: relative;
@media (max-width: $breakpoint-mobile) {
padding: $spacing-lg;
min-height: 100vh;
}
}
// 右侧内容容器
.formContent {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-xl;
}
// Logo
.logo {
width: 80px;
height: 80px;
border-radius: $radius-full;
object-fit: cover;
box-shadow: var(--shadow-lg);
border: 3px solid var(--border-color);
}
// 登录表单卡片
.loginCard {
width: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
box-shadow: var(--shadow-lg);
padding: $spacing-xl;
display: flex;
flex-direction: column;
gap: $spacing-lg;
@media (max-width: $breakpoint-mobile) {
padding: $spacing-lg;
box-shadow: none;
border: none;
background: transparent;
}
}
// 登录头部
.loginHeader {
display: flex;
flex-direction: column;
gap: $spacing-sm;
text-align: center;
}
// 标题行
.titleRow {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
flex-wrap: wrap;
}
// 标题
.title {
font-size: 22px;
font-weight: 800;
color: var(--text-primary);
}
// 副标题
.subtitle {
color: var(--text-secondary);
font-size: 14px;
}
// 语言切换按钮
.languageBtn {
white-space: nowrap;
}
// 连接信息框
.connectionBox {
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;
.label {
color: var(--text-secondary);
font-size: 14px;
}
.value {
font-weight: 700;
color: var(--text-primary);
word-break: break-all;
}
.hint {
color: var(--text-secondary);
font-size: 12px;
}
}
// 复选框行
.toggleAdvanced {
display: flex;
justify-content: flex-start;
align-items: center;
gap: $spacing-xs;
color: var(--text-secondary);
cursor: pointer;
input[type='checkbox'] {
cursor: pointer;
}
label {
cursor: pointer;
font-size: 14px;
}
}
// 错误提示框
.errorBox {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.4);
border-radius: $radius-md;
padding: $spacing-sm $spacing-md;
color: $error-color;
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;
}
.value {
font-weight: 600;
color: var(--text-primary);
}
}

View File

@@ -6,6 +6,8 @@ import { Input } from '@/components/ui/Input';
import { IconEye, IconEyeOff } from '@/components/ui/icons'; import { IconEye, IconEyeOff } from '@/components/ui/icons';
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores'; import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection'; import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import styles from './LoginPage.module.scss';
export function LoginPage() { export function LoginPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -50,7 +52,7 @@ export function LoginPage() {
init(); init();
}, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]); }, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]);
const handleSubmit = async () => { const handleSubmit = useCallback(async () => {
if (!managementKey.trim()) { if (!managementKey.trim()) {
setError(t('login.error_required')); setError(t('login.error_required'));
return; return;
@@ -74,7 +76,7 @@ export function LoginPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [apiBase, detectedBase, login, managementKey, navigate, rememberPassword, showNotification, t]);
const handleSubmitKeyDown = useCallback( const handleSubmitKeyDown = useCallback(
(event: React.KeyboardEvent) => { (event: React.KeyboardEvent) => {
@@ -92,103 +94,121 @@ export function LoginPage() {
} }
return ( return (
<div className="login-page"> <div className={styles.container}>
<div className="login-card"> {/* 左侧品牌展示区 */}
<div className="login-header"> <div className={styles.brandPanel}>
<div className="login-title-row"> <div className={styles.brandContent}>
<div className="title">{t('title.login')}</div> <span className={styles.brandWord}>CLI</span>
<Button <span className={styles.brandWord}>PROXY</span>
type="button" <span className={styles.brandWord}>API</span>
variant="ghost" </div>
size="sm" </div>
className="login-language-btn"
onClick={toggleLanguage} {/* 右侧功能交互区 */}
title={t('language.switch')} <div className={styles.formPanel}>
aria-label={t('language.switch')} <div className={styles.formContent}>
> {/* Logo */}
{nextLanguageLabel} <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> </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="subtitle">{t('login.subtitle')}</div>
</div> </div>
<div className="connection-box">
<div className="label">{t('login.connection_current')}</div>
<div className="value">{apiBase || detectedBase}</div>
<div className="hint">{t('login.connection_auto_hint')}</div>
</div>
<div className="toggle-advanced">
<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="toggle-advanced">
<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="error-box">{error}</div>}
{autoLoading && (
<div className="connection-box">
<div className="label">{t('auto_login.title')}</div>
<div className="value">{t('auto_login.message')}</div>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -407,98 +407,6 @@
} }
} }
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
padding: $spacing-lg;
.login-card {
width: 100%;
max-width: 520px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
box-shadow: var(--shadow-lg);
padding: $spacing-xl;
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.login-header {
display: flex;
flex-direction: column;
gap: $spacing-sm;
text-align: center;
.title {
font-size: 22px;
font-weight: 800;
color: var(--text-primary);
}
.subtitle {
color: var(--text-secondary);
}
}
.login-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
flex-wrap: wrap;
}
.login-language-btn {
white-space: nowrap;
}
.connection-box {
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;
.label {
color: var(--text-secondary);
font-size: 14px;
}
.value {
font-weight: 700;
color: var(--text-primary);
}
.item-actions {
display: flex;
gap: $spacing-md;
}
}
.toggle-advanced {
display: flex;
justify-content: flex-start;
align-items: center;
gap: $spacing-xs;
color: var(--text-secondary);
cursor: pointer;
}
.error-box {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.4);
border-radius: $radius-md;
padding: $spacing-sm $spacing-md;
color: $error-color;
}
}
.grid { .grid {
display: grid; display: grid;