feat: 全站 PageShell 设计系统与专业 UI 统一

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

No files matched your search

+156 -342
View File
@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react';
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 } from 'lucide-react';
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, THEME_OPTIONS } from '@/hooks/useTheme';
import { useTheme } from '@/hooks/useTheme';
import { ThemePicker } from '@/components/layout/ThemePicker';
import { cn } from '@/lib/utils';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '@/i18n';
import {
@@ -16,58 +17,7 @@ import {
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
/* ---------- CSS Animations ---------- */
const ANIMATIONS = `
@keyframes login-orb-1 {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(40px, -50px) scale(1.12); }
66% { transform: translate(-30px, 35px) scale(0.92); }
}
@keyframes login-orb-2 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-50px, 25px) scale(1.18); }
}
@keyframes login-orb-3 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(30px, 45px) scale(0.88); }
}
@keyframes login-orb-4 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(25px, -30px) scale(1.1); }
}
@keyframes login-card-enter {
from { opacity: 0; transform: translateY(24px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes login-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes login-input-focus {
from { box-shadow: 0 0 0 0px var(--dg-input-focus-ring); }
to { box-shadow: 0 0 0 4px var(--dg-input-focus-ring); }
}
`;
/* ---------- Floating background orbs ---------- */
const ORBS = [
{ top: '-10%', left: '-5%', size: 520, color: 'var(--dg-blue)', opacity: 0.18, blur: 90, animation: 'login-orb-1 18s ease-in-out infinite' },
{ bottom: '-15%', right: '-8%', size: 480, color: 'var(--dg-purple)', opacity: 0.16, blur: 100, animation: 'login-orb-2 22s ease-in-out infinite' },
{ top: '30%', right: '10%', size: 320, color: 'var(--dg-cyan)', opacity: 0.12, blur: 70, animation: 'login-orb-3 16s ease-in-out infinite' },
{ bottom: '10%', left: '8%', size: 380, color: 'var(--dg-gradient-start)', opacity: 0.14, blur: 80, animation: 'login-orb-4 20s ease-in-out infinite' },
];
/**
* Login - 优化后的登录页面
*
* 优化点:
* 1. 使用 glass 效果替代简单的半透明背景
* 2. 输入框使用 dg-input 工具类
* 3. 密码显示/隐藏使用图标按钮
* 4. 主题切换更精致
* 5. 错误提示动画更流畅
* 6. 背景光球动画更协调
*/
/** 登录页 — 克制企业风,无渐变/粉彩色光球 // Logan.Li */
export function Login() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -78,18 +28,13 @@ export function Login() {
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const result = await authApi.login(username, password) as { user: any; token: string; permissions?: string[] };
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 });
@@ -102,297 +47,166 @@ export function Login() {
};
return (
<>
<style>{ANIMATIONS}</style>
<div className="relative min-h-screen flex items-center justify-center px-4 bg-[var(--surface-canvas)]">
{/* 极弱品牌氛围 — 单色径向,非彩色光球 // Logan.Li */}
<div
className="relative min-h-screen flex items-center justify-center px-4 overflow-hidden"
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background: `radial-gradient(ellipse at 50% 0%, ${getComputedStyle(document.documentElement).getPropertyValue('--dg-bg-secondary')} 0%, ${getComputedStyle(document.documentElement).getPropertyValue('--dg-bg-primary')} 100%)`,
background: 'radial-gradient(ellipse 80% 50% at 50% -10%, color-mix(in srgb, var(--brand) 6%, transparent), transparent)',
}}
>
{/* Floating light spots */}
{ORBS.map((orb, i) => (
<div
key={`orb-${i}`}
aria-hidden="true"
className="pointer-events-none absolute"
style={{
top: orb.top,
left: orb.left,
bottom: orb.bottom,
right: orb.right,
width: orb.size,
height: orb.size,
borderRadius: '50%',
background: `radial-gradient(circle, ${orb.color} 0%, transparent 70%)`,
opacity: mounted ? orb.opacity : 0,
filter: `blur(${orb.blur}px)`,
animation: orb.animation,
willChange: 'transform',
transition: 'opacity 1s ease-out',
}}
/>
))}
/>
{/* Theme + Language switcher */}
<div
className="fixed top-4 right-4 z-50 flex items-center gap-2"
style={{ animation: 'login-fade-in 0.6s ease-out' }}
>
{/* Language switcher */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t('header.language')}
className={cn(
'inline-flex items-center justify-center size-10 rounded-full',
'border border-[var(--dg-border)] transition-all duration-200',
'hover:scale-105 active:scale-95 hover:border-[var(--dg-border-hover)]',
'glass'
)}
style={{ color: 'var(--dg-text-secondary)' }}
>
<Languages className="size-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-40 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"
style={{ boxShadow: 'var(--shadow-dropdown)' }}
>
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">{t('header.languageSwitch')}</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
{SUPPORTED_LANGUAGES.map((lang) => (
<DropdownMenuItem
key={lang.code}
className={cn(
'flex items-center gap-2.5 cursor-pointer transition-colors',
'focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)]',
language === lang.code ? 'text-[var(--dg-text-primary)]' : 'text-[var(--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(--dg-blue)]" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Theme switcher */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t('header.theme')}
className={cn(
'inline-flex items-center justify-center size-10 rounded-full',
'border border-[var(--dg-border)] transition-all duration-200',
'hover:scale-105 active:scale-95 hover:border-[var(--dg-border-hover)]',
'glass'
)}
style={{ color: 'var(--dg-text-secondary)' }}
>
<Palette className="size-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-60 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"
style={{ boxShadow: 'var(--shadow-dropdown)' }}
>
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">{t('header.themeSwitch')}</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
{THEME_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.id}
className={cn(
'flex items-center gap-2.5 cursor-pointer transition-colors',
'focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)]',
theme === option.id ? 'text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-secondary)]',
)}
onClick={() => setTheme(option.id)}
>
<span
className="size-3.5 rounded-full shrink-0 ring-2"
style={{
backgroundColor: option.primaryColor,
boxShadow: '0 0 0 2px var(--dg-bg-primary)',
}}
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium block">{option.name}</span>
<span className="text-xs opacity-60 block truncate">{option.description}</span>
</div>
{theme === option.id && <Check className="size-4 shrink-0 text-[var(--dg-blue)]" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Login card */}
<div
className="relative z-10 w-full max-w-md"
style={{
background: 'color-mix(in srgb, var(--dg-bg-secondary) 80%, transparent)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
border: '1px solid var(--dg-border)',
borderRadius: 24,
boxShadow: 'var(--shadow-card-hover)',
padding: '44px 40px',
animation: 'login-card-enter 0.6s cubic-bezier(0.16, 1, 0.3, 1)',
}}
>
{/* Brand */}
<div className="text-center mb-8">
<div
className="inline-flex items-center justify-center size-16 rounded-2xl mb-5"
style={{
background: 'linear-gradient(135deg, var(--dg-gradient-start), var(--dg-gradient-end))',
boxShadow: '0 8px 32px color-mix(in srgb, var(--dg-gradient-start) 30%, transparent)',
}}
>
<svg
className="size-8 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 12h4l3-8 4 16 3-8h4" />
</svg>
</div>
<h1 className="text-3xl font-bold dg-gradient-text tracking-tight">
AIOps
</h1>
<p className="mt-2.5 text-sm text-[var(--dg-text-secondary)]">
{t('login.subtitle')}
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{/* Error */}
{error && (
<div
className="flex items-start gap-2.5 rounded-xl px-4 py-3 text-sm animate-fade-in"
style={{
background: 'color-mix(in srgb, var(--dg-red) 10%, transparent)',
border: '1px solid color-mix(in srgb, var(--dg-red) 25%, transparent)',
color: 'var(--dg-red)',
}}
>
<AlertCircle className="size-4 shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
{/* Username */}
<div className="space-y-1.5">
<label className="block text-sm font-medium text-[var(--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 text-[var(--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>
{/* Password */}
<div className="space-y-1.5">
<label className="block text-sm font-medium text-[var(--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 text-[var(--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 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)] transition-colors"
tabIndex={-1}
>
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
</div>
{/* Submit */}
{/* 语言 / 主题 */}
<div className="fixed top-4 right-4 z-50 flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="submit"
disabled={loading || !username || !password}
className={cn(
'dg-gradient-bg w-full rounded-xl py-3 mt-2 text-white font-semibold text-[15px]',
'transition-all duration-200 active:scale-[0.98] disabled:cursor-not-allowed',
)}
style={{
opacity: loading || !username || !password ? 0.6 : 1,
boxShadow: loading || !username || !password
? 'none'
: '0 6px 24px color-mix(in srgb, var(--dg-gradient-start) 30%, transparent)',
}}
type="button"
aria-label={t('header.language')}
className="dg-btn-secondary h-9 w-9 p-0 rounded-lg"
>
{loading ? (
<span className="inline-flex items-center justify-center gap-2">
<Loader2 className="size-4 animate-spin" />
{t('login.loggingIn')}
</span>
) : (
t('login.loginButton')
)}
<Languages className="size-4" />
</button>
</form>
{/* Hints */}
<div
className="mt-6 pt-5 text-center text-xs space-y-1"
style={{
borderTop: '1px solid var(--dg-border-light)',
color: 'var(--dg-text-muted)',
}}
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-40 bg-[var(--surface-raised)] border-[var(--border-default)]"
style={{ boxShadow: 'var(--shadow-dropdown)' }}
>
<p>
默认管理员账号{' '}
<span className="font-medium text-[var(--dg-text-secondary)]">admin / 123456</span>
</p>
<p>LDAP 用户可直接使用域账号登录</p>
<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>
{/* Footer */}
<p
className="absolute bottom-4 left-0 right-0 text-center text-xs"
style={{ color: 'var(--dg-text-muted)', opacity: 0.5 }}
>
&copy; AIOps 智能运维平台
</p>
<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>
);
}
}