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:
parent
99220b6daf
commit
12697c5750
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue