**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
376 lines
13 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|