- 新增 PageShell/PageTabs/ContentPanel/PageLoading/PageEmpty 页面骨架 - index.css 设计令牌文档化,布局令牌与纯色主按钮(去除渐变粉彩) - 全站路由页接入 PageShell,云账单三 Tab 为标杆 - 主题体系:跟随系统 + 主流/开发者分组,新增 Dracula/Tokyo Night,One Dark 暖灰差异化 - 登录页/侧栏/顶栏去渐变光球,企业克制风 - 修复 AIAlertAnalyzer JSX 与构建依赖导入问题
212 lines
8.5 KiB
TypeScript
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">
|
|
© AIOps 智能运维平台
|
|
</p>
|
|
</div>
|
|
);
|
|
} |