Files
stock/serve/frontend/src/pages/Auth.tsx
T
2026-07-04 16:59:25 +08:00

196 lines
7.9 KiB
TypeScript

/**
* 访问认证页 — 复用同一组件处理「首次设密码」和「登录」两种状态。
*
* 根据后端 /api/auth/status 的 configured 字段决定显示:
* - configured=false → 显示「设置访问密码」(首次)
* - configured=true → 显示「登录」
*
* 安全:
* - 设密码接口后端限本机/内网; 公网用户设密码会被 403 拒绝, 页面据此提示。
* - 登录失败由后端限流(5次锁5分钟), 429 时前端显示等待提示。
*/
import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { Eye, EyeOff, Loader2, Lock, ShieldCheck, ShieldAlert, Sparkles } from 'lucide-react'
import { api } from '@/lib/api'
import { Logo } from '@/components/Logo'
import { cn } from '@/lib/cn'
export function Auth() {
const navigate = useNavigate()
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('') // 仅设密码时用
const [showPwd, setShowPwd] = useState(false)
const [localError, setLocalError] = useState('')
// 取认证状态(是否已设密码)
const [status, setStatus] = useState<{ configured: boolean } | null>(null)
useEffect(() => {
api.authStatus().then(s => {
setStatus(s)
// 已登录的话直接进面板(避免登录页死循环)
if (s.authenticated) navigate('/', { replace: true })
}).catch(() => setStatus({ configured: false }))
}, [navigate])
const isSetup = !status?.configured // configured=false → 设密码模式
// 登录 / 设密码 共用一个 mutation(按 isSetup 调不同接口)
const submitMut = useMutation({
mutationFn: async () => {
if (isSetup) {
return api.authSetup(password)
}
return api.authLogin(password)
},
onSuccess: () => {
// 成功: 跳回原页面(或首页)
const redirect = new URLSearchParams(window.location.search).get('redirect') || '/'
navigate(redirect, { replace: true })
},
onError: (err: any) => {
const msg = err?.message || (isSetup ? '设置失败' : '登录失败')
// 设密码/登录失败必须显示: 401(密码错)/403(公网设密码被拒)/429(限流) 都要提示
setLocalError(msg)
},
})
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
setLocalError('')
if (isSetup) {
if (password.length < 6) { setLocalError('密码至少 6 位'); return }
if (password !== confirmPassword) { setLocalError('两次密码不一致'); return }
}
submitMut.mutate()
}
if (!status) {
return (
<div className="flex min-h-screen items-center justify-center bg-base">
<Loader2 className="h-6 w-6 animate-spin text-muted" />
</div>
)
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-base px-4">
{/* 背景辉光(与 Onboarding 风格一致) */}
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(139,92,246,0.15),transparent_40%),radial-gradient(circle_at_70%_80%,rgba(59,130,246,0.12),transparent_40%)]" />
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="relative w-full max-w-sm"
>
{/* Logo */}
<div className="mb-6 flex flex-col items-center gap-2">
<Logo className="h-10 w-10" />
<h1 className="text-lg font-semibold text-foreground">TickFlow Stock Panel</h1>
</div>
<div className="rounded-card border border-border bg-surface/90 p-6 shadow-2xl backdrop-blur">
{/* 标题区: 图标 + 文案随模式切换 */}
<div className="mb-5 flex items-center gap-2.5">
<div className={cn(
'grid h-9 w-9 place-items-center rounded-lg',
isSetup ? 'bg-accent/15 text-accent' : 'bg-purple-500/15 text-purple-400',
)}>
{isSetup ? <ShieldCheck className="h-5 w-5" /> : <Lock className="h-5 w-5" />}
</div>
<div>
<div className="text-sm font-medium text-foreground">
{isSetup ? '设置访问密码' : '登录访问'}
</div>
<div className="text-[11px] text-muted">
{isSetup ? '首次使用, 请为面板设置访问密码' : '请输入访问密码以继续'}
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
{/* 密码输入 */}
<div className="relative">
<input
type={showPwd ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="访问密码"
autoFocus
className="h-10 w-full rounded-btn border border-border bg-base px-3 pr-9 text-sm text-foreground outline-none transition-colors focus:border-accent/50"
/>
<button
type="button"
onClick={() => setShowPwd(s => !s)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted hover:text-foreground"
tabIndex={-1}
>
{showPwd ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{/* 确认密码(仅设密码模式) */}
{isSetup && (
<input
type={showPwd ? 'text' : 'password'}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
placeholder="再次输入密码"
className="h-10 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none transition-colors focus:border-accent/50"
/>
)}
{/* 错误提示 */}
{(localError || submitMut.error) && (
<div className="flex items-start gap-1.5 rounded-btn bg-danger/10 px-3 py-2 text-[11px] text-danger">
<ShieldAlert className="mt-px h-3.5 w-3.5 shrink-0" />
<span>{localError}</span>
</div>
)}
<button
type="submit"
disabled={submitMut.isPending || !password}
className="inline-flex h-10 w-full items-center justify-center gap-1.5 rounded-btn bg-accent text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
>
{submitMut.isPending ? (
<><Loader2 className="h-4 w-4 animate-spin" />处理中…</>
) : (
<>{isSetup ? '设置并进入' : '登录'}</>
)}
</button>
</form>
{/* 提示: 设密码模式告知本机限制 */}
{isSetup && (
<div className="mt-3 space-y-1.5 text-[10px] leading-relaxed text-muted/70">
<p>
出于安全考虑, 首次设置密码需在服务器本机或内网访问时操作。公网环境下仅可登录。
</p>
<p>
详细配置说明见{' '}
<a
href="https://github.com/shy3130/tickflow-stock-panel/blob/main/docs/deploy-password.md"
target="_blank"
rel="noreferrer"
className="text-accent underline-offset-2 hover:underline"
>
访问密码部署文档
</a>
</p>
</div>
)}
</div>
<div className="mt-4 flex items-center justify-center gap-1.5 text-[10px] text-muted/60">
<Sparkles className="h-3 w-3" />
自托管量化工作台 · 数据完全掌握在自己手里
</div>
</motion.div>
</div>
)
}