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):
"""自定义邮箱验证码登入视图"""
serializer_class = LoginSerializer
"""自定义登入视图,支持邮箱验证码和用户名密码两种方式"""
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)
user = serializer.validated_data['user']
@ -64,17 +78,19 @@ class CustomTokenObtainPairView(TokenObtainPairView):
vc.mark_as_verified()
# 生成 JWT token
refresh = self.get_token(user)
from rest_framework_simplejwt.tokens import RefreshToken
refresh = RefreshToken.for_user(user)
return Response({
'refresh': str(refresh),
'access': str(refresh.access_token),
}, status=status.HTTP_200_OK)
@classmethod
def get_token(cls, user):
"""使用 TokenObtainPairSerializer 生成 token"""
token = super().get_token(user)
return token
def _login_with_password(self, request):
"""用户名密码登入"""
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
serializer = TokenObtainPairSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
return Response(serializer.validated_data, status=status.HTTP_200_OK)
class RegisterView(APIView):

View File

@ -1,12 +1,30 @@
<template>
<div class="auth-page">
<el-card class="auth-card">
<h2>邮箱登录</h2>
<el-form :model="form" @submit.prevent="handleLogin">
<h2>登录</h2>
<!-- 角色选择 -->
<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-input
v-model="form.email"
v-model="seekerForm.email"
placeholder="请输入邮箱"
type="email"
:disabled="codeSent"
@ -29,7 +47,7 @@
<el-form-item v-if="codeSent">
<div style="display: flex; gap: 8px;">
<el-input
v-model="form.code"
v-model="seekerForm.code"
placeholder="请输入验证码6位"
maxlength="6"
style="flex: 1"
@ -44,7 +62,7 @@
</div>
</el-form-item>
<!-- 错误提示和重试次数 -->
<!-- 错误提示 -->
<div v-if="codeSent && attemptsLeft < 5" style="margin-bottom: 12px; text-align: center; color: #f56c6c; font-size: 12px;">
还有 {{ attemptsLeft }} 次重试机会
</div>
@ -57,7 +75,7 @@
v-if="codeSent"
type="primary"
native-type="submit"
:loading="logging"
:loading="seekerLoading"
style="width: 100%"
:disabled="isLocked"
>
@ -65,8 +83,36 @@
</el-button>
</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>
</el-card>
</div>
@ -79,15 +125,21 @@ import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
import { sendCode, loginApi } from '@/api/auth'
const form = reactive({ email: '', code: '' })
const loading = ref(false)
const role = ref('seeker')
//
const seekerForm = reactive({ email: '', code: '' })
const codeSent = ref(false)
const sendingCode = ref(false)
const logging = ref(false)
const seekerLoading = ref(false)
const codeCountdown = ref(0)
const attemptsLeft = ref(5)
const isLocked = ref(false)
//
const adminForm = reactive({ username: '', password: '' })
const adminLoading = ref(false)
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
@ -103,14 +155,14 @@ const startCountdown = () => {
//
async function handleSendCode() {
if (!form.email) {
if (!seekerForm.email) {
ElMessage.warning('请输入邮箱')
return
}
sendingCode.value = true
try {
await sendCode(form.email)
await sendCode(seekerForm.email)
ElMessage.success('验证码已发送到您的邮箱')
codeSent.value = true
attemptsLeft.value = 5
@ -125,25 +177,24 @@ async function handleSendCode() {
//
async function handleResendCode() {
form.code = ''
seekerForm.code = ''
await handleSendCode()
}
//
async function handleLogin() {
if (!form.code || form.code.length !== 6) {
//
async function handleSeekerLogin() {
if (!seekerForm.code || seekerForm.code.length !== 6) {
ElMessage.warning('请输入正确的6位验证码')
return
}
logging.value = true
seekerLoading.value = true
try {
await auth.login(form.email, form.code)
await auth.login(seekerForm.email, seekerForm.code)
ElMessage.success('登录成功')
const redirect = route.query.redirect
if (redirect) return router.push(redirect)
if (auth.isSeeker) router.push('/seeker/resume')
else router.push('/admin/jobs')
router.push('/seeker/resume')
} catch (err) {
const errorMsg = err.response?.data?.non_field_errors?.[0] ||
err.response?.data?.code?.[0] ||
@ -162,7 +213,32 @@ async function handleLogin() {
}
}
} 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>
@ -181,8 +257,40 @@ async function handleLogin() {
h2 {
text-align: center;
margin-bottom: 24px;
margin-bottom: 20px;
color: #333;
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>