DRF_Admin项目笔记

一、 自定义全局异常

  • drf_admin.utils.exceptions
"""
@author   : Wang Meng
@github   : https://github.com/tianpangji
@software : PyCharm
@file     : exceptions.py
@create   : 2020/7/11 13:44
"""
import logging
import traceback

from django.core.exceptions import PermissionDenied
from django.db import DatabaseError
from django.http import Http404
from redis.exceptions import RedisError
from rest_framework import status, exceptions
from rest_framework.exceptions import ErrorDetail
from rest_framework.response import Response
from rest_framework.views import set_rollback

# 获取在配置文件中定义的logger,用来记录日志
from monitor.models import ErrorLogs
from oauth.utils import get_request_ip

logger = logging.getLogger('error')


def errors_handler(exc):
    """
    # 处理DRF异常的消息格式,确保返回统一的错误格式
    # 处理列表类型的错误详情
    # 处理字典类型的错误详情(包括嵌套结构)
    # 返回格式: {'detail': '错误消息'}
    """
    try:
        if isinstance(exc.detail, list):
            msg = ''.join([str(x) for x in exc.detail])
        elif isinstance(exc.detail, dict):
            def search_error(detail: dict, message: str):
                for k, v in detail.items():
                    if k == 'non_field_errors':
                        if isinstance(v, list) and isinstance(v[0], ErrorDetail):
                            message += ''.join([str(x) for x in v])
                        else:
                            message += str(v)
                    else:
                        if isinstance(v, list) and isinstance(v[0], ErrorDetail):
                            message += str(k)
                            message += ''.join([str(x) for x in v])
                        elif isinstance(v, list) and isinstance(v[0], dict):
                            for value_dict in v:
                                message = search_error(value_dict, message)
                return message

            msg = ''
            msg = search_error(exc.detail, msg)
        else:
            msg = exc.detail
        if not msg:
            msg = exc.detail
    except Exception:
        msg = exc.detail
    data = {'detail': msg}
    return data


def exception_handler(exc, context):
    """
    # 主异常处理逻辑
    # 1. 将Django的Http404和PermissionDenied转换为DRF对应异常
    # 2. 处理DRF APIException
    # 3. 处理数据库和Redis异常
    # 4. 处理其他未知异常
    """
    if isinstance(exc, Http404):
        exc = exceptions.NotFound()
    elif isinstance(exc, PermissionDenied):
        exc = exceptions.PermissionDenied()

    if isinstance(exc, exceptions.APIException):
        headers = {}
        if getattr(exc, 'auth_header', None):
            headers['WWW-Authenticate'] = exc.auth_header
        if getattr(exc, 'wait', None):
            headers['Retry-After'] = '%d' % exc.wait
        data = errors_handler(exc)
        set_rollback()
        response = Response(data, status=exc.status_code, headers=headers)
    elif isinstance(exc, AssertionError):
        # assert断言异常捕获
        headers = {}
        if getattr(exc, 'auth_header', None):
            headers['WWW-Authenticate'] = exc.auth_header
        if getattr(exc, 'wait', None):
            headers['Retry-After'] = '%d' % exc.wait
        data = errors_handler(exc)
        set_rollback()
        response = Response(data, status=status.HTTP_400_BAD_REQUEST, headers=headers)
    elif isinstance(exc, DatabaseError) or isinstance(exc, RedisError):
        # 数据库异常
        view = context['view']
        # 数据库记录异常
        detail = traceback.format_exc()
        write_error_logs(exc, context, detail)
        logger.error('[%s] %s' % (view, detail))
        response = Response({'detail': '服务器内部错误'}, status=status.HTTP_507_INSUFFICIENT_STORAGE)
    else:
        # 未知错误
        view = context['view']
        # 数据库记录异常
        detail = traceback.format_exc()
        write_error_logs(exc, context, detail)
        logger.error('[%s] %s' % (view, detail))
        response = Response({'detail': '服务端未知错误'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
    return response


def write_error_logs(exc, context, detail):
    """
    # 记录错误日志到数据库
    # 包含用户名、视图名称、错误描述、IP地址和详细堆栈跟踪
    """
    data = {
        'username': context['request'].user.username if context['request'].user.username else 'AnonymousUser',
        'view': context['view'].get_view_name(),
        'desc': exc.__str__(),
        'ip': get_request_ip(context['request']),
        'detail': detail
    }
    ErrorLogs.objects.create(**data)

流程图

  • 最后,在配置文件中应用
# drf_admin/settings/dev.py
......
REST_FRAMEWORK = {
  # 异常处理
  'EXCEPTION_HANDLER': 'drf_admin.utils.exceptions.exception_handler',
}

二、全局分页配置

  • 全局分页配置→官方文档

  • drf_admin/settings/dev.py配置如下

  • REST_FRAMEWORK = {
      # 全局分页
      'DEFAULT_PAGINATION_CLASS': 'drf_admin.utils.pagination.GlobalPagination',
    }
    
  • pagination.py配置如下

  • from rest_framework.pagination import PageNumberPagination
    class GlobalPagination(PageNumberPagination):
        page_query_param = 'page'  # 前端发送的页数关键字名,默认为page
        page_size = 10  # 每页数目
        page_size_query_param = 'size'  # 前端发送的每页数目关键字名,默认为None
        max_page_size = 1000  # 前端最多能设置的每页数量
    

三、全局响应消息体格式化配置

  • 前后端分离项目,规范响应消息体格式,如下:

  • {'msg': msg, 'errors': detail, 'code': code, 'data': data}
    
  • 格式化采用Django中间件处理:

  • drf_admin/settings/dev.py配置如下:

  • MIDDLEWARE = [
    'drf_admin.utils.middleware.ResponseMiddleware',
    ]
    
  • middleware.py配置如下:

  • class ResponseMiddleware(MiddlewareMixin):
        """
        自定义响应数据格式
        """
    
        def process_request(self, request):
            pass
    
        def process_view(self, request, view_func, view_args, view_kwargs):
            pass
    
        def process_exception(self, request, exception):
            pass
    
        def process_response(self, request, response):
            if isinstance(response, Response) and response.get('content-type') == 'application/json':
                if response.status_code >= 400:
                    msg = '请求失败'
                    detail = response.data.get('detail')
                    code = 1
                    data = {}
                elif response.status_code == 200 or response.status_code == 201:
                    msg = '成功'
                    detail = ''
                    code = 200
                    data = response.data
                else:
                    return response
                response.data = {'msg': msg, 'errors': detail, 'code': code, 'data': data}
                response.content = response.rendered_content
            return response
    

四、日志配置

  • 参考drf_admin/settings/dev.py 下的LOGGING配置:
......
# 日志配置
LOGS_DIR = os.path.join(os.path.dirname(BASE_DIR), 'logs')
if not os.path.exists(LOGS_DIR):
    os.makedirs(LOGS_DIR)
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,  # 是否禁用已经存在的日志器
    'formatters': {  # 日志信息显示的格式
        'standard': {
            'format': '[%(asctime)s][%(levelname)s][%(filename)s:%(lineno)d]==>[%(message)s]'
        },
        'simple': {
            'format': '[%(asctime)s][%(levelname)s]==>[%(message)s]'
        },
    },
    'filters': {  # 对日志进行过滤
        'require_debug_true': {  # django在debug模式下才输出日志
            '()': 'django.utils.log.RequireDebugTrue',
        },
    },
    'handlers': {
        'default': {
            'level': 'INFO',
            'class': 'logging.handlers.RotatingFileHandler',
            'filename': os.path.join(LOGS_DIR, 'admin_info.log'),
            'maxBytes': 1024 * 1024 * 50,
            'backupCount': 5,
            'formatter': 'standard',
            'encoding': 'utf-8',
        },
        # 向终端中输出日志
        'console': {
            'level': 'DEBUG',
            'filters': ['require_debug_true'],
            'class': 'logging.StreamHandler',
            'formatter': 'standard'
        },
        'operation': {
            'level': 'INFO',
            'class': 'logging.handlers.RotatingFileHandler',
            'filename': os.path.join(LOGS_DIR, 'admin_operation.log'),
            'maxBytes': 1024 * 1024 * 50,  # 50 MB
            'backupCount': 5,
            'formatter': 'simple',
            'encoding': 'utf-8',
        },
        'query': {
            'level': 'INFO',
            'class': 'logging.handlers.RotatingFileHandler',
            'filename': os.path.join(LOGS_DIR, 'admin_query.log'),
            'maxBytes': 1024 * 1024 * 50,  # 50 MB
            'backupCount': 5,
            'formatter': 'simple',
            'encoding': 'utf-8',
        },
        'error': {
            'level': 'ERROR',
            'class': 'logging.handlers.RotatingFileHandler',
            'filename': os.path.join(LOGS_DIR, 'admin_error.log'),
            'maxBytes': 1024 * 1024 * 50,  # 50 MB
            'backupCount': 5,
            'formatter': 'standard',
            'encoding': 'utf-8',
        },

    },
    'loggers': {
        # 记录视图中手动info日志
        'info': {
            'handlers': ['default', 'console'],
            'level': 'INFO',
            'propagate': True,
        },
        # 非GET方法操作日志
        'operation': {
            'handlers': ['operation', 'console'],
            'level': 'INFO',
            'propagate': True,
        },
        # GET方法查询日志
        'query': {
            'handlers': ['query', 'console'],
            'level': 'INFO',
            'propagate': True,
        },
        # 记录视图异常日志
        'error': {
            'handlers': ['error', 'console'],
            'level': 'ERROR',
            'propagate': True,
        }
    }
}
......

五、API文档 Swagger配置

SWAGGER_SETTINGS = {
    'USE_SESSION_AUTH': False,
    'SECURITY_DEFINITIONS': {
        'api_key': {
            'type': 'apiKey',
            'in': 'header',
            'name': 'Authorization'
        }
    },
}

RABC权限篇

  • 访问地址: https://blog.csdn.net/Mr_w_ang/article/details/114898401
  • 流程图如下

表关系

未命名绘图-第 1 页

自定义滑块验证码

  • 生成滑块验证码
class GenerateSliderView(APIView):
    """
   生成滑块验证码的API视图
   """

    permission_classes = ()
    authentication_classes = ()

    def get_images_list(self):
        """
        获取所有验证图片文件名列表
        """
        from drf_admin.settings.dev import BASE_DIR
        base_image_directory = os.path.join(BASE_DIR, 'static', 'verify_images')

        return [
            # 只返回文件名,不包含完整路径
            entry.name
            for entry in os.scandir(base_image_directory)
            if entry.is_file()
        ]


    def get(self,request,*args, **kwargs):
        """
        处理GET请求,生成滑块验证码
        """
        conn = get_redis_connection('captcha')
        token = hashlib.sha256(f"{time.time()}{random.randint(1000, 9999)}".encode()).hexdigest()
        bg_images = self.get_images_list()
        bg_image = random.choice(bg_images)

        # 生成滑块目标距离(100-300px),并在生成时就确定
        expected_distance = random.randint(100, 300)

        # 存储验证状态到缓存
        cache_data = {
            'created_at': time.time(),
            'status': 'pending',  # 验证状态:pending/success/failed
            'bg_image': bg_image,  # 只存储文件名
            'attempts': 0,  # 尝试次数
            'expected_distance': expected_distance  # 提前存入期望距离
        }

        # conn.set(f"slider:{token}", cache_data, timeout=300)
        conn.set(f"slider:{token}", json.dumps(cache_data), ex=300)

        # 构建前端可访问的图片URL
        bg_image_url = f"/static/verify_images/{bg_image}"

        return Response({
            'token': token,
            'bg_image': bg_image_url,
            'expires_in': 300,  # 过期时间(秒)
            'expected_distance': expected_distance  # 传递给前端
        })

生成验证码

  • 校验验证码
class VerifySliderView(APIView):
    """
    验证滑块结果的API视图
    """
    permission_classes = ()
    authentication_classes = ()

    def post(self,request,*args, **kwargs):
        # 获取前端数据
        token = request.data.get('token')
        distance = request.data.get('distance') # 用户滑动距离
        duration = request.data.get('duration',0) # 滑动耗时(毫秒)
        print(f'本次用户操作耗时为{ duration }毫秒')
        # 验证必要参数
        if not token or distance is None:
            return Response(
                {'detail': '缺少必要参数: token 或 distance'},
                status=status.HTTP_400_BAD_REQUEST
            )
        # 获取Redis连接
        conn = get_redis_connection('captcha')
        cache_key = f"slider:{token}"
        # 从Redis获取验证数据
        cache_data_json = conn.get(cache_key)
        if not cache_data_json:
            return Response(
                {'detail': '验证码已过期或不存在'},
                status=status.HTTP_400_BAD_REQUEST
            )

        # 解析json数据
        try:
            cache_data = json.loads(cache_data_json.decode('utf-8'))
        except json.JSONDecodeError:
            return Response(
                {'detail': '验证码数据格式错误'},
                status=status.HTTP_500_INTERNAL_SERVER_ERROR
            )
        # 更新尝试次数
        cache_data['attempts']  = cache_data.get('attempts',0) + 1
        # 防止暴力破解(最多5次尝试)
        if cache_data['attempts'] > 5:
            # 删除过期验证码
            conn.delete(cache_key)
            return Response(
                { 'detail': '尝试次数过多'},
                status=status.HTTP_403_FORBIDDEN
            )

        # 获取期望滑动距离(如果没有则生成一个)
        expected_distance = cache_data.get('expected_distance')
        if expected_distance is None:
            # 如果没有存储期望距离,随机生成一个(100-300px之间)
            expected_distance = cache_data['expected_distance'] = random.randint(100, 300)

        # 验证滑动距离(容差范围±5px)
        try:
            user_distance = float(distance)

            print(f'用户滑动距离为{user_distance},后端期望距离为{expected_distance}')
            print(f'计算结果为: {abs(user_distance - expected_distance)>10}')
            if abs(user_distance - expected_distance) > 10:
                # 保存更新后的尝试次数
                conn.set(cache_key, json.dumps(cache_data), ex=300)
                return Response(
                    {'detail': '验证失败'},
                    status=status.HTTP_400_BAD_REQUEST
                )
        except (TypeError, ValueError):
            return Response(
                {'detail': '无效的滑动距离'},
                status=status.HTTP_400_BAD_REQUEST
            )

        # 验证滑动时间(正常人类操作时间应在500ms以上)
        try:
            if int(duration) < 500:
                # 保存更新后的尝试次数
                conn.set(cache_key, json.dumps(cache_data), ex=300)
                return Response(
                    {'detail': '操作过快'},
                    status=status.HTTP_400_BAD_REQUEST
                )
        except (TypeError, ValueError):
            pass  # 如果时间格式错误,跳过时间验证

        # 验证通过,更新状态
        cache_data['status'] = 'success'
        cache_data['verified_at'] = time.time()

        # 成功后有效期缩短为1分钟
        conn.set(cache_key, json.dumps(cache_data), ex=60)

        return Response({'msg': 'Success'})

校验验证码

  • 登录逻辑补充
class UserLoginView(ObtainJSONWebToken):
    """
    post:
    用户登录, status: 200(成功), return: Token信息
    """
    throttle_classes = [AnonRateThrottle]

    def post(self, request, *args, **kwargs):
        # 验证滑块验证码
        captcha_token = request.data.get('captcha_token')
        if not captcha_token:
            return Response(
                {'detail': '请先完成滑块验证'},
                status=status.HTTP_400_BAD_REQUEST
            )

        # 检查验证码状态
        conn = get_redis_connection('captcha')
        cache_key = f"slider:{captcha_token}"
        cache_data_json = conn.get(cache_key)

        if not cache_data_json:
            return Response(
                {'detail': '验证码已过期,请重新验证'},
                status=status.HTTP_400_BAD_REQUEST
            )

        try:
            cache_data = json.loads(cache_data_json.decode('utf-8'))
        except json.JSONDecodeError:
            return Response(
                {'detail': '验证码数据错误'},
                status=status.HTTP_500_INTERNAL_SERVER_ERROR
            )

        if cache_data.get('status') != 'success':
            return Response(
                {'detail': '验证码未通过,请重新验证'},
                status=status.HTTP_400_BAD_REQUEST
            )

        # 验证码验证通过,执行登录逻辑
        try:
            response = super().post(request, *args, **kwargs)
        except Exception as e:
            # 登录失败时删除验证码缓存
            conn.delete(cache_key)
            return Response(
                {'detail': '用户名或密码错误'},
                status=status.HTTP_400_BAD_REQUEST
            )

        if response.status_code == 200:
            # 登录成功后删除验证码缓存
            conn.delete(cache_key)
            user_conn = get_redis_connection('user_info')
            user_conn.incr('visits')
            return response
        else:
            # 登录失败时删除验证码缓存
            conn.delete(cache_key)
            if response.data.get('non_field_errors'):
                if isinstance(response.data.get('non_field_errors'), list) and len(
                        response.data.get('non_field_errors')) > 0:
                    if response.data.get('non_field_errors')[0].strip() == '无法使用提供的认证信息登录。':
                        return Response(data={'detail': '用户名或密码错误'}, status=status.HTTP_400_BAD_REQUEST)
            # 确保所有分支都返回Response对象
            return Response(data=response.data, status=response.status_code)

登录逻辑

  • 路由
urlpatterns = [
 	......
    path('login/', oauth.UserLoginView.as_view()),
    path('captcha/slider/', oauth.GenerateSliderView.as_view(), name='generate_slider'),
    path('slider/verify/', oauth.VerifySliderView.as_view(), name='verify_slider'),
]

  • 前端代码
// components.SliderCaptcha.vue

<template>
  <div class="slider-captcha">
    <!-- 滑块容器 -->
    <div class="slider-container" ref="sliderContainer">
      <!-- 背景图 -->
      <div class="slider-bg">
        <img :src="bgImage" alt="验证背景" class="bg-img">
        <!-- 滑块凹槽 -->
        <div class="slider-groove" :style="{ left: grooveLeft + 'px' }"></div>
        <!-- 滑块 -->
        <div
          class="slider-block"
          :style="{ left: sliderLeft + 'px' }"
          @mousedown="handleMouseDown"
          @touchstart="handleTouchStart"
        >
          <svg-icon icon-class="right" class="slider-icon" />
        </div>
      </div>

      <!-- 滑块底部提示 -->
      <div class="slider-info">
        <span :class="{ success: isSuccess, error: isError }">
          {{ infoText }}
        </span>
      </div>
    </div>
  </div>
</template>

<script>
import { getSliderCaptcha, verifySliderCaptcha } from '@/api/user'

export default {
  name: 'SliderCaptcha',
  data() {
    return {
      // 滑块相关
      sliderLeft: 0,          // 滑块当前位置
      grooveLeft: 0,          // 凹槽位置
      startX: 0,              // 起始位置
      isDragging: false,      // 是否正在拖动
      maxLeft: 0,             // 最大可拖动距离
      startTimestamp: 0,      // 开始拖动时间戳


      // 验证码相关
      token: '',              // 验证码token
      bgImage: '',            // 背景图片URL
      distance: 0,            // 滑动距离
      duration: 0,            // 滑动耗时

      // 状态相关
      isSuccess: false,       // 验证成功
      isError: false,         // 验证失败
      infoText: '向右拖动滑块完成验证', // 提示文本
      isVerifying: false,      // 正在验证中
      expectedDistance: 0,  // 新增:存储后端返回的期望距离
    }
  },
  mounted() {
    // 初始化滑块
    this.initSlider()
    // 获取验证码
    this.fetchCaptcha()
  },
  methods: {
    // 初始化滑块
    initSlider() {
      const container = this.$refs.sliderContainer
      if (container) {
        // 计算最大可拖动距离(容器宽度 - 滑块宽度)
        this.maxLeft = container.clientWidth - 50
        // 随机生成凹槽位置(100-300px之间,与后端逻辑对应)
        this.grooveLeft = Math.floor(Math.random() * 200) + 100
      }

      // 绑定鼠标和触摸事件
      document.addEventListener('mousemove', this.handleMouseMove)
      document.addEventListener('mouseup', this.handleMouseUp)
      document.addEventListener('touchmove', this.handleTouchMove)
      document.addEventListener('touchend', this.handleTouchEnd)
    },

    // 获取验证码
    async fetchCaptcha() {
      try {
        this.reset()
        const response = await getSliderCaptcha()
        this.token = response.data.token
        this.bgImage = "http://127.0.0.1:8769" + response.data.bg_image
        // 关键:使用后端返回的期望距离设置凹槽位置
        this.expectedDistance = response.data.expected_distance
        this.grooveLeft = this.expectedDistance  // 凹槽位置与后端期望距离一致
        console.log("背景地址为: ",this.bgImage)
        this.reset()
      } catch (error) {
        this.infoText = '验证码加载失败,请刷新重试'
        this.isError = true
        setTimeout(() => this.reset(), 2000)
      }
    },

    // 重置滑块
    reset() {
      this.sliderLeft = 0
      this.isDragging = false
      this.isSuccess = false
      this.isError = false
      this.infoText = '向右拖动滑块完成验证'
    },

    // 鼠标按下/触摸开始
    handleMouseDown(e) {
      this.startDrag(e.clientX)
    },

    handleTouchStart(e) {
      this.startDrag(e.touches[0].clientX)
    },

    // 开始拖动
    startDrag(x) {
      if (this.isSuccess || this.isVerifying) return

      this.startX = x
      this.isDragging = true
      this.startTimestamp = Date.now()
      this.isError = false
      this.infoText = '向右拖动滑块完成验证'
    },

    // 鼠标移动/触摸移动
    handleMouseMove(e) {
      if (this.isDragging) {
        this.moveSlider(e.clientX)
      }
    },

    handleTouchMove(e) {
      if (this.isDragging) {
        this.moveSlider(e.touches[0].clientX)
      }
    },

    // 移动滑块
    moveSlider(x) {
      // 计算移动距离
      let distance = x - this.startX
      // 限制在有效范围内
      distance = Math.max(0, Math.min(distance, this.maxLeft))
      this.sliderLeft = distance
    },

    // 鼠标释放/触摸结束
    handleMouseUp() {
      this.endDrag()
    },

    handleTouchEnd() {
      this.endDrag()
    },

    // 结束拖动
    async endDrag() {
      if (!this.isDragging) return

      this.isDragging = false
      this.distance = this.sliderLeft
      this.duration = Date.now() - this.startTimestamp

      // 验证滑动结果
      if (this.distance < 10) {
        // 移动距离过小,重置
        this.sliderLeft = 0
        return
      }

      await this.verify()
    },

    // 验证滑块
    async verify() {
      if (!this.token) {
        this.infoText = '验证码已过期,请刷新重试'
        this.isError = true
        setTimeout(() => this.fetchCaptcha(), 1500)
        return
      }

      this.isVerifying = true
      this.infoText = '验证中...'

      try {
        const response = await verifySliderCaptcha({
          token: this.token,
          distance: this.distance,
          duration: this.duration
        })

        if (response.data.msg === 'Success') {
          // 验证成功
          this.isSuccess = true
          this.isError = false
          this.infoText = '验证成功'
          // 通知父组件验证成功
          this.$emit('success', this.token)
        } else {
          this.handleVerifyError('验证失败,请重试')
        }
      } catch (error) {
        console.log("错误为",error)
        const message = error.response?.data?.detail || '验证失败,请重试'
        this.handleVerifyError(message)
      } finally {
        this.isVerifying = false
      }
    },

    // 处理验证失败
    handleVerifyError(message) {
      this.isError = true
      this.infoText = message
      // 滑块回到起始位置
      this.sliderLeft = 0
      // 如果是尝试次数过多或过期,重新获取验证码
      if (message.includes('次数过多') || message.includes('过期')) {
        setTimeout(() => this.fetchCaptcha(), 1500)
      }
    }
  },
  beforeDestroy() {
    // 移除事件监听
    document.removeEventListener('mousemove', this.handleMouseMove)
    document.removeEventListener('mouseup', this.handleMouseUp)
    document.removeEventListener('touchmove', this.handleTouchMove)
    document.removeEventListener('touchend', this.handleTouchEnd)
  }
}
</script>

<style scoped>
.slider-captcha {
  width: 100%;
  margin-bottom: 20px;
}

.slider-container {
  width: 100%;
  position: relative;
}

.slider-bg {
  width: 100%;
  height: 160px;
  position: relative;
  border-radius: 8px;
  overflow: hidden;
  background-color: #f7f9fa;
  border: 1px solid #e4e7ed;
}

.bg-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.slider-groove {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 50px;
  height: 50px;
  background-color: rgba(255, 255, 255, 0.5);
  border: 1px dashed #ccc;
  box-sizing: border-box;
}

.slider-block {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 50px;
  height: 50px;
  background-color: #fff;
  border: 1px solid #e4e7ed;
  border-radius: 4px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background-color 0.2s;
}

.slider-block:hover {
  background-color: #f5f7fa;
}

.slider-icon {
  width: 20px;
  height: 20px;
  color: #409eff;
}

.slider-info {
  height: 36px;
  line-height: 36px;
  font-size: 14px;
  text-align: center;
  margin-top: 10px;
}

.success {
  color: #67c23a;
}

.error {
  color: #f56c6c;
}
</style>

// login.index.vue

<template>
  <div class="login" :style="'background-image:url('+ Background +');'">
    <el-form ref="loginForm" :model="loginForm" :rules="loginRules" label-position="left" label-width="0px" class="login-form">
      <h3 class="title">
        ADMIN 后台管理系统
      </h3>
      <el-form-item prop="username">
        <el-input v-model="loginForm.username" type="text" auto-complete="off" placeholder="账号">
          <svg-icon slot="prefix" icon-class="user" class="el-input__icon input-icon" />
        </el-input>
      </el-form-item>
      <el-form-item prop="password">
        <el-input v-model="loginForm.password" show-password type="password" auto-complete="off" placeholder="密码" @keyup.enter.native="handleLogin">
          <svg-icon slot="prefix" icon-class="password" class="el-input__icon input-icon" />
        </el-input>
      </el-form-item>

      <!-- 滑块验证码 -->
      <el-form-item>
        <slider-captcha ref="sliderCaptcha" @success="handleCaptchaSuccess" />
      </el-form-item>

      <el-checkbox v-model="loginForm.rememberMe" style="margin:0 0 25px 0;">
        记住我
      </el-checkbox>

      <el-form-item style="width:100%;">
        <el-button :loading="loading" size="medium" type="primary" style="width:100%;" @click.native.prevent="handleLogin">
          <span v-if="!loading">登 录</span>
          <span v-else>登 录 中...</span>
        </el-button>
      </el-form-item>
    </el-form>
    <!-- 底部 -->
    <div id="el-login-footer">
      <span />
      <a href="https://github.com/TianPangJi" target="_blank">个人练习项目 @ 点击查看GitHub</a>
    </div>
  </div>
</template>

<script>
import { encrypt, decrypt } from '@/utils/rsaEncrypt'
import Config from '@/settings'
import Cookies from 'js-cookie'
import Background from '@/assets/images/login-bg.jpg'
import { getSliderCaptcha, verifySliderCaptcha } from '@/api/user'
import SliderCaptcha from '@/components/SliderCaptcha'

export default {
  name: 'Login',
  components: {
    SliderCaptcha
  },
  data() {
    return {
      Background: Background,
      cookiePass: '',
      loginForm: {
        username: '',
        password: '',
        rememberMe: false
      },

      loginRules: {
        username: [{ required: true, trigger: 'blur', message: '用户名不能为空' }],
        password: [{ required: true, trigger: 'blur', message: '密码不能为空' }]
      },
      loading: false,
      redirect: undefined,
      otherQuery: {},
      captchaToken: '' // 滑块验证成功后的token
    }
  },
  watch: {
    $route: {
      handler: function(route) {
        this.redirect = route.query && route.query.redirect
        this.otherQuery = this.getOtherQuery(route)
      },
      immediate: true
    }
  },
  created() {
    // 获取用户名密码等Cookie
    this.getCookie()
  },
  methods: {
    getOtherQuery(route) {
      const query = { ...route.query }
      delete query.redirect
      return query
    },
    getCookie() {
      const username = Cookies.get('username')
      let password = Cookies.get('password')
      const rememberMe = Cookies.get('rememberMe')
      // 保存cookie里面的加密后的密码
      this.cookiePass = password === undefined ? '' : password
      password = password === undefined ? this.loginForm.password : password
      this.loginForm = {
        username: username === undefined ? this.loginForm.username : username,
        password: password,
        rememberMe: rememberMe === undefined ? false : Boolean(rememberMe)
      }
    },
    handleCaptchaSuccess(token) {
      // 保存验证成功的token,登录时需要提交
      this.captchaToken = token
    },
    handleLogin() {
      this.$refs.loginForm.validate(valid => {
        if (valid) {
          // 检查验证码是否已通过
          if (!this.captchaToken) {
            this.$message.error('请先完成滑块验证')
            return
          }

          const user = {
            username: this.loginForm.username,
            password: this.loginForm.password,
            rememberMe: this.loginForm.rememberMe,
            captcha_token: this.captchaToken // 添加滑块验证token
          }

          if (user.password !== this.cookiePass) {
            user.password = encrypt(user.password)
          } else {
            this.loginForm.password = decrypt(user.password)
          }

          this.loading = true
          if (user.rememberMe) {
            Cookies.set('username', user.username, { expires: Config.passCookieExpires })
            Cookies.set('password', user.password, { expires: Config.passCookieExpires })
            Cookies.set('rememberMe', user.rememberMe, { expires: Config.passCookieExpires })
          } else {
            Cookies.remove('username')
            Cookies.remove('password')
            Cookies.remove('rememberMe')
          }

          user.password = decrypt(user.password)
          console.log("发送的数据是",user)
          this.$store.dispatch('user/login', user).then(() => {
          // this.$store.dispatch('user/login', this.loginForm).then(() => {
            this.$router.push({ path: this.redirect || '/', query: this.otherQuery })
            this.loading = false
          }).catch(() => {
            this.loading = false
            // 登录失败后刷新验证码
            this.captchaToken = ''
             this.resetSliderCaptcha() // 刷新验证码
          })
        } else {
          return false
        }
      })
    },

    // 重置滑块验证码
    resetSliderCaptcha() {
      if (this.$refs.sliderCaptcha) {
        this.$refs.sliderCaptcha.fetchCaptcha()
      }
    }



  }
}
</script>

<style rel="stylesheet/scss" lang="scss">
.login {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
  position: relative;
  overflow: hidden;

  &:before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(0, 0, 0, 0.3);
    z-index: 0;
  }
}

.title {
  margin: 0 auto 30px auto;
  text-align: center;
  color: #333;
  font-size: 24px;
  font-weight: bold;
}

.login-form {
  border-radius: 8px;
  background: #ffffff;
  width: 400px;
  padding: 30px;
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
  position: relative;
  z-index: 1;

  .el-input {
    height: 42px;
    margin-bottom: 18px;

    input {
      height: 42px;
      border-radius: 4px;
      padding-left: 36px;
      font-size: 14px;
    }

    .el-input__prefix {
      left: 10px;
    }
  }

  .input-icon {
    height: 16px;
    width: 16px;
    color: #999;
  }

  /* 错误提示 */
  .slider-error {
    color: #f56c6c;
    margin-top: 12px;
    text-align: center;
    font-size: 13px;
    font-weight: 500;
    background: #fef0f0;
    padding: 8px 12px;
    border-radius: 4px;
    border: 1px solid #fde2e2;
  }

  /* 记住我复选框 */
  .el-checkbox {
    margin-bottom: 20px;

    .el-checkbox__label {
      color: #606266;
    }
  }

  /* 登录按钮 */
  .el-button {
    height: 44px;
    font-size: 16px;
    font-weight: 500;
    letter-spacing: 1px;
    border-radius: 4px;
    transition: all 0.3s;

    &:hover {
      transform: translateY(-2px);
      box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
    }

    &:active {
      transform: translateY(0);
    }
  }
}

#el-login-footer {
  height: 40px;
  line-height: 40px;
  position: fixed;
  bottom: 0;
  width: 100%;
  text-align: center;
  color: rgba(255, 255, 255, 0.8);
  font-family: Arial, sans-serif;
  font-size: 13px;
  letter-spacing: 0.5px;
  z-index: 1;

  a {
    color: rgba(255, 255, 255, 0.9);
    text-decoration: none;
    transition: all 0.3s;

    &:hover {
      color: white;
      text-decoration: underline;
    }
  }
}
</style>

posted @ 2025-08-05 15:44  清安宁  阅读(22)  评论(0)    收藏  举报