feat: 前端认证系统重构 - 密码注册和多种登入方式

前端改动:

RegisterView.vue:
- 改为传统密码注册(邮箱+用户名+手机号+密码)
- 移除邮箱验证码逻辑
- 注册成功后自动登入并跳转到首页

LoginView.vue:
- 求职者和管理员两个角色选项卡
- 求职者支持两种登入方式:
  * 邮箱/用户名 + 密码
  * 邮箱 + 验证码(快速登入)
- 登入方式通过子选项卡切换
- 添加"忘记密码"链接指向密码重置页面
- 管理员仍使用用户名+密码登入

ResetPasswordView.vue (新建):
- 两步流程:
  1. 输入邮箱 → 获取验证码 → 输入验证码
  2. 输入新密码 → 确认密码 → 重置完成
- 验证码倒计时和重新获取
- 密码重置成功后跳转到登入页

API更新 (auth.js):
- 修改 register() 使用 axios 而非 client
- 新增 resetPassword() - 请求密码重置
- 新增 confirmResetPassword() - 确认密码重置

路由更新 (router/index.js):
- 新增 /forgot-password 路由

设计特点:
- 统一的多种登入方式UI
- 清晰的密码重置流程
- 保留邮箱验证码快速登入选项

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
TianyangZhang 2026-03-25 16:15:58 +08:00
parent 8a5ed86421
commit 911e872a4a
5 changed files with 491 additions and 183 deletions

View File

@ -3,6 +3,9 @@ import axios from 'axios'
export const sendCode = (email) => axios.post('/api/auth/send-code/', { email }) export const sendCode = (email) => axios.post('/api/auth/send-code/', { email })
export const loginApi = (data) => axios.post('/api/auth/login/', data) export const loginApi = (data) => axios.post('/api/auth/login/', data)
export const register = (data) => client.post('/auth/register/', data) export const register = (data) => axios.post('/api/auth/register/', data)
export const getMe = () => client.get('/auth/me/') export const getMe = () => client.get('/auth/me/')
export const updateMe = (data) => client.patch('/auth/me/', data) export const updateMe = (data) => client.patch('/auth/me/', data)
export const resetPassword = (email) => axios.post('/api/auth/reset-password/', { email })
export const confirmResetPassword = (email, code, newPassword) =>
axios.post('/api/auth/confirm-reset-password/', { email, code, new_password: newPassword })

View File

@ -18,6 +18,7 @@ const routes = [
}, },
{ path: '/login', name: 'Login', component: () => import('@/views/auth/LoginView.vue') }, { path: '/login', name: 'Login', component: () => import('@/views/auth/LoginView.vue') },
{ path: '/register', name: 'Register', component: () => import('@/views/auth/RegisterView.vue') }, { path: '/register', name: 'Register', component: () => import('@/views/auth/RegisterView.vue') },
{ path: '/forgot-password', name: 'ResetPassword', component: () => import('@/views/auth/ResetPasswordView.vue') },
// 求职者中心 // 求职者中心
{ {
path: '/seeker', path: '/seeker',

View File

@ -19,71 +19,112 @@
</button> </button>
</div> </div>
<!-- 求职者登入邮箱验证码 --> <!-- 求职者登入 -->
<el-form v-if="role === 'seeker'" :model="seekerForm" @submit.prevent="handleSeekerLogin"> <div v-if="role === 'seeker'" class="login-container">
<!-- 邮箱输入 --> <!-- 登入方式选择 -->
<el-form-item> <div class="method-tabs">
<el-input <button
v-model="seekerForm.email" :class="['method-tab', { active: seekerMethod === 'password' }]"
placeholder="请输入邮箱" @click="seekerMethod = 'password'"
type="email" >
:disabled="codeSent" 邮箱/用户名登入
/> </button>
</el-form-item> <button
:class="['method-tab', { active: seekerMethod === 'code' }]"
@click="seekerMethod = 'code'"
>
验证码登入
</button>
</div>
<!-- 获取验证码按钮 --> <!-- 密码登入 -->
<el-form-item v-if="!codeSent"> <el-form v-if="seekerMethod === 'password'" :model="passwordForm" @submit.prevent="handlePasswordLogin">
<el-form-item>
<el-input
v-model="passwordForm.username"
placeholder="邮箱或用户名"
/>
</el-form-item>
<el-form-item>
<el-input
v-model="passwordForm.password"
type="password"
placeholder="密码"
show-password
/>
</el-form-item>
<el-button <el-button
type="info" type="primary"
@click="handleSendCode" native-type="submit"
:loading="sendingCode" :loading="passwordLoading"
style="width: 100%" style="width: 100%"
> >
获取验证码 登录
</el-button> </el-button>
</el-form-item> <div style="text-align: right; margin-top: 8px;">
<router-link to="/forgot-password" style="color: #909399; font-size: 12px; text-decoration: none;">
忘记密码
</router-link>
</div>
</el-form>
<!-- 验证码输入 --> <!-- 验证码登入 -->
<el-form-item v-if="codeSent"> <el-form v-if="seekerMethod === 'code'" @submit.prevent="handleCodeLogin">
<div style="display: flex; gap: 8px;"> <!-- 邮箱输入 -->
<el-form-item>
<el-input <el-input
v-model="seekerForm.code" v-model="codeForm.email"
placeholder="请输入验证码6位" type="email"
maxlength="6" placeholder="请输入邮箱"
style="flex: 1" :disabled="codeSent"
/> />
</el-form-item>
<!-- 获取验证码按钮 -->
<el-form-item v-if="!codeSent">
<el-button <el-button
type="info" type="info"
@click="handleResendCode" @click="handleSendCode"
:disabled="codeCountdown > 0" :loading="sendingCode"
style="width: 100%"
> >
{{ codeCountdown > 0 ? `${codeCountdown}s` : '重新获取' }} 获取验证码
</el-button> </el-button>
</div> </el-form-item>
</el-form-item>
<!-- 错误提示 --> <!-- 验证码输入 -->
<div v-if="codeSent && attemptsLeft < 5" style="margin-bottom: 12px; text-align: center; color: #f56c6c; font-size: 12px;"> <el-form-item v-if="codeSent">
还有 {{ attemptsLeft }} 次重试机会 <div style="display: flex; gap: 8px;">
</div> <el-input
<div v-if="isLocked" style="margin-bottom: 12px; text-align: center; color: #f56c6c; font-size: 12px;"> v-model="codeForm.code"
验证次数过多请10分钟后重试 placeholder="请输入验证码6位"
</div> maxlength="6"
style="flex: 1"
/>
<el-button
type="info"
@click="handleResendCode"
:disabled="codeCountdown > 0"
>
{{ codeCountdown > 0 ? `${codeCountdown}s` : '重新获取' }}
</el-button>
</div>
</el-form-item>
<!-- 登录按钮 --> <!-- 登录按钮 -->
<el-button <el-button
v-if="codeSent" v-if="codeSent"
type="primary" type="primary"
native-type="submit" native-type="submit"
:loading="seekerLoading" :loading="codeLoading"
style="width: 100%" style="width: 100%"
:disabled="isLocked" >
> 登录
登录 </el-button>
</el-button> </el-form>
</el-form> </div>
<!-- 管理员登入用户名密码 --> <!-- 管理员登入 -->
<el-form v-if="role === 'admin'" :model="adminForm" @submit.prevent="handleAdminLogin"> <el-form v-if="role === 'admin'" :model="adminForm" @submit.prevent="handleAdminLogin">
<el-form-item> <el-form-item>
<el-input <el-input
@ -126,17 +167,20 @@ import { ElMessage } from 'element-plus'
import { sendCode, loginApi } from '@/api/auth' import { sendCode, loginApi } from '@/api/auth'
const role = ref('seeker') const role = ref('seeker')
const seekerMethod = ref('password')
// //
const seekerForm = reactive({ email: '', code: '' }) const passwordForm = reactive({ username: '', password: '' })
const passwordLoading = ref(false)
//
const codeForm = reactive({ email: '', code: '' })
const codeSent = ref(false) const codeSent = ref(false)
const sendingCode = ref(false) const sendingCode = ref(false)
const seekerLoading = ref(false) const codeLoading = ref(false)
const codeCountdown = ref(0) const codeCountdown = ref(0)
const attemptsLeft = ref(5)
const isLocked = ref(false)
// //
const adminForm = reactive({ username: '', password: '' }) const adminForm = reactive({ username: '', password: '' })
const adminLoading = ref(false) const adminLoading = ref(false)
@ -155,26 +199,19 @@ const startCountdown = () => {
// //
async function handleSendCode() { async function handleSendCode() {
if (!seekerForm.email) { if (!codeForm.email) {
ElMessage.warning('请输入邮箱') ElMessage.warning('请输入邮箱')
return return
} }
sendingCode.value = true sendingCode.value = true
try { try {
await sendCode(seekerForm.email) await sendCode(codeForm.email)
ElMessage.success('验证码已发送到您的邮箱') ElMessage.success('验证码已发送到您的邮箱')
codeSent.value = true codeSent.value = true
attemptsLeft.value = 5
isLocked.value = false
startCountdown() startCountdown()
} catch (err) { } catch (err) {
const errorMsg = err.response?.data?.error || ElMessage.error(err.response?.data?.email?.[0] || err.response?.data?.error || '发送失败')
err.response?.data?.email?.[0] ||
err.response?.data?.detail ||
'发送失败,请检查邮箱是否正确'
console.error('SendCode Error:', err.response?.data)
ElMessage.error(errorMsg)
} finally { } finally {
sendingCode.value = false sendingCode.value = false
} }
@ -182,43 +219,63 @@ async function handleSendCode() {
// //
async function handleResendCode() { async function handleResendCode() {
seekerForm.code = '' codeForm.code = ''
await handleSendCode() await handleSendCode()
} }
// //
async function handleSeekerLogin() { async function handlePasswordLogin() {
if (!seekerForm.code || seekerForm.code.length !== 6) { if (!passwordForm.username || !passwordForm.password) {
ElMessage.warning('请输入正确的6位验证码') ElMessage.warning('请输入邮箱/用户名和密码')
return return
} }
seekerLoading.value = true passwordLoading.value = true
try { try {
await auth.login(seekerForm.email, seekerForm.code) const { data } = await loginApi({
username: passwordForm.username,
password: passwordForm.password,
})
localStorage.setItem('access_token', data.access)
localStorage.setItem('refresh_token', data.refresh)
await auth.fetchMe()
ElMessage.success('登录成功') ElMessage.success('登录成功')
const redirect = route.query.redirect const redirect = route.query.redirect
if (redirect) return router.push(redirect) if (redirect) return router.push(redirect)
router.push('/seeker/resume') router.push('/seeker/resume')
} catch (err) { } catch (err) {
const errorMsg = err.response?.data?.non_field_errors?.[0] || ElMessage.error(err.response?.data?.detail || err.response?.data?.username?.[0] || '登录失败')
err.response?.data?.code?.[0] ||
err.response?.data?.email?.[0] ||
err.response?.data?.error ||
'登录失败'
ElMessage.error(errorMsg)
//
if (errorMsg.includes('错误')) {
attemptsLeft.value = Math.max(0, attemptsLeft.value - 1)
if (attemptsLeft.value === 0) {
isLocked.value = true
ElMessage.error('验证次数过多请10分钟后重试')
}
}
} finally { } finally {
seekerLoading.value = false passwordLoading.value = false
}
}
//
async function handleCodeLogin() {
if (!codeForm.code || codeForm.code.length !== 6) {
ElMessage.warning('请输入正确的6位验证码')
return
}
codeLoading.value = true
try {
const { data } = await loginApi({
email: codeForm.email,
code: codeForm.code,
})
localStorage.setItem('access_token', data.access)
localStorage.setItem('refresh_token', data.refresh)
await auth.fetchMe()
ElMessage.success('登录成功')
const redirect = route.query.redirect
if (redirect) return router.push(redirect)
router.push('/seeker/resume')
} catch (err) {
ElMessage.error(err.response?.data?.code?.[0] || '登录失败')
} finally {
codeLoading.value = false
} }
} }
@ -231,7 +288,10 @@ async function handleAdminLogin() {
adminLoading.value = true adminLoading.value = true
try { try {
const { data } = await loginApi({ username: adminForm.username, password: adminForm.password }) const { data } = await loginApi({
username: adminForm.username,
password: adminForm.password,
})
localStorage.setItem('access_token', data.access) localStorage.setItem('access_token', data.access)
localStorage.setItem('refresh_token', data.refresh) localStorage.setItem('refresh_token', data.refresh)
await auth.fetchMe() await auth.fetchMe()
@ -241,7 +301,7 @@ async function handleAdminLogin() {
if (redirect) return router.push(redirect) if (redirect) return router.push(redirect)
router.push('/admin/jobs') router.push('/admin/jobs')
} catch (err) { } catch (err) {
ElMessage.error('用户名或密码错误') ElMessage.error(err.response?.data?.detail || '用户名或密码错误')
} finally { } finally {
adminLoading.value = false adminLoading.value = false
} }
@ -298,4 +358,40 @@ h2 {
font-weight: 600; font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
} }
.method-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
background: #f5f7fa;
padding: 4px;
border-radius: 4px;
}
.method-tab {
flex: 1;
padding: 6px 12px;
border: none;
background: transparent;
cursor: pointer;
font-size: 12px;
color: #606266;
border-radius: 3px;
transition: all 0.2s;
}
.method-tab:hover {
background: rgba(255, 255, 255, 0.5);
}
.method-tab.active {
background: #fff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.login-container {
width: 100%;
}
</style> </style>

View File

@ -3,67 +3,56 @@
<el-card class="auth-card"> <el-card class="auth-card">
<h2>求职者注册</h2> <h2>求职者注册</h2>
<el-form :model="form" @submit.prevent="handleRegister"> <el-form :model="form" @submit.prevent="handleRegister">
<!-- 用户名邮箱手机号 --> <!-- 邮箱 -->
<el-form-item v-if="!codeSent"> <el-form-item>
<el-input
v-model="form.username"
placeholder="请输入用户名"
:disabled="codeSent"
/>
</el-form-item>
<el-form-item v-if="!codeSent">
<el-input <el-input
v-model="form.email" v-model="form.email"
type="email" type="email"
placeholder="请输入邮箱" placeholder="请输入邮箱"
:disabled="codeSent"
/> />
</el-form-item> </el-form-item>
<el-form-item v-if="!codeSent">
<!-- 用户名 -->
<el-form-item>
<el-input
v-model="form.username"
placeholder="请输入用户名"
/>
</el-form-item>
<!-- 手机号 -->
<el-form-item>
<el-input <el-input
v-model="form.phone" v-model="form.phone"
placeholder="请输入手机号" placeholder="请输入手机号"
:disabled="codeSent"
/> />
</el-form-item> </el-form-item>
<!-- 获取验证码按钮 --> <!-- 密码 -->
<el-form-item v-if="!codeSent"> <el-form-item>
<el-button <el-input
type="info" v-model="form.password"
@click="handleSendCode" type="password"
:loading="sendingCode" placeholder="请输入密码至少6位"
style="width: 100%" show-password
> />
获取验证码
</el-button>
</el-form-item> </el-form-item>
<!-- 验证码输入 --> <!-- 确认密码 -->
<el-form-item v-if="codeSent"> <el-form-item>
<div style="display: flex; gap: 8px;"> <el-input
<el-input v-model="form.passwordConfirm"
v-model="form.code" type="password"
placeholder="请输入验证码6位" placeholder="请确认密码"
maxlength="6" show-password
style="flex: 1" />
/>
<el-button
type="info"
@click="handleResendCode"
:disabled="codeCountdown > 0"
>
{{ codeCountdown > 0 ? `${codeCountdown}s` : '重新获取' }}
</el-button>
</div>
</el-form-item> </el-form-item>
<!-- 注册按钮 --> <!-- 注册按钮 -->
<el-button <el-button
v-if="codeSent"
type="primary" type="primary"
native-type="submit" native-type="submit"
:loading="registering" :loading="loading"
style="width: 100%" style="width: 100%"
> >
注册 注册
@ -82,68 +71,43 @@ import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { sendCode } from '@/api/auth'
const form = reactive({ username: '', email: '', phone: '', code: '' }) const form = reactive({
const codeSent = ref(false) email: '',
const sendingCode = ref(false) username: '',
const registering = ref(false) phone: '',
const codeCountdown = ref(0) password: '',
passwordConfirm: '',
})
const loading = ref(false)
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
// async function handleRegister() {
const startCountdown = () => { //
codeCountdown.value = 60
const interval = setInterval(() => {
codeCountdown.value--
if (codeCountdown.value <= 0) clearInterval(interval)
}, 1000)
}
//
async function handleSendCode() {
if (!form.username) {
ElMessage.warning('请输入用户名')
return
}
if (!form.email) { if (!form.email) {
ElMessage.warning('请输入邮箱') ElMessage.warning('请输入邮箱')
return return
} }
if (!form.username) {
ElMessage.warning('请输入用户名')
return
}
if (!form.phone) { if (!form.phone) {
ElMessage.warning('请输入手机号') ElMessage.warning('请输入手机号')
return return
} }
if (!form.password || form.password.length < 6) {
sendingCode.value = true ElMessage.warning('请输入至少6位密码')
try { return
await sendCode(form.email)
ElMessage.success('验证码已发送到您的邮箱')
codeSent.value = true
startCountdown()
} catch (err) {
ElMessage.error(err.response?.data?.email?.[0] || err.response?.data?.error || '发送失败')
} finally {
sendingCode.value = false
} }
} if (form.password !== form.passwordConfirm) {
ElMessage.warning('两次输入的密码不一致')
//
async function handleResendCode() {
form.code = ''
await handleSendCode()
}
//
async function handleRegister() {
if (!form.code || form.code.length !== 6) {
ElMessage.warning('请输入正确的6位验证码')
return return
} }
registering.value = true loading.value = true
try { try {
const response = await fetch('/api/auth/register/', { const response = await fetch('/api/auth/register/', {
method: 'POST', method: 'POST',
@ -151,16 +115,16 @@ async function handleRegister() {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
username: form.username,
email: form.email, email: form.email,
username: form.username,
phone: form.phone, phone: form.phone,
code: form.code, password: form.password,
}), }),
}) })
if (!response.ok) { if (!response.ok) {
const data = await response.json() const data = await response.json()
const errorMsg = data.code?.[0] || data.username?.[0] || data.email?.[0] || data.phone?.[0] || '注册失败' const errorMsg = data.username?.[0] || data.email?.[0] || data.phone?.[0] || data.password?.[0] || '注册失败'
ElMessage.error(errorMsg) ElMessage.error(errorMsg)
return return
} }
@ -177,7 +141,7 @@ async function handleRegister() {
} catch (err) { } catch (err) {
ElMessage.error('注册失败,请稍后重试') ElMessage.error('注册失败,请稍后重试')
} finally { } finally {
registering.value = false loading.value = false
} }
} }
</script> </script>

View File

@ -0,0 +1,244 @@
<template>
<div class="auth-page">
<el-card class="auth-card">
<h2>重置密码</h2>
<el-form @submit.prevent="handleResetPassword">
<!-- 第一步邮箱验证 -->
<div v-if="step === 1">
<el-form-item>
<el-input
v-model="form.email"
type="email"
placeholder="请输入邮箱"
:disabled="codeSent"
/>
</el-form-item>
<el-form-item v-if="!codeSent">
<el-button
type="info"
@click="handleSendCode"
:loading="sendingCode"
style="width: 100%"
>
获取验证码
</el-button>
</el-form-item>
<el-form-item v-if="codeSent">
<div style="display: flex; gap: 8px;">
<el-input
v-model="form.code"
placeholder="请输入验证码6位"
maxlength="6"
style="flex: 1"
/>
<el-button
type="info"
@click="handleResendCode"
:disabled="codeCountdown > 0"
>
{{ codeCountdown > 0 ? `${codeCountdown}s` : '重新获取' }}
</el-button>
</div>
</el-form-item>
<el-button
v-if="codeSent"
type="primary"
@click="goToStep2"
style="width: 100%"
>
下一步
</el-button>
</div>
<!-- 第二步重置密码 -->
<div v-if="step === 2">
<div style="margin-bottom: 16px; padding: 8px 12px; background: #f0f9ff; border-radius: 4px; color: #606266; font-size: 12px;">
邮箱<span style="font-weight: 600;">{{ form.email }}</span>
</div>
<el-form-item>
<el-input
v-model="form.password"
type="password"
placeholder="请输入新密码至少6位"
show-password
/>
</el-form-item>
<el-form-item>
<el-input
v-model="form.passwordConfirm"
type="password"
placeholder="请确认新密码"
show-password
/>
</el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="resetLoading"
style="width: 100%"
>
重置密码
</el-button>
<el-button
@click="goBackToStep1"
style="width: 100%; margin-top: 8px;"
>
上一步
</el-button>
</div>
</el-form>
<div style="margin-top: 12px; text-align: center; font-size: 12px;">
<router-link to="/login" style="color: #409eff; text-decoration: none;">返回登录</router-link>
</div>
</el-card>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
const step = ref(1)
const form = reactive({
email: '',
code: '',
password: '',
passwordConfirm: '',
})
const codeSent = ref(false)
const sendingCode = ref(false)
const resetLoading = ref(false)
const codeCountdown = ref(0)
const router = useRouter()
//
const startCountdown = () => {
codeCountdown.value = 60
const interval = setInterval(() => {
codeCountdown.value--
if (codeCountdown.value <= 0) clearInterval(interval)
}, 1000)
}
//
async function handleSendCode() {
if (!form.email) {
ElMessage.warning('请输入邮箱')
return
}
sendingCode.value = true
try {
const response = await fetch('/api/auth/reset-password/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: form.email }),
})
if (!response.ok) {
const data = await response.json()
ElMessage.error(data.email?.[0] || '发送失败')
return
}
ElMessage.success('验证码已发送到您的邮箱')
codeSent.value = true
startCountdown()
} catch (err) {
ElMessage.error('发送失败,请稍后重试')
} finally {
sendingCode.value = false
}
}
//
async function handleResendCode() {
form.code = ''
await handleSendCode()
}
//
function goToStep2() {
if (!form.code || form.code.length !== 6) {
ElMessage.warning('请输入正确的6位验证码')
return
}
step.value = 2
}
//
function goBackToStep1() {
step.value = 1
}
//
async function handleResetPassword() {
if (!form.password || form.password.length < 6) {
ElMessage.warning('请输入至少6位密码')
return
}
if (form.password !== form.passwordConfirm) {
ElMessage.warning('两次输入的密码不一致')
return
}
resetLoading.value = true
try {
const response = await fetch('/api/auth/confirm-reset-password/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: form.email,
code: form.code,
new_password: form.password,
}),
})
if (!response.ok) {
const data = await response.json()
const errorMsg = data.code?.[0] || data.new_password?.[0] || '重置失败'
ElMessage.error(errorMsg)
return
}
ElMessage.success('密码重置成功,请使用新密码登入')
router.push('/login')
} catch (err) {
ElMessage.error('密码重置失败,请稍后重试')
} finally {
resetLoading.value = false
}
}
</script>
<style scoped>
.auth-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.auth-card {
width: 380px;
}
h2 {
text-align: center;
margin-bottom: 24px;
color: #333;
font-weight: 600;
}
</style>