Files
aiops/frontend/src/components/auth/Login.tsx
T
attacker a3cd0d3910 feat: PTY kubectl终端 + 侧边栏对齐修复 + 构建缓存清理
**kubectl WebShell 修复:**
- 引入 github.com/creack/pty 库实现真正的伪终端
- 替换 bash --noediting + TERM=dumb 方案为 PTY-backed bash
- 支持 xterm-256color、窗口自适应、readline 交互
- 修复键盘输入无响应问题

**侧边栏优化:**
- 独立菜单项高度从 py-1.5 改为 h-8,与父菜单对齐
- 独立菜单图标从 h-3 w-3 改为 h-3.5 w-3.5,统一 14px

**构建优化:**
- run.sh docker up 增加 docker image prune -f 清理悬空镜像
- 每次部署自动清理 builder cache + dangling images

**认证修复(历史):**
- Token 存储:Zustand + localStorage 双写,http.ts 双源 fallback
- YamlViewer/DeploymentDetail token 来源统一为 Zustand store
2026-06-19 11:22:45 +00:00

376 lines
13 KiB
TypeScript

import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User, Lock, Palette, Check, Loader2, AlertCircle } from 'lucide-react';
import { useUserStore } from '@/store';
import { userApi } from '@/services/api';
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
import { cn } from '@/lib/utils';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
/* ---------- Floating background orbs (themed light spots) ---------- */
const ORBS = [
{
top: '-10%',
left: '-5%',
size: 520,
color: 'var(--dg-blue)',
opacity: 0.22,
blur: 90,
animation: 'login-orb-1 18s ease-in-out infinite',
},
{
bottom: '-15%',
right: '-8%',
size: 480,
color: 'var(--dg-purple)',
opacity: 0.2,
blur: 100,
animation: 'login-orb-2 22s ease-in-out infinite',
},
{
top: '30%',
right: '10%',
size: 320,
color: 'var(--dg-cyan)',
opacity: 0.16,
blur: 70,
animation: 'login-orb-3 16s ease-in-out infinite',
},
{
bottom: '10%',
left: '8%',
size: 380,
color: 'var(--dg-gradient-start)',
opacity: 0.18,
blur: 80,
animation: 'login-orb-4 20s ease-in-out infinite',
},
];
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; }
}
`;
export function Login() {
const navigate = useNavigate();
const { theme, setTheme } = useTheme();
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 userApi.login(username, password);
useUserStore.getState().login(result.user, result.token);
localStorage.setItem('token', result.token); // backward compat
navigate('/', { replace: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '登录失败,请检查用户名和密码';
setError(message);
} finally {
setLoading(false);
}
};
return (
<>
<style>{ANIMATIONS}</style>
<div
className="relative min-h-screen flex items-center justify-center px-4 overflow-hidden"
style={{
background:
'linear-gradient(135deg, var(--dg-bg-primary) 0%, var(--dg-bg-secondary) 100%)',
}}
>
{/* -------- Floating themed 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: orb.opacity,
filter: `blur(${orb.blur}px)`,
animation: orb.animation,
willChange: 'transform',
}}
/>
))}
{/* -------- Theme switcher (top-right) -------- */}
<div
className="fixed top-4 right-4 z-50"
style={{ animation: 'login-fade-in 0.6s ease-out' }}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="切换主题"
className={cn(
'inline-flex items-center justify-center size-10 rounded-full',
'border transition-all duration-200 hover:scale-105 active:scale-95'
)}
style={{
background: 'color-mix(in srgb, var(--dg-bg-secondary) 70%, transparent)',
borderColor: 'var(--dg-border)',
color: 'var(--dg-text-secondary)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
}}
>
<Palette className="size-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel>主题切换</DropdownMenuLabel>
<DropdownMenuSeparator />
{THEME_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.id}
className="flex items-center gap-2.5 cursor-pointer"
onClick={() => setTheme(option.id)}
>
<span
className="size-3.5 rounded-full shrink-0 ring-2"
style={{
backgroundColor: option.primaryColor,
// @ts-expect-error css var in style string
'--tw-ring-color': '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" />}
</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) 75%, transparent)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
border: '1px solid var(--dg-border)',
borderRadius: 24,
boxShadow:
'0 8px 48px rgba(0,0,0,0.28), 0 0 0 1px rgba(255,255,255,0.02)',
padding: '40px 36px',
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-14 rounded-2xl mb-4 shadow-lg"
style={{
background:
'linear-gradient(135deg, var(--dg-gradient-start), var(--dg-gradient-end))',
boxShadow: '0 8px 24px color-mix(in srgb, var(--dg-gradient-start) 35%, transparent)',
}}
>
<svg
className="size-7 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 text-sm"
style={{ color: 'var(--dg-text-secondary)' }}
>
智能运维 · 全栈可观测平台
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{/* Error */}
{error && (
<div
className="flex items-start gap-2 rounded-lg px-4 py-3 text-sm"
style={{
background: 'color-mix(in srgb, var(--dg-red) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--dg-red) 30%, transparent)',
color: 'var(--dg-red)',
animation: 'login-fade-in 0.25s ease-out',
}}
>
<AlertCircle className="size-4 shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
{/* Username */}
<div>
<label
className="block text-sm font-medium mb-1.5"
style={{ color: 'var(--dg-text-secondary)' }}
>
用户名
</label>
<div className="relative">
<User
className="absolute left-3.5 top-1/2 -translate-y-1/2 size-4 pointer-events-none"
style={{ color: 'var(--dg-text-muted)' }}
/>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
autoComplete="username"
autoFocus
className="dg-input w-full rounded-lg py-2.5 pl-10 pr-4 text-sm transition-colors"
/>
</div>
</div>
{/* Password */}
<div>
<label
className="block text-sm font-medium mb-1.5"
style={{ color: 'var(--dg-text-secondary)' }}
>
密码
</label>
<div className="relative">
<Lock
className="absolute left-3.5 top-1/2 -translate-y-1/2 size-4 pointer-events-none"
style={{ color: 'var(--dg-text-muted)' }}
/>
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
autoComplete="current-password"
className="dg-input w-full rounded-lg py-2.5 pl-10 pr-12 text-sm transition-colors"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium transition-colors"
style={{ color: 'var(--dg-text-muted)' }}
>
{showPassword ? '隐藏' : '显示'}
</button>
</div>
</div>
{/* Submit */}
<button
type="submit"
disabled={loading || !username || !password}
className={cn(
'dg-gradient-bg w-full rounded-lg 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:
'0 6px 24px color-mix(in srgb, var(--dg-gradient-start) 30%, transparent)',
}}
>
{loading ? (
<span className="inline-flex items-center justify-center gap-2">
<Loader2 className="size-4 animate-spin" />
登录中…
</span>
) : (
'登 录'
)}
</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)',
}}
>
<p>
默认管理员账号 <span className="font-medium" style={{ color: 'var(--dg-text-secondary)' }}>admin / 123456</span>
</p>
<p>LDAP 用户可直接使用域账号登录</p>
</div>
</div>
{/* Footer */}
<p
className="absolute bottom-4 left-0 right-0 text-center text-xs"
style={{ color: 'var(--dg-text-muted)', opacity: 0.7 }}
>
© AIOps · 智能运维平台
</p>
</div>
</>
);
}