Files
aiops/frontend/src/components/auth/Login.tsx
T
Logan e363ded445 feat: 全站 PageShell 设计系统与专业 UI 统一
- 新增 PageShell/PageTabs/ContentPanel/PageLoading/PageEmpty 页面骨架
- index.css 设计令牌文档化,布局令牌与纯色主按钮(去除渐变粉彩)
- 全站路由页接入 PageShell,云账单三 Tab 为标杆
- 主题体系:跟随系统 + 主流/开发者分组,新增 Dracula/Tokyo Night,One Dark 暖灰差异化
- 登录页/侧栏/顶栏去渐变光球,企业克制风
- 修复 AIAlertAnalyzer JSX 与构建依赖导入问题
2026-07-01 12:45:38 +08:00

212 lines
8.5 KiB
TypeScript

import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { User, Lock, Palette, Check, Loader2, AlertCircle, Eye, EyeOff, Languages, Activity } from 'lucide-react';
import { useUserStore, useLanguageStore } from '@/store';
import { authApi } from '@/api';
import { useTheme } from '@/hooks/useTheme';
import { ThemePicker } from '@/components/layout/ThemePicker';
import { cn } from '@/lib/utils';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '@/i18n';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
/** 登录页 — 克制企业风,无渐变/粉彩色光球 // Logan.Li */
export function Login() {
const { t } = useTranslation();
const navigate = useNavigate();
const { theme, setTheme } = useTheme();
const { language, setLanguage } = useLanguageStore();
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('123456');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const result = await authApi.login(username, password) as { user: { id: number; username: string }; token: string; permissions?: string[] };
useUserStore.getState().login(result.user, result.token, result.permissions ?? null);
localStorage.setItem('token', result.token);
navigate('/', { replace: true });
} catch (err) {
const message = err instanceof Error ? err.message : t('login.loginFailed');
setError(message);
} finally {
setLoading(false);
}
};
return (
<div className="relative min-h-screen flex items-center justify-center px-4 bg-[var(--surface-canvas)]">
{/* 极弱品牌氛围 — 单色径向,非彩色光球 // Logan.Li */}
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background: 'radial-gradient(ellipse 80% 50% at 50% -10%, color-mix(in srgb, var(--brand) 6%, transparent), transparent)',
}}
/>
{/* 语言 / 主题 */}
<div className="fixed top-4 right-4 z-50 flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t('header.language')}
className="dg-btn-secondary h-9 w-9 p-0 rounded-lg"
>
<Languages className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-40 bg-[var(--surface-raised)] border-[var(--border-default)]"
style={{ boxShadow: 'var(--shadow-dropdown)' }}
>
<DropdownMenuLabel className="text-[var(--text-secondary)]">{t('header.languageSwitch')}</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[var(--border-default)]" />
{SUPPORTED_LANGUAGES.map((lang) => (
<DropdownMenuItem
key={lang.code}
className={cn(
'flex items-center gap-2.5 cursor-pointer',
language === lang.code ? 'text-[var(--text-primary)]' : 'dg-text-secondary',
)}
onClick={() => setLanguage(lang.code as AppLanguage)}
>
<span className="text-base">{lang.flag}</span>
<span className="flex-1 text-sm">{lang.label}</span>
{language === lang.code && <Check className="size-4 shrink-0 text-[var(--brand)]" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t('header.theme')}
className="dg-btn-secondary h-9 w-9 p-0 rounded-lg"
>
<Palette className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-60 bg-[var(--surface-raised)] border-[var(--border-default)]"
style={{ boxShadow: 'var(--shadow-dropdown)' }}
>
<DropdownMenuLabel className="text-[var(--text-secondary)]">{t('header.themeSwitch')}</DropdownMenuLabel>
<ThemePicker value={theme} onChange={setTheme} />
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 登录卡片 */}
<div className="relative z-10 w-full max-w-md dg-card rounded-xl p-10 animate-fade-in">
<div className="text-center mb-8">
<div className="dg-icon-box w-14 h-14 rounded-xl mx-auto mb-4">
<Activity className="size-7" />
</div>
<h1 className="text-2xl font-bold text-[var(--text-primary)] tracking-tight">
AIOps
</h1>
<p className="mt-2 text-sm dg-text-secondary">
{t('login.subtitle')}
</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{error && (
<div className="flex items-start gap-2.5 rounded-lg px-4 py-3 text-sm dg-alert-banner border-[var(--alert-error-border)] bg-[var(--alert-error-bg)] text-[var(--alert-error-text)]">
<AlertCircle className="size-4 shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
<div className="space-y-1.5">
<label className="block text-sm font-medium dg-text-secondary">
{t('login.username')}
</label>
<div className="relative">
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 size-4 pointer-events-none dg-text-muted" />
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t('login.usernamePlaceholder')}
autoComplete="username"
autoFocus
className="dg-input w-full py-2.5 pl-10 pr-4 text-sm"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium dg-text-secondary">
{t('login.password')}
</label>
<div className="relative">
<Lock className="absolute left-3.5 top-1/2 -translate-y-1/2 size-4 pointer-events-none dg-text-muted" />
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('login.passwordPlaceholder')}
autoComplete="current-password"
className="dg-input w-full py-2.5 pl-10 pr-12 text-sm"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 dg-text-muted hover:dg-text-secondary transition-colors"
tabIndex={-1}
>
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
</div>
<button
type="submit"
disabled={loading || !username || !password}
className="dg-btn-primary w-full rounded-lg py-2.5 mt-1 text-[15px] font-semibold disabled:cursor-not-allowed"
>
{loading ? (
<span className="inline-flex items-center justify-center gap-2">
<Loader2 className="size-4 animate-spin" />
{t('login.loggingIn')}
</span>
) : (
t('login.loginButton')
)}
</button>
</form>
<div className="mt-6 pt-5 text-center text-xs space-y-1 border-t border-[var(--border-subtle)] dg-text-muted">
<p>
默认管理员账号{' '}
<span className="font-medium dg-text-secondary">admin / 123456</span>
</p>
<p>LDAP 用户可直接使用域账号登录</p>
</div>
</div>
<p className="absolute bottom-4 left-0 right-0 text-center text-xs dg-text-muted opacity-60">
&copy; AIOps 智能运维平台
</p>
</div>
);
}