feat: 登入页面支持求职者和管理员两种角色

前端改动:
- LoginView.vue 添加角色选择(求职者/管理员)
- 求职者使用邮箱验证码登入
- 管理员使用用户名密码登入
- 两种方式在同一页面,通过角色选项卡切换

后端改动:
- CustomTokenObtainPairView 改为支持两种登入方式
- 若提供 email+code 则使用邮箱验证码登入
- 若提供 username+password 则使用用户名密码登入

设计:
- 求职者可自助注册和邮箱验证码登入
- 管理员由 superadmin 创建,使用用户名密码登入
- 两种登入都返回同样的 JWT token

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
TianyangZhang 2026-03-25 15:59:30 +08:00
parent 99220b6daf
commit 12697c5750
2 changed files with 156 additions and 32 deletions

View File

@ -50,11 +50,25 @@ class SendCodeView(APIView):
class CustomTokenObtainPairView(TokenObtainPairView): class CustomTokenObtainPairView(TokenObtainPairView):
"""自定义邮箱验证码登入视图""" """自定义登入视图,支持邮箱验证码和用户名密码两种方式"""
serializer_class = LoginSerializer
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data) # 判断是否是邮箱验证码登入(有 email 和 code还是用户名密码登入有 username 和 password
if 'email' in request.data and 'code' in request.data:
# 邮箱验证码登入
return self._login_with_code(request)
elif 'username' in request.data and 'password' in request.data:
# 用户名密码登入
return self._login_with_password(request)
else:
return Response(
{'error': '请提供正确的登入方式'},
status=status.HTTP_400_BAD_REQUEST
)
def _login_with_code(self, request):
"""邮箱验证码登入"""
serializer = LoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
user = serializer.validated_data['user'] user = serializer.validated_data['user']
@ -64,17 +78,19 @@ class CustomTokenObtainPairView(TokenObtainPairView):
vc.mark_as_verified() vc.mark_as_verified()
# 生成 JWT token # 生成 JWT token
refresh = self.get_token(user) from rest_framework_simplejwt.tokens import RefreshToken
refresh = RefreshToken.for_user(user)
return Response({ return Response({
'refresh': str(refresh), 'refresh': str(refresh),
'access': str(refresh.access_token), 'access': str(refresh.access_token),
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
@classmethod def _login_with_password(self, request):
def get_token(cls, user): """用户名密码登入"""
"""使用 TokenObtainPairSerializer 生成 token""" from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
token = super().get_token(user) serializer = TokenObtainPairSerializer(data=request.data)
return token serializer.is_valid(raise_exception=True)
return Response(serializer.validated_data, status=status.HTTP_200_OK)
class RegisterView(APIView): class RegisterView(APIView):

View File

@ -1,12 +1,30 @@
<template> <template>
<div class="auth-page"> <div class="auth-page">
<el-card class="auth-card"> <el-card class="auth-card">
<h2>邮箱登录</h2> <h2>登录</h2>
<el-form :model="form" @submit.prevent="handleLogin">
<!-- 角色选择 -->
<div class="role-tabs">
<button
:class="['role-tab', { active: role === 'seeker' }]"
@click="role = 'seeker'"
>
求职者
</button>
<button
:class="['role-tab', { active: role === 'admin' }]"
@click="role = 'admin'"
>
管理员
</button>
</div>
<!-- 求职者登入邮箱验证码 -->
<el-form v-if="role === 'seeker'" :model="seekerForm" @submit.prevent="handleSeekerLogin">
<!-- 邮箱输入 --> <!-- 邮箱输入 -->
<el-form-item> <el-form-item>
<el-input <el-input
v-model="form.email" v-model="seekerForm.email"
placeholder="请输入邮箱" placeholder="请输入邮箱"
type="email" type="email"
:disabled="codeSent" :disabled="codeSent"
@ -29,7 +47,7 @@
<el-form-item v-if="codeSent"> <el-form-item v-if="codeSent">
<div style="display: flex; gap: 8px;"> <div style="display: flex; gap: 8px;">
<el-input <el-input
v-model="form.code" v-model="seekerForm.code"
placeholder="请输入验证码6位" placeholder="请输入验证码6位"
maxlength="6" maxlength="6"
style="flex: 1" style="flex: 1"
@ -44,7 +62,7 @@
</div> </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;"> <div v-if="codeSent && attemptsLeft < 5" style="margin-bottom: 12px; text-align: center; color: #f56c6c; font-size: 12px;">
还有 {{ attemptsLeft }} 次重试机会 还有 {{ attemptsLeft }} 次重试机会
</div> </div>
@ -57,7 +75,7 @@
v-if="codeSent" v-if="codeSent"
type="primary" type="primary"
native-type="submit" native-type="submit"
:loading="logging" :loading="seekerLoading"
style="width: 100%" style="width: 100%"
:disabled="isLocked" :disabled="isLocked"
> >
@ -65,8 +83,36 @@
</el-button> </el-button>
</el-form> </el-form>
<div style="margin-top: 12px; text-align: center; font-size: 12px; color: #999;"> <!-- 管理员登入用户名密码 -->
需要帮助<a href="#" style="color: #409eff; text-decoration: none;">联系支持</a> <el-form v-if="role === 'admin'" :model="adminForm" @submit.prevent="handleAdminLogin">
<el-form-item>
<el-input
v-model="adminForm.username"
placeholder="用户名"
/>
</el-form-item>
<el-form-item>
<el-input
v-model="adminForm.password"
type="password"
placeholder="密码"
show-password
/>
</el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="adminLoading"
style="width: 100%"
>
登录
</el-button>
</el-form>
<div style="margin-top: 12px; text-align: center; font-size: 12px;">
<span v-if="role === 'seeker'">
没有账号<router-link to="/register" style="color: #409eff; text-decoration: none;">立即注册</router-link>
</span>
</div> </div>
</el-card> </el-card>
</div> </div>
@ -79,15 +125,21 @@ import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { sendCode, loginApi } from '@/api/auth' import { sendCode, loginApi } from '@/api/auth'
const form = reactive({ email: '', code: '' }) const role = ref('seeker')
const loading = ref(false)
//
const seekerForm = reactive({ email: '', code: '' })
const codeSent = ref(false) const codeSent = ref(false)
const sendingCode = ref(false) const sendingCode = ref(false)
const logging = ref(false) const seekerLoading = ref(false)
const codeCountdown = ref(0) const codeCountdown = ref(0)
const attemptsLeft = ref(5) const attemptsLeft = ref(5)
const isLocked = ref(false) const isLocked = ref(false)
//
const adminForm = reactive({ username: '', password: '' })
const adminLoading = ref(false)
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const auth = useAuthStore() const auth = useAuthStore()
@ -103,14 +155,14 @@ const startCountdown = () => {
// //
async function handleSendCode() { async function handleSendCode() {
if (!form.email) { if (!seekerForm.email) {
ElMessage.warning('请输入邮箱') ElMessage.warning('请输入邮箱')
return return
} }
sendingCode.value = true sendingCode.value = true
try { try {
await sendCode(form.email) await sendCode(seekerForm.email)
ElMessage.success('验证码已发送到您的邮箱') ElMessage.success('验证码已发送到您的邮箱')
codeSent.value = true codeSent.value = true
attemptsLeft.value = 5 attemptsLeft.value = 5
@ -125,25 +177,24 @@ async function handleSendCode() {
// //
async function handleResendCode() { async function handleResendCode() {
form.code = '' seekerForm.code = ''
await handleSendCode() await handleSendCode()
} }
// //
async function handleLogin() { async function handleSeekerLogin() {
if (!form.code || form.code.length !== 6) { if (!seekerForm.code || seekerForm.code.length !== 6) {
ElMessage.warning('请输入正确的6位验证码') ElMessage.warning('请输入正确的6位验证码')
return return
} }
logging.value = true seekerLoading.value = true
try { try {
await auth.login(form.email, form.code) await auth.login(seekerForm.email, seekerForm.code)
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)
if (auth.isSeeker) router.push('/seeker/resume') router.push('/seeker/resume')
else router.push('/admin/jobs')
} catch (err) { } catch (err) {
const errorMsg = err.response?.data?.non_field_errors?.[0] || const errorMsg = err.response?.data?.non_field_errors?.[0] ||
err.response?.data?.code?.[0] || err.response?.data?.code?.[0] ||
@ -162,7 +213,32 @@ async function handleLogin() {
} }
} }
} finally { } finally {
logging.value = false seekerLoading.value = false
}
}
//
async function handleAdminLogin() {
if (!adminForm.username || !adminForm.password) {
ElMessage.warning('请输入用户名和密码')
return
}
adminLoading.value = true
try {
const { data } = await loginApi({ username: adminForm.username, password: adminForm.password })
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('/admin/jobs')
} catch (err) {
ElMessage.error('用户名或密码错误')
} finally {
adminLoading.value = false
} }
} }
</script> </script>
@ -181,8 +257,40 @@ async function handleLogin() {
h2 { h2 {
text-align: center; text-align: center;
margin-bottom: 24px; margin-bottom: 20px;
color: #333; color: #333;
font-weight: 600; font-weight: 600;
} }
.role-tabs {
display: flex;
gap: 12px;
margin-bottom: 24px;
background: #f5f7fa;
padding: 4px;
border-radius: 4px;
}
.role-tab {
flex: 1;
padding: 8px 16px;
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
color: #606266;
border-radius: 3px;
transition: all 0.2s;
}
.role-tab:hover {
background: rgba(255, 255, 255, 0.5);
}
.role-tab.active {
background: #fff;
color: #409eff;
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
</style> </style>