jiachenlong/frontend/src/pages/Login.jsx

672 lines
21 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react'
import { APP_VERSION } from '../config/version'
import { api } from '../utils/api'
import { ErrorCodes } from '../utils/errorCodes'
export default function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [captcha, setCaptcha] = useState({ num1: 0, num2: 0, answer: '' })
// 注册相关状态
const [showRegister, setShowRegister] = useState(false)
const [registerData, setRegisterData] = useState({ agreeTerms: false,
username: '',
password: '',
confirmPassword: '',
email: '',
phone: '',
verifyCode: ''
})
const [registerLoading, setRegisterLoading] = useState(false)
const [registerError, setRegisterError] = useState('')
// 短信获取相关
const [sendingCode, setSendingCode] = useState(false)
const [codeCountdown, setCodeCountdown] = useState(0)
const [codeSent, setCodeSent] = useState(false)
// 生成获取
useEffect(() => {
generateCaptcha()
}, [])
// 获取倒计时
useEffect(() => {
if (codeCountdown > 0) {
const timer = setTimeout(() => setCodeCountdown(codeCountdown - 1), 1000)
return () => clearTimeout(timer)
}
}, [codeCountdown])
const generateCaptcha = () => {
const num1 = Math.floor(Math.random() * 10)
const num2 = Math.floor(Math.random() * 10)
setCaptcha({ num1, num2, answer: '' })
}
// 发送短信获取
const handleSendCode = async () => {
if (!registerData.phone) {
setRegisterError('请先输入手机号')
return
}
// 验证手机号格式
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(registerData.phone)) {
setRegisterError('请输入正确的手机号')
return
}
setSendingCode(true)
setRegisterError('')
try {
const res = await fetch('/api/auth/send-verification-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ phone: registerData.phone })
})
const data = await res.json()
if (data.success) {
setCodeSent(true)
setCodeCountdown(60)
setRegisterError('')
} else {
setRegisterError(data.message || '发送失败')
}
} catch (err) {
setRegisterError('发送失败,请稍后重试')
} finally {
setSendingCode(false)
}
}
// 处理注册
const handleRegister = async () => {
// 验证输入
if (!registerData.username || !registerData.password) {
setRegisterError('用户名和密码为必填项')
return
}
if (!registerData.agreeTerms) {
setRegisterError('请先同意用户协议')
return
}
if (registerData.username.length < 2) {
setRegisterError('用户名至少 2 个字符')
return
}
if (registerData.password.length < 8 || !/[A-Z]/.test(registerData.password) || !/[a-z]/.test(registerData.password) || !/[0-9]/.test(registerData.password)) {
setRegisterError('密码至少8位需包含大写、小写、数字')
return
}
if (registerData.password !== registerData.confirmPassword) {
setRegisterError('两次输入的密码不一致')
return
}
// 验证手机号
if (!registerData.phone) {
setRegisterError('手机号为必填项')
return
}
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(registerData.phone)) {
setRegisterError('请输入正确的手机号')
return
}
// 验证短信获取
if (!registerData.verifyCode) {
setRegisterError('请输入短信获取')
return
}
setRegisterLoading(true)
setRegisterError('')
try {
const payload = {
username: registerData.username,
password: registerData.password,
phone: registerData.phone,
verifyCode: registerData.verifyCode
}
if (registerData.email) {
payload.email = registerData.email
}
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.detail || data.error?.message || '注册失败')
}
alert('注册成功!请登录')
setShowRegister(false)
setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '' })
setCodeSent(false)
setCodeCountdown(0)
generateCaptcha()
} catch (err) {
console.error('注册错误:', err)
setRegisterError(err.message || '注册失败')
} finally {
setRegisterLoading(false)
}
}
const handleLogin = async () => {
// 验证输入
if (!username || !password) {
setError(ErrorCodes.E00020.message)
return
}
if (username.length < 2) {
setError(ErrorCodes.E00021.message)
return
}
// 验证获取
const expectedAnswer = captcha.num1 + captcha.num2
if (!captcha.answer || Number(captcha.answer) !== expectedAnswer) {
setError(ErrorCodes.E00012.message)
return
}
setLoading(true)
setError('')
try {
console.log('开始登录...')
const loginData = await api.auth.login(username, password)
console.log('登录响应:', loginData)
if (!loginData.access_token) {
throw new Error('登录响应中没有 access_token')
}
localStorage.setItem('token', loginData.access_token)
console.log('Token 已保存')
const userData = await api.user.me()
console.log('用户信息:', userData)
localStorage.setItem('user', JSON.stringify(userData))
console.log('用户信息已保存')
// 强制刷新页面,确保 React 状态更新
window.location.href = '/'
console.log('跳转首页')
} catch (err) {
console.error('登录错误:', err)
const errorCode = err.code || 'E00000'
const errorMsg = err.message || '登录失败'
setError(`${errorCode}: ${errorMsg}`)
} finally {
setLoading(false)
}
}
return (
<div style={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
}}>
{/* Logo - 龙型 + 甲辰收藏文字 (橙色圆形设计) */}
<div style={{ marginBottom: '40px', textAlign: 'center' }}>
<img
src="/static/images/jiachenlong-logo.png?v=1"
alt="甲辰收藏"
style={{
width: '200px',
height: '200px',
marginBottom: '12px',
borderRadius: '50%',
boxShadow: '0 0 40px rgba(251, 191, 36, 0.4)',
background: '#fff'
}}
/>
<p style={{ color: 'rgba(255,255,255,0.6)', fontSize: '14px', margin: 0 }}>生肖纪念钞管理系统</p>
</div>
{/* 登录表单 */}
<div style={{
width: '100%',
maxWidth: '320px',
background: 'rgba(30, 41, 59, 0.8)',
borderRadius: '16px',
padding: '24px',
border: '1px solid rgba(255,255,255,0.1)'
}}>
<h2 style={{ color: '#fff', fontSize: '20px', fontWeight: '600', marginBottom: '24px', textAlign: 'center' }}>
欢迎回来 👋
</h2>
{error && (
<div style={{
background: 'rgba(239, 68, 68, 0.2)',
border: '1px solid rgba(239, 68, 68, 0.5)',
borderRadius: '8px',
padding: '12px',
marginBottom: '16px',
color: '#fca5a5',
fontSize: '13px'
}}>
{error}
</div>
)}
{/* 用户名 */}
<div style={{ marginBottom: '16px' }}>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="用户名"
style={{
width: '100%',
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '16px',
outline: 'none'
}}
/>
</div>
{/* 密码 */}
<div style={{ marginBottom: '20px' }}>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="密码"
style={{
width: '100%',
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '16px',
outline: 'none'
}}
/>
</div>
{/* 获取 */}
<div style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', gap: '12px' }}>
<div onClick={generateCaptcha} style={{
flex: 1, minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fbbf24',
fontSize: '18px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: '700',
cursor: 'pointer',
userSelect: 'none'
}}>
{captcha.num1} + {captcha.num2} = ?
</div>
<input
type="text"
value={captcha.answer}
onChange={(e) => setCaptcha(prev => ({ ...prev, answer: e.target.value }))}
placeholder="?"
style={{
width: '80px',
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '18px',
outline: 'none',
textAlign: 'center',
fontWeight: '700'
}}
/>
</div>
</div>
{/* 登录按钮 */}
<button
onClick={handleLogin}
disabled={loading}
style={{
width: '100%',
padding: '16px',
borderRadius: '12px',
border: 'none',
background: loading ? '#f59e0b' : 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%)',
color: '#0f172a',
fontSize: '18px',
fontWeight: '700',
cursor: loading ? 'not-allowed' : 'pointer',
boxShadow: '0 4px 15px rgba(251, 191, 36, 0.3)'
}}
>
{loading ? '登录中...' : '登录'}
</button>
{/* 注册链接 */}
<div style={{ textAlign: 'center', marginTop: '16px', display: 'flex', justifyContent: 'center', gap: '16px' }}>
<span
onClick={() => setShowRegister(true)}
style={{
color: '#fbbf24',
fontSize: '14px',
cursor: 'pointer',
textDecoration: 'underline'
}}
>
注册新账号
</span>
<span style={{ color: 'rgba(255,255,255,0.3)' }}>|</span>
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: '14px' }}>
需要帮助联系管理员
</span>
</div>
</div>
{/* 版本号 */}
<div style={{
position: 'fixed',
bottom: '20px',
color: 'rgba(251, 191, 36, 0.5)',
fontSize: '12px',
textAlign: 'center',
fontWeight: '600'
}}>
v{APP_VERSION}
</div>
{/* 注册弹窗 */}
{showRegister && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
zIndex: 1000
}}>
<div style={{
width: '100%',
maxWidth: '320px',
background: 'rgba(30, 41, 59, 0.95)',
borderRadius: '16px',
padding: '24px',
border: '1px solid rgba(255,255,255,0.1)',
maxHeight: '90vh',
overflowY: 'auto'
}}>
<h2 style={{ color: '#fbbf24', fontSize: '20px', fontWeight: '600', marginBottom: '20px', textAlign: 'center' }}>
注册新账号 🎉
</h2>
{registerError && (
<div style={{
background: 'rgba(239, 68, 68, 0.2)',
border: '1px solid rgba(239, 68, 68, 0.5)',
borderRadius: '8px',
padding: '12px',
marginBottom: '16px',
color: '#fca5a5',
fontSize: '13px'
}}>
{registerError}
</div>
)}
{/* 用户名 */}
<div style={{ marginBottom: '16px' }}>
<input
type="text"
value={registerData.username}
onChange={(e) => setRegisterData(prev => ({ ...prev, username: e.target.value }))}
placeholder="用户名(至少 2 个字符)"
style={{
width: '100%',
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '16px',
outline: 'none'
}}
/>
</div>
{/* 手机号 */}
<div style={{ marginBottom: '16px' }}>
<input
type="tel"
value={registerData.phone}
onChange={(e) => setRegisterData(prev => ({ ...prev, phone: e.target.value }))}
placeholder="手机号11位"
maxLength={11}
style={{
width: '100%',
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '16px',
outline: 'none'
}}
/>
</div>
{/* 短信获取 */}
<div style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', gap: '12px' }}>
<input
type="text"
value={registerData.verifyCode}
onChange={(e) => setRegisterData(prev => ({ ...prev, verifyCode: e.target.value }))}
placeholder="短信验证码"
maxLength={6}
style={{
flex: 1, minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '16px',
outline: 'none'
}}
/>
<button
onClick={handleSendCode}
disabled={sendingCode || codeCountdown > 0}
style={{
minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: 'none',
background: codeCountdown > 0 ? 'rgba(148, 163, 184, 0.3)' : 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
color: '#fff',
fontSize: '14px',
fontWeight: '600',
cursor: codeCountdown > 0 ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap'
}}
>
{codeCountdown > 0 ? `${codeCountdown}` : sendingCode ? '发送中...' : '获取验证码'}
</button>
</div>
</div>
{/* 邮箱(可选) */}
<div style={{ marginBottom: '16px' }}>
<input
type="email"
value={registerData.email}
onChange={(e) => setRegisterData(prev => ({ ...prev, email: e.target.value }))}
placeholder="邮箱(可选)"
style={{
width: '100%',
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '16px',
outline: 'none'
}}
/>
</div>
{/* 密码 */}
<div style={{ marginBottom: '16px' }}>
<input
type="password"
value={registerData.password}
onChange={(e) => setRegisterData(prev => ({ ...prev, password: e.target.value }))}
placeholder="密码至少8位需大写+小写+数字)"
style={{
width: '100%',
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '16px',
outline: 'none'
}}
/>
</div>
{/* 确认密码 */}
<div style={{ marginBottom: '24px' }}>
<input
type="password"
value={registerData.confirmPassword}
onChange={(e) => setRegisterData(prev => ({ ...prev, confirmPassword: e.target.value }))}
placeholder="确认密码"
style={{
width: '100%',
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '16px',
outline: 'none'
}}
/>
</div>
{/* 用户协议勾选 */}
<div style={{ marginBottom: '20px', display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
<input
type="checkbox"
checked={registerData.agreeTerms}
onChange={(e) => setRegisterData(prev => ({ ...prev, agreeTerms: e.target.checked }))}
style={{ width: '18px', height: '18px', marginTop: '2px', cursor: 'pointer' }}
/>
<div style={{ fontSize: '12px', color: 'rgba(255,255,255,0.7)', lineHeight: '1.5' }}>
我已阅读并同意
<span
onClick={() => window.open('/user_agreement.html', '_blank')}
style={{ color: '#fbbf24', cursor: 'pointer', textDecoration: 'underline' }}
>用户协议</span>
</div>
</div>
{/* 按钮 */}
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={() => {
setShowRegister(false)
setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '' })
setRegisterError('')
setCodeSent(false)
setCodeCountdown(0)
}}
style={{
flex: 1, minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: 'none',
background: 'rgba(148, 163, 184, 0.2)',
color: '#94a3b8',
fontSize: '16px',
fontWeight: '600',
cursor: 'pointer'
}}
>
取消
</button>
<button
onClick={handleRegister}
disabled={registerLoading}
style={{
flex: 1, minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: 'none',
background: registerLoading ? '#f59e0b' : 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%)',
color: '#0f172a',
fontSize: '16px',
fontWeight: '600',
cursor: registerLoading ? 'not-allowed' : 'pointer'
}}
>
{registerLoading ? '注册中...' : '注册'}
</button>
</div>
</div>
</div>
)}
</div>
)
}