feat: 实现邮箱验证码登入功能
后端改动: - 新增 VerificationCode 模型,支持验证码有效期和重试限制 - 新增 SendCodeView 生成并发送邮箱验证码 - 自定义 TokenObtainPairView 支持邮箱+验证码登入 - 添加 SendCodeSerializer 和 LoginSerializer 前端改动: - 改写 LoginView.vue 为单页面邮箱+验证码登入流程 - 修改 auth API,新增 sendCode() 和修改 loginApi() - 更新 auth store 的 login 方法支持邮箱和验证码 功能特性: - 验证码有效期 10 分钟 - 同一邮箱 5 次错误尝试后锁定 10 分钟 - 支持重新发送验证码 - 完全替换原有用户名密码登入方式 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1029bf812d
commit
2edc9beef3
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Generated by Django 4.2.20 on 2026-03-25 07:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='VerificationCode',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('email', models.EmailField(max_length=254)),
|
||||||
|
('code', models.CharField(max_length=6)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('is_verified', models.BooleanField(default=False)),
|
||||||
|
('attempts', models.IntegerField(default=0)),
|
||||||
|
('locked_until', models.DateTimeField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '验证码',
|
||||||
|
'verbose_name_plural': '验证码',
|
||||||
|
'unique_together': {('email', 'code')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1,5 +1,51 @@
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
|
||||||
|
|
||||||
|
class VerificationCode(models.Model):
|
||||||
|
"""邮箱验证码模型"""
|
||||||
|
email = models.EmailField()
|
||||||
|
code = models.CharField(max_length=6)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
is_verified = models.BooleanField(default=False)
|
||||||
|
attempts = models.IntegerField(default=0)
|
||||||
|
locked_until = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '验证码'
|
||||||
|
verbose_name_plural = '验证码'
|
||||||
|
unique_together = [['email', 'code']]
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
"""检查验证码是否有效"""
|
||||||
|
if self.is_verified:
|
||||||
|
return False
|
||||||
|
if self.locked_until and timezone.now() < self.locked_until:
|
||||||
|
return False
|
||||||
|
# 检查10分钟内有效
|
||||||
|
if (timezone.now() - self.created_at).total_seconds() > 600:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def increment_attempts(self):
|
||||||
|
"""增加失败尝试次数,5次后锁定"""
|
||||||
|
self.attempts += 1
|
||||||
|
if self.attempts >= 5:
|
||||||
|
self.locked_until = timezone.now() + timezone.timedelta(minutes=10)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def mark_as_verified(self):
|
||||||
|
"""标记为已使用"""
|
||||||
|
self.is_verified = True
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_code():
|
||||||
|
"""生成6位数字验证码"""
|
||||||
|
return ''.join(random.choices(string.digits, k=6))
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from .models import VerificationCode
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
@ -36,3 +37,56 @@ class AdminUserSerializer(serializers.ModelSerializer):
|
||||||
user.set_password(password)
|
user.set_password(password)
|
||||||
user.save()
|
user.save()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class SendCodeSerializer(serializers.Serializer):
|
||||||
|
"""发送验证码 serializer"""
|
||||||
|
email = serializers.EmailField()
|
||||||
|
|
||||||
|
def validate_email(self, value):
|
||||||
|
"""验证邮箱是否存在于系统"""
|
||||||
|
if not User.objects.filter(email=value).exists():
|
||||||
|
raise serializers.ValidationError('该邮箱未在系统中注册')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class LoginSerializer(serializers.Serializer):
|
||||||
|
"""邮箱验证码登入 serializer"""
|
||||||
|
email = serializers.EmailField()
|
||||||
|
code = serializers.CharField(max_length=6, min_length=6)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""验证邮箱和验证码"""
|
||||||
|
email = attrs.get('email')
|
||||||
|
code = attrs.get('code')
|
||||||
|
|
||||||
|
# 检查用户是否存在
|
||||||
|
try:
|
||||||
|
user = User.objects.get(email=email)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
raise serializers.ValidationError('用户不存在')
|
||||||
|
|
||||||
|
# 检查验证码
|
||||||
|
try:
|
||||||
|
vc = VerificationCode.objects.filter(email=email).latest('created_at')
|
||||||
|
except VerificationCode.DoesNotExist:
|
||||||
|
raise serializers.ValidationError('请先获取验证码')
|
||||||
|
|
||||||
|
# 检查是否被锁定
|
||||||
|
if vc.locked_until:
|
||||||
|
from django.utils import timezone
|
||||||
|
if timezone.now() < vc.locked_until:
|
||||||
|
raise serializers.ValidationError('验证码错误次数过多,请10分钟后重试')
|
||||||
|
|
||||||
|
# 检查验证码是否有效
|
||||||
|
if not vc.is_valid():
|
||||||
|
raise serializers.ValidationError('验证码已过期或已使用')
|
||||||
|
|
||||||
|
# 验证码是否正确
|
||||||
|
if vc.code != code:
|
||||||
|
vc.increment_attempts()
|
||||||
|
raise serializers.ValidationError('验证码错误')
|
||||||
|
|
||||||
|
attrs['user'] = user
|
||||||
|
attrs['vc'] = vc
|
||||||
|
return attrs
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
from rest_framework_simplejwt.views import TokenRefreshView
|
||||||
from .views import RegisterView, MeView, UserManageViewSet, UserDetailView
|
from .views import RegisterView, MeView, UserManageViewSet, UserDetailView, SendCodeView, CustomTokenObtainPairView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('register/', RegisterView.as_view()),
|
path('register/', RegisterView.as_view()),
|
||||||
path('login/', TokenObtainPairView.as_view()),
|
path('send-code/', SendCodeView.as_view()),
|
||||||
|
path('login/', CustomTokenObtainPairView.as_view()),
|
||||||
path('token/refresh/', TokenRefreshView.as_view()),
|
path('token/refresh/', TokenRefreshView.as_view()),
|
||||||
path('me/', MeView.as_view()),
|
path('me/', MeView.as_view()),
|
||||||
path('users/', UserManageViewSet.as_view()),
|
path('users/', UserManageViewSet.as_view()),
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,82 @@
|
||||||
from rest_framework import generics
|
from rest_framework import generics, status
|
||||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from .serializers import RegisterSerializer, UserSerializer, AdminUserSerializer
|
from django.core.mail import send_mail
|
||||||
|
from django.conf import settings
|
||||||
|
from .models import VerificationCode
|
||||||
|
from .serializers import RegisterSerializer, UserSerializer, AdminUserSerializer, SendCodeSerializer, LoginSerializer
|
||||||
from .permissions import IsSuperAdmin
|
from .permissions import IsSuperAdmin
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class SendCodeView(APIView):
|
||||||
|
"""发送邮箱验证码"""
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
serializer = SendCodeSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
email = serializer.validated_data['email']
|
||||||
|
|
||||||
|
# 清除旧验证码
|
||||||
|
VerificationCode.objects.filter(email=email).delete()
|
||||||
|
|
||||||
|
# 生成新验证码
|
||||||
|
code = VerificationCode.generate_code()
|
||||||
|
vc = VerificationCode.objects.create(email=email, code=code)
|
||||||
|
|
||||||
|
# 发送邮件
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject='【集团招聘平台】登入验证码',
|
||||||
|
message=f'您的验证码是:{code}\n\n验证码有效期为10分钟,请勿泄露给他人。',
|
||||||
|
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@offer.com'),
|
||||||
|
recipient_list=[email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
vc.delete()
|
||||||
|
return Response(
|
||||||
|
{'error': '邮件发送失败,请稍后重试'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({'message': '验证码已发送到您的邮箱'})
|
||||||
|
|
||||||
|
|
||||||
|
class CustomTokenObtainPairView(TokenObtainPairView):
|
||||||
|
"""自定义邮箱验证码登入视图"""
|
||||||
|
serializer_class = LoginSerializer
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
user = serializer.validated_data['user']
|
||||||
|
vc = serializer.validated_data['vc']
|
||||||
|
|
||||||
|
# 标记验证码为已使用
|
||||||
|
vc.mark_as_verified()
|
||||||
|
|
||||||
|
# 生成 JWT token
|
||||||
|
refresh = self.get_token(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
|
||||||
|
|
||||||
|
|
||||||
class RegisterView(generics.CreateAPIView):
|
class RegisterView(generics.CreateAPIView):
|
||||||
serializer_class = RegisterSerializer
|
serializer_class = RegisterSerializer
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
|
|
||||||
|
|
@ -90,3 +90,11 @@ LANGUAGE_CODE = 'zh-hans'
|
||||||
TIME_ZONE = 'Asia/Shanghai'
|
TIME_ZONE = 'Asia/Shanghai'
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
|
# Email Configuration
|
||||||
|
DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@offer.com')
|
||||||
|
EMAIL_HOST = config('EMAIL_HOST', default='smtp.gmail.com')
|
||||||
|
EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int)
|
||||||
|
EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=True, cast=bool)
|
||||||
|
EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='')
|
||||||
|
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='')
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import client from './client'
|
import client from './client'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
export const login = (data) => axios.post('/api/auth/login/', data)
|
export const sendCode = (email) => axios.post('/api/auth/send-code/', { email })
|
||||||
|
export const loginApi = (data) => axios.post('/api/auth/login/', data)
|
||||||
export const register = (data) => client.post('/auth/register/', data)
|
export const register = (data) => client.post('/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)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { login as loginApi, getMe } from '@/api/auth'
|
import { loginApi, getMe } from '@/api/auth'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', {
|
export const useAuthStore = defineStore('auth', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
|
|
@ -13,8 +13,8 @@ export const useAuthStore = defineStore('auth', {
|
||||||
isSeeker: s => s.user?.role === 'seeker',
|
isSeeker: s => s.user?.role === 'seeker',
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
async login(username, password) {
|
async login(email, code) {
|
||||||
const { data } = await loginApi({ username, password })
|
const { data } = await loginApi({ email, code })
|
||||||
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 this.fetchMe()
|
await this.fetchMe()
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,188 @@
|
||||||
<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">
|
<el-form :model="form" @submit.prevent="handleLogin">
|
||||||
<el-form-item><el-input v-model="form.username" placeholder="用户名" /></el-form-item>
|
<!-- 邮箱输入 -->
|
||||||
<el-form-item><el-input v-model="form.password" type="password" placeholder="密码" show-password /></el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" native-type="submit" :loading="loading" style="width:100%">登录</el-button>
|
<el-input
|
||||||
|
v-model="form.email"
|
||||||
|
placeholder="请输入邮箱"
|
||||||
|
type="email"
|
||||||
|
: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>
|
||||||
|
|
||||||
|
<!-- 错误提示和重试次数 -->
|
||||||
|
<div v-if="codeSent && attemptsLeft < 5" style="margin-bottom: 12px; text-align: center; color: #f56c6c; font-size: 12px;">
|
||||||
|
还有 {{ attemptsLeft }} 次重试机会
|
||||||
|
</div>
|
||||||
|
<div v-if="isLocked" style="margin-bottom: 12px; text-align: center; color: #f56c6c; font-size: 12px;">
|
||||||
|
验证次数过多,请10分钟后重试
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 登录按钮 -->
|
||||||
|
<el-button
|
||||||
|
v-if="codeSent"
|
||||||
|
type="primary"
|
||||||
|
native-type="submit"
|
||||||
|
:loading="logging"
|
||||||
|
style="width: 100%"
|
||||||
|
:disabled="isLocked"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</el-button>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div style="margin-top:12px;text-align:center">
|
|
||||||
没有账号?<router-link to="/register">立即注册</router-link>
|
<div style="margin-top: 12px; text-align: center; font-size: 12px; color: #999;">
|
||||||
|
需要帮助?<a href="#" style="color: #409eff; text-decoration: none;">联系支持</a>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } 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, loginApi } from '@/api/auth'
|
||||||
|
|
||||||
const form = reactive({ username: '', password: '' })
|
const form = reactive({ email: '', code: '' })
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const codeSent = ref(false)
|
||||||
|
const sendingCode = ref(false)
|
||||||
|
const logging = ref(false)
|
||||||
|
const codeCountdown = ref(0)
|
||||||
|
const attemptsLeft = ref(5)
|
||||||
|
const isLocked = ref(false)
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
async function handleLogin() {
|
// 倒计时逻辑
|
||||||
loading.value = true
|
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 {
|
try {
|
||||||
await auth.login(form.username, form.password)
|
await sendCode(form.email)
|
||||||
|
ElMessage.success('验证码已发送到您的邮箱')
|
||||||
|
codeSent.value = true
|
||||||
|
attemptsLeft.value = 5
|
||||||
|
isLocked.value = false
|
||||||
|
startCountdown()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(err.response?.data?.error || err.response?.data?.email?.[0] || '发送失败,请检查邮箱是否正确')
|
||||||
|
} finally {
|
||||||
|
sendingCode.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新发送验证码
|
||||||
|
async function handleResendCode() {
|
||||||
|
form.code = ''
|
||||||
|
await handleSendCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!form.code || form.code.length !== 6) {
|
||||||
|
ElMessage.warning('请输入正确的6位验证码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.value = true
|
||||||
|
try {
|
||||||
|
await auth.login(form.email, form.code)
|
||||||
|
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')
|
if (auth.isSeeker) router.push('/seeker/resume')
|
||||||
else router.push('/admin/jobs')
|
else router.push('/admin/jobs')
|
||||||
} catch {
|
} catch (err) {
|
||||||
ElMessage.error('用户名或密码错误')
|
const errorMsg = err.response?.data?.non_field_errors?.[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 {
|
||||||
loading.value = false
|
logging.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.auth-page { display:flex; justify-content:center; align-items:center; min-height:60vh; }
|
.auth-page {
|
||||||
.auth-card { width: 380px; }
|
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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue