Merge pull request #132 from legeling/feature/frontend-refactor-vben-admin

fix: 优化登录注册验证流程和修复多个前端问题,以及修改密码失败的问题
This commit is contained in:
zhinianboke 2025-12-07 15:42:07 +08:00 committed by GitHub
commit 05778d8082
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 155 additions and 51 deletions

View File

@ -22,6 +22,7 @@ import { Logs } from '@/pages/admin/Logs'
import { RiskLogs } from '@/pages/admin/RiskLogs'
import { DataManagement } from '@/pages/admin/DataManagement'
import { verifyToken } from '@/api/auth'
import { Toast } from '@/components/common/Toast'
// Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@ -96,6 +97,8 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
function App() {
return (
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
{/* 全局 Toast 组件 */}
<Toast />
<Routes>
{/* Public routes */}
<Route path="/login" element={<Login />} />

View File

@ -1,5 +1,5 @@
import { get, post, put, del } from '@/utils/request'
import type { ApiResponse, NotificationChannel, MessageNotification } from '@/types'
import { del, get, post, put } from '@/utils/request'
import type { ApiResponse, MessageNotification, NotificationChannel } from '@/types'
// ========== 通知渠道 ==========
@ -90,9 +90,10 @@ export const deleteNotificationChannel = (channelId: string): Promise<ApiRespons
return del(`/notification-channels/${channelId}`)
}
// 测试通知渠道
export const testNotificationChannel = (channelId: string): Promise<ApiResponse> => {
return post(`/notification-channels/${channelId}/test`)
// 测试通知渠道 - 后端暂未实现此接口
export const testNotificationChannel = async (_channelId: string): Promise<ApiResponse> => {
// TODO: 后端暂未实现 POST /notification-channels/{id}/test 接口
return { success: false, message: '通知渠道测试功能暂未实现' }
}
// ========== 消息通知 ==========

View File

@ -20,20 +20,28 @@ export const getSystemSettings = async (): Promise<{ success: boolean; data?: Sy
// 更新系统设置
export const updateSystemSettings = async (data: Partial<SystemSettings>): Promise<ApiResponse> => {
// 逐个更新设置项,确保 value 是字符串
const promises = Object.entries(data).map(([key, value]) => {
// 将布尔值和数字转换为字符串
let stringValue: string
if (typeof value === 'boolean') {
stringValue = value ? 'true' : 'false'
} else if (typeof value === 'number') {
stringValue = String(value)
} else {
stringValue = value as string
}
return put(`/system-settings/${key}`, { value: stringValue })
})
await Promise.all(promises)
return { success: true, message: '设置已保存' }
const promises = Object.entries(data)
.filter(([, value]) => value !== undefined && value !== null) // 过滤掉空值
.map(([key, value]) => {
// 将布尔值和数字转换为字符串
let stringValue: string
if (typeof value === 'boolean') {
stringValue = value ? 'true' : 'false'
} else if (typeof value === 'number') {
stringValue = String(value)
} else {
stringValue = String(value ?? '')
}
return put(`/system-settings/${key}`, { value: stringValue })
})
try {
await Promise.all(promises)
return { success: true, message: '设置已保存' }
} catch (error) {
console.error('保存设置失败:', error)
return { success: false, message: '保存设置失败' }
}
}
// 获取 AI 设置
@ -59,8 +67,11 @@ export const testAIConnection = async (cookieId?: string): Promise<ApiResponse>
return { success: true, message: `AI 回复: ${result.reply}` }
}
return { success: result.success ?? true, message: result.message || 'AI 连接测试成功' }
} catch (error) {
return { success: false, message: 'AI 连接测试失败' }
} catch (error: unknown) {
// 提取后端返回的错误信息
const axiosError = error as { response?: { data?: { detail?: string; message?: string } } }
const detail = axiosError.response?.data?.detail || axiosError.response?.data?.message
return { success: false, message: detail || 'AI 连接测试失败' }
}
}
@ -83,9 +94,9 @@ export const testEmailSend = async (_email: string): Promise<ApiResponse> => {
return { success: false, message: '邮件测试功能暂未实现,请检查 SMTP 配置后直接保存' }
}
// 修改密码
// 修改密码(管理员)
export const changePassword = async (data: { current_password: string; new_password: string }): Promise<ApiResponse> => {
return post('/change-password', data)
return post('/change-admin-password', data)
}
// 获取备份文件列表(管理员)

View File

@ -36,6 +36,7 @@ export function Login() {
const [sessionId] = useState(() => `session_${Math.random().toString(36).substr(2, 9)}_${Date.now()}`)
const [captchaVerified, setCaptchaVerified] = useState(false)
const [countdown, setCountdown] = useState(0)
const [emailError, setEmailError] = useState('')
// 初始化主题
useEffect(() => {
@ -113,11 +114,12 @@ export function Login() {
}
}
const handleVerifyCaptcha = async () => {
if (captchaCode.length !== 4) return
const handleVerifyCaptcha = async (code?: string) => {
const codeToVerify = code || captchaCode
if (codeToVerify.length !== 4) return
try {
const result = await verifyCaptcha(sessionId, captchaCode)
const result = await verifyCaptcha(sessionId, codeToVerify)
if (result.success) {
setCaptchaVerified(true)
addToast({ type: 'success', message: '验证码验证成功' })
@ -131,8 +133,33 @@ export function Login() {
}
}
// 邮箱格式校验
const isValidEmail = (emailStr: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailStr)
// 邮箱输入处理
const handleEmailChange = (value: string) => {
setEmailForCode(value)
if (value && !isValidEmail(value)) {
setEmailError('请输入正确的邮箱格式')
} else {
setEmailError('')
}
}
const handleSendCode = async () => {
if (!captchaVerified || !emailForCode || countdown > 0) return
if (!emailForCode) {
setEmailError('请输入邮箱地址')
return
}
if (!isValidEmail(emailForCode)) {
setEmailError('请输入正确的邮箱格式')
return
}
if (!captchaVerified) {
addToast({ type: 'warning', message: '请先完成图形验证码验证' })
return
}
if (countdown > 0) return
try {
const result = await sendVerificationCode(emailForCode, 'login', sessionId)
@ -395,11 +422,12 @@ export function Login() {
<input
type="email"
value={emailForCode}
onChange={(e) => setEmailForCode(e.target.value)}
onChange={(e) => handleEmailChange(e.target.value)}
placeholder="name@example.com"
className="input-ios pl-9"
className={cn('input-ios pl-9', emailError && 'border-red-500 focus:border-red-500')}
/>
</div>
{emailError && <p className="text-xs text-red-500 mt-1">{emailError}</p>}
</div>
{/* Captcha */}
@ -410,14 +438,20 @@ export function Login() {
type="text"
value={captchaCode}
onChange={(e) => {
setCaptchaCode(e.target.value)
if (e.target.value.length === 4) {
setTimeout(handleVerifyCaptcha, 100)
const val = e.target.value.toUpperCase()
setCaptchaCode(val)
// 输入4位后自动验证
if (val.length === 4 && !captchaVerified) {
handleVerifyCaptcha(val)
}
}}
placeholder="输入验证码"
maxLength={4}
className="input-ios flex-1"
className={cn(
'input-ios flex-1',
captchaVerified && 'border-green-500 bg-green-50 dark:bg-green-900/20'
)}
disabled={captchaVerified}
/>
<img
src={captchaImage}
@ -430,7 +464,7 @@ export function Login() {
'text-xs',
captchaVerified ? 'text-green-600' : 'text-gray-400'
)}>
{captchaVerified ? '✓ 验证成功' : '点击图片更换验证码'}
{captchaVerified ? '✓ 验证成功,可以发送邮箱验证码' : '输入4位验证码后自动验证'}
</p>
</div>
@ -452,7 +486,7 @@ export function Login() {
<button
type="button"
onClick={handleSendCode}
disabled={!captchaVerified || !emailForCode || countdown > 0}
disabled={countdown > 0}
className="btn-ios-secondary whitespace-nowrap"
>
{countdown > 0 ? `${countdown}s` : '发送'}

View File

@ -27,6 +27,7 @@ export function Register() {
const [sessionId] = useState(() => `session_${Math.random().toString(36).substr(2, 9)}_${Date.now()}`)
const [captchaVerified, setCaptchaVerified] = useState(false)
const [countdown, setCountdown] = useState(0)
const [emailError, setEmailError] = useState('')
useEffect(() => {
getRegistrationStatus()
@ -63,11 +64,12 @@ export function Register() {
}
}
const handleVerifyCaptcha = async () => {
if (captchaCode.length !== 4) return
const handleVerifyCaptcha = async (code?: string) => {
const codeToVerify = code || captchaCode
if (codeToVerify.length !== 4) return
try {
const result = await verifyCaptcha(sessionId, captchaCode)
const result = await verifyCaptcha(sessionId, codeToVerify)
if (result.success) {
setCaptchaVerified(true)
addToast({ type: 'success', message: '验证码验证成功' })
@ -81,8 +83,33 @@ export function Register() {
}
}
// 邮箱格式校验
const isValidEmail = (emailStr: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailStr)
// 邮箱输入处理
const handleEmailChange = (value: string) => {
setEmail(value)
if (value && !isValidEmail(value)) {
setEmailError('请输入正确的邮箱格式')
} else {
setEmailError('')
}
}
const handleSendCode = async () => {
if (!captchaVerified || !email || countdown > 0) return
if (!email) {
setEmailError('请输入邮箱地址')
return
}
if (!isValidEmail(email)) {
setEmailError('请输入正确的邮箱格式')
return
}
if (!captchaVerified) {
addToast({ type: 'warning', message: '请先完成图形验证码验证' })
return
}
if (countdown > 0) return
try {
const result = await sendVerificationCode(email, 'register', sessionId)
@ -194,11 +221,12 @@ export function Register() {
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onChange={(e) => handleEmailChange(e.target.value)}
placeholder="name@example.com"
className="input-ios pl-9"
className={cn('input-ios pl-9', emailError && 'border-red-500 focus:border-red-500')}
/>
</div>
{emailError && <p className="text-xs text-red-500 mt-1">{emailError}</p>}
</div>
{/* Password */}
@ -246,14 +274,19 @@ export function Register() {
type="text"
value={captchaCode}
onChange={(e) => {
setCaptchaCode(e.target.value)
if (e.target.value.length === 4) {
setTimeout(handleVerifyCaptcha, 100)
const val = e.target.value.toUpperCase()
setCaptchaCode(val)
if (val.length === 4 && !captchaVerified) {
handleVerifyCaptcha(val)
}
}}
disabled={captchaVerified}
placeholder="输入验证码"
maxLength={4}
className="input-ios flex-1"
className={cn(
'input-ios flex-1',
captchaVerified && 'border-green-500 bg-green-50 dark:bg-green-900/20',
)}
/>
<img
src={captchaImage}
@ -266,7 +299,7 @@ export function Register() {
'text-xs',
captchaVerified ? 'text-green-600 dark:text-green-400' : 'text-slate-400'
)}>
{captchaVerified ? '✓ 验证成功' : '点击图片更换验证码'}
{captchaVerified ? '✓ 验证成功,可以发送邮箱验证码' : '输入4位验证码后自动验证'}
</p>
</div>
@ -288,7 +321,7 @@ export function Register() {
<button
type="button"
onClick={handleSendCode}
disabled={!captchaVerified || !email || countdown > 0}
disabled={countdown > 0}
className="btn-ios-secondary whitespace-nowrap"
>
{countdown > 0 ? `${countdown}s` : '发送'}

View File

@ -345,11 +345,11 @@ export function Items() {
</tr>
) : (
filteredItems.map((item) => (
<tr key={item.id} className={selectedIds.has(item.id) ? 'bg-blue-50' : ''}>
<tr key={item.id} className={selectedIds.has(item.id) ? 'bg-blue-50 dark:bg-blue-900/30' : ''}>
<td>
<button
onClick={() => toggleSelect(item.id)}
className="p-1 hover:bg-gray-100 rounded"
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
{selectedIds.has(item.id) ? (
<CheckSquare className="w-4 h-4 text-blue-600 dark:text-blue-400" />

View File

@ -92,6 +92,12 @@ export default defineConfig(({ command }) => ({
'/register': {
target: 'http://localhost:8080',
changeOrigin: true,
bypass: (req) => {
// 浏览器直接访问时返回前端页面,只有 POST 请求才代理到后端
if (req.method === 'GET' && req.headers.accept?.includes('text/html')) {
return '/index.html'
}
},
},
'/itemReplays': {
target: 'http://localhost:8080',
@ -149,6 +155,18 @@ export default defineConfig(({ command }) => ({
target: 'http://localhost:8080',
changeOrigin: true,
},
'/change-admin-password': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/logout': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/user-settings': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/search': {
target: 'http://localhost:8080',
changeOrigin: true,
@ -158,8 +176,12 @@ export default defineConfig(({ command }) => ({
target: 'http://localhost:8080',
changeOrigin: true,
bypass: (req) => {
// 浏览器直接访问Accept 包含 text/html让前端路由处理
if (req.headers.accept?.includes('text/html')) {
// 只有浏览器直接访问 /items 路径时才返回前端页面
// API 请求通常是 /items/xxx 或带有 application/json
const isApiRequest = req.url !== '/items' ||
req.headers.accept?.includes('application/json') ||
req.headers['content-type']?.includes('application/json')
if (!isApiRequest && req.headers.accept?.includes('text/html')) {
return '/index.html'
}
},