前端: - src/srv/api → src/api,按域分文件夹(auth/cluster/host/storage/deployment/collector) - 新增 http.ts 引擎(http.get/post/put/delete),域模块改用语义化调用 - index.html 增加 favicon(匹配应用 logo 的 emerald + Activity 脉冲) 后端分层增强(保留包名): - 新增 repository/:从 models 拆出 JsonDB 持久化层(InitDB/GetDB/Shutdown/RequestSave) - 新增 dto/:从 controllers 抽出各域请求契约(带 binding 校验) - 新增 pkg/crypto、pkg/jwt:从 utils 拆出安全原语(外部 jwt 别名 jwtv5 避免冲突) - models 只留领域实体 + sanitizer;utils 只留 gin/应用相关(response/errors) v2 优化移植与缺陷修复: - 移植密钥分离/SSH TOFU/并发写安全/Loki 生产级配置/Docker 加固 - 修复 deployment.go 并发丢更新、Helm values 重复键、EncryptField 哨兵、loki-gateway 端点等审计缺陷 - 实现真正的优雅关停(main.go http.Server + signal + repository.Shutdown) - 修复登录契约:后端返回 accessToken,前端 auth.ts/Login.tsx 对齐 - 补 crypto/jwt/repository 单元测试(含并发写回归) 验证: go build/vet/test 全绿;npm run build 通过;登录→鉴权→优雅关停冒烟通过 Co-Authored-By: Claude <noreply@anthropic.com>
203 lines
7.1 KiB
TypeScript
203 lines
7.1 KiB
TypeScript
import { memo, useCallback, useEffect, useState } from 'react'
|
||
import { useNavigate } from 'react-router'
|
||
import { Activity, Eye, EyeOff, Shield, ServerOff } from 'lucide-react'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { authApi } from '@/api'
|
||
import { toast } from 'sonner'
|
||
|
||
/**
|
||
* Login 页面 - 优化版本
|
||
*
|
||
* 优化点:
|
||
* 1. React.memo 包裹组件
|
||
* 2. 表单处理使用 useCallback 缓存
|
||
* 3. 错误处理使用 sonner toast 替代 alert
|
||
* 4. 清理 useEffect 中的未使用变量
|
||
*/
|
||
const Login = memo(function Login() {
|
||
const [username, setUsername] = useState('admin')
|
||
const [password, setPassword] = useState('admin123')
|
||
const [showPassword, setShowPassword] = useState(false)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [backendStatus, setBackendStatus] = useState<'checking' | 'online' | 'offline'>('checking')
|
||
const navigate = useNavigate()
|
||
|
||
useEffect(() => {
|
||
// Check if already logged in
|
||
const token = localStorage.getItem('token')
|
||
if (token) {
|
||
navigate('/')
|
||
return
|
||
}
|
||
// Quick backend check
|
||
checkBackend()
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [navigate])
|
||
|
||
// 后端状态检查 - useCallback缓存
|
||
const checkBackend = useCallback(async () => {
|
||
try {
|
||
const controller = new AbortController()
|
||
const timeoutId = setTimeout(() => controller.abort(), 3000)
|
||
// 用公开的健康检查端点探活;避免打 /auth/login(生产环境被 nginx 限流 5r/m,
|
||
// 反复刷新登录页会触发限流并锁死真正的登录请求)
|
||
const res = await fetch('/api/v1/health', {
|
||
method: 'GET',
|
||
signal: controller.signal,
|
||
})
|
||
clearTimeout(timeoutId)
|
||
setBackendStatus(res.ok ? 'online' : 'offline')
|
||
} catch {
|
||
setBackendStatus('offline')
|
||
}
|
||
}, [])
|
||
|
||
// 表单提交处理 - useCallback缓存
|
||
const handleSubmit = useCallback(
|
||
async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
setLoading(true)
|
||
setError('')
|
||
|
||
try {
|
||
const result = await authApi.login(username, password)
|
||
localStorage.setItem('token', result.accessToken)
|
||
localStorage.setItem('refreshToken', result.refreshToken)
|
||
localStorage.setItem('user', JSON.stringify(result.user))
|
||
toast.success('登录成功')
|
||
navigate('/')
|
||
} catch (err: unknown) {
|
||
const message = err instanceof Error ? err.message : '登录失败'
|
||
if (message.includes('Cannot connect')) {
|
||
setError('无法连接后端服务,请确认后端已启动(端口 8080)。')
|
||
setBackendStatus('offline')
|
||
} else if (
|
||
message.includes('invalid') ||
|
||
message.includes('password') ||
|
||
message.includes('密码') ||
|
||
message.includes('Unauthorized')
|
||
) {
|
||
setError('用户名或密码错误')
|
||
} else {
|
||
setError(message)
|
||
}
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
},
|
||
[username, password, navigate]
|
||
)
|
||
|
||
// 密码显示切换 - useCallback缓存
|
||
const togglePassword = useCallback(() => {
|
||
setShowPassword((prev) => !prev)
|
||
}, [])
|
||
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex items-center justify-center p-4">
|
||
<div className="w-full max-w-md">
|
||
{/* Logo */}
|
||
<div className="text-center mb-8">
|
||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-emerald-500 mb-4">
|
||
<Activity className="w-9 h-9 text-white" />
|
||
</div>
|
||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">LokiDeploy</h1>
|
||
<p className="text-sm text-slate-500 mt-1">Kubernetes Loki 日志部署平台</p>
|
||
</div>
|
||
|
||
{/* Backend Status */}
|
||
{backendStatus === 'offline' && (
|
||
<div className="mb-4 p-4 rounded-xl bg-amber-50 border border-amber-200 flex items-start gap-3">
|
||
<ServerOff className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="text-sm font-medium text-amber-800">后端服务未启动</p>
|
||
<p className="text-xs text-amber-700 mt-1">
|
||
无法连接 API 服务,请先启动后端:
|
||
</p>
|
||
<code className="block mt-2 px-3 py-2 bg-amber-100 rounded text-xs font-mono text-amber-900">
|
||
./run.sh
|
||
</code>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Login Card */}
|
||
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-800 p-8">
|
||
<div className="flex items-center gap-2 mb-6">
|
||
<Shield className="w-5 h-5 text-emerald-500" />
|
||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">登录</h2>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="mb-4 p-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="username">用户名</Label>
|
||
<Input
|
||
id="username"
|
||
value={username}
|
||
onChange={(e) => setUsername(e.target.value)}
|
||
placeholder="请输入用户名"
|
||
required
|
||
className="h-10"
|
||
autoComplete="username"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="password">密码</Label>
|
||
<div className="relative">
|
||
<Input
|
||
id="password"
|
||
type={showPassword ? 'text' : 'password'}
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
placeholder="请输入密码"
|
||
required
|
||
className="h-10 pr-10"
|
||
autoComplete="current-password"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={togglePassword}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||
tabIndex={-1}
|
||
>
|
||
{showPassword ? (
|
||
<EyeOff className="w-4 h-4" />
|
||
) : (
|
||
<Eye className="w-4 h-4" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<Button
|
||
type="submit"
|
||
className="w-full h-10 bg-emerald-600 hover:bg-emerald-700 text-white"
|
||
disabled={loading}
|
||
>
|
||
{loading ? '登录中...' : '登录'}
|
||
</Button>
|
||
</form>
|
||
|
||
<div className="mt-6 pt-4 border-t border-slate-100 dark:border-slate-800">
|
||
<p className="text-xs text-center text-slate-500">
|
||
默认账号:admin / admin123
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})
|
||
|
||
export default Login
|