Skip to content

charpter1:项目搭建

1.搭建环境

1.1 创建虚拟环境

conda create -n luffycity python=3.9

1.2依赖包安装

pip install django==3.2.9 -i https://pypi.douban.com/simple

pip install djangorestframework  -i https://pypi.douban.com/simple

pip install Pillow  -i https://pypi.douban.com/simple

conda install  -c conda-forge PymySQL

1.3 外部依赖

  1. 注册支付宝的开发者账号。https://open.alipay.com/platform/home.htm

  2. 注册阿里云账号,如果可以购买一个ESC服务器和购买一个心仪的域名,[先不要买。在项目部署阶段再买]

    域名需要进行备案[ICP备案和公安部备案]

    阿里云ICP备案:https://beian.aliyun.com/?spm=5176.19720258.J_8058803260.11.e9392c4auztrvI

    公安部备案:http://www.beian.gov.cn/portal/index.do

    注意:服务器和域名最好是同一个平台购买,否则备案有问题的,到时候还需要迁移域名到同一个平台。

  3. 注册容联云短信接口平台的账号 。

  4. 注册保利威视频服务平台的账号[先不要注册,有个7天免费的测试开发时间,项目最后会带着大家注册的]

  5. 注册gitee[码云]的账号,托管项目代码。

  6. 开通阿里云OSS对象存储。

  7. 注册一个163邮箱。

2. 服务端项目搭建

2.1创建服务端项目

django-admin startproject LuffyCity .

调整目录

luffycity/                  # 工程目录
  ├── docs/                 # 项目相关资料保存目录
  │    ├── 技术开发文档.md   # touch 技术开发文档.md
  │    ├── requirements.txt
  │    ├── luffycity.sql
  ├── luffycityweb/         # 前端项目目录[该目录先不用创建]
  ├── luffycityapi/         # api服务端项目目录
       ├── manage.py
       ├── logs/            # 项目运行时/开发时日志目录
       ├── luffycityapi/    # 项目主应用,开发时的代码保存
       │    ├── apps/       # 开发者的代码保存目录,以模块[子应用]为目录保存
       │    ├── libs/       # 第三方类库的保存目录[别人写好的,开源的第三方组件、模块]
       │    ├── settings/
       │         ├── dev.py   # 项目开发时的本地配置[不需要上传到线上或者服务器]
       │         ├── prod.py  # 项目上线时的运行配置
       │    ├── urls.py       # 总路由
       │    └── utils/        # 项目各个子应用所使用的公共函数类库[自己开发的组件]
       └── scripts/           # 保存项目运营时的维护项目脚本文件

2.2 配置Pycharm解释器

2.3 Edit Configuration (有变化)

点选pycharm的右上角的manage,选择Edit Configurations... 进入修改python文件的启动参数设置窗口。

image-20210707114245606

image-20210707114314627

在新窗口中,在Parameters对应一栏下加上 runserver 0.0.0.0:8000 并点击OK按钮

image-20240210122218135

并在配置文件settings.py中设置ALLOWED_HOST=["0.0.0.0"],如下图:

image-20240211121947298

再次点击manage右侧绿色三角形,运行django项目,打开浏览器,输入访问项目的地址:http://127.0.0.1:8000,效果如下:

image-20240210122235984

2.4 pycharm中django的objects无代码提示、自动补全的真香方案

pycharm -> file -> settings -> languages&frameworks-django 把 Enable Django Support 勾上

img

2.5 利用Github创建代码版本

2.5.1 利用Github创建代码版本步骤

创建本地仓库.git【pycharm直接打开终端就是项目根目录了。无须cd了】 新创建的本地仓库.git是个空仓库

git init

git init 用于在当前目录中创建一个新的 Git 仓库。执行这个命令会在当前目录下创建一个名为 .git 的隐藏文件夹,其中包含了 Git 仓库的所有必要文件和子目录。通过 git init 命令初始化的仓库可以跟踪文件的更改,并且可以在需要时将这些更改提交到仓库中。

在github创建代码仓库

所谓的分支,其实就是一个项目的代码的不同流程版本。

image-20240210130046979

git-flow分支命名规范:

分支前缀描述
master生产环境分支,将来可以部署到生产环境(公司的外网服务器)的代码
release预发布环境分支,将来可以部署到预发布环境的代码,也可以是rel
develop开发环境分支,也可以是dev
feature新功能,新增业务分支,也可以是feat
hotfix修复bug问题、漏洞分支,也可以是fix
test测试相关分支

创建公钥

创建仓库后,使用ssh连接远程的git仓库。先在本地电脑下生成ssh秘钥对。

bash
git init #初始化后才能生成ssh密钥

ssh-keygen -t rsa -C "sunminghong110@gmail.com" //生成ssh密钥

cat ~/.ssh/id_rsa.pub  //查看公钥

将公钥添加到GitHub

  • 登录GitHub帐户。
  • 点击右上角的头像,然后选择“Settings”。
  • 在左侧导航栏中,点击“SSH and GPG keys”。
  • 点击“New SSH key”。
  • 在“Title”字段中,为这个密钥取一个描述性的名称,例如“My SSH Key”。
  • 在“Key”字段中,粘贴您从步骤2中获取的公钥内容。
  • 最后,点击“Add SSH key”按钮。

按github的步骤上传代码

image-20240210130448588

2.5.2推代码到github上
bash
#	创建本地仓库
git init

#将当前目录下所有修改过的文件和新添加的文件添加到Git的暂存区
git add .   

#提交暂存区中的更改到本地仓库
git commit -m "feature:ProjcetInitialization"  

#创建main分支
git branch -M main 

 # 将线上仓库与线下仓库连接起来,并将线上仓库命别名为 "origin
git remote add origin git@github.com:hongDa27/ddd.git

 #推代码到main分支
git push -u origin main
2.5.3 各种git代码详细

远程 Git 仓库与本地仓库

bash
git remote add origin git@gitee.com:mooluo_admin/luffycity.git # 将线上仓库与线下仓库连接起来,并将线上仓库命别名为 "origin

git remote remove origin # 移除本地 Git 仓库中名为 "origin" 的远程仓库别名

这个命令用于将远程 Git 仓库的地址添加到本地仓库中,并将其命名为 "origin"。在这个例子中,远程仓库的地址是 git@gitee.com:mooluo_admin/luffycity.git

分支管理

bash

git branch <分支名>      # 新建本地分支
# git branch test         # 例如:创建一个test分支

git checkout <分支名>    # 切换本地分支
# git checkout test       # 例如:切换到test分支,检出分支代码

git branch -d <分支名>    # 删除本地分支
# git branch -d test

git push <远程仓库别> --delete <分支名>    # 删除远程服务器分支
# git push origin --delete test             # 例如:删除远程仓库origin中的test

# 创建本地分支develop, develop在自定义分支模型(git-flow)中属于开发分支
git checkout -b develop
2.5.4 在git上传时过滤不必要文件

使用.gitignore可以在git上传或下载代码时,把一些不必要记录的垃圾文件/目录过滤掉。

bash

# 新建git忽略文件
vim .gitignore

# .gitignore的ignore文件内容复制到.gitignore文件中
https://github.com/github/gitignore/blob/main/Python.gitignore
# :wq 保存文件

注意:必须保证.git 和.gitignore在同一级目录下,才能生效。

image-20240210142834608

记录并保存.gitignore到git中

bash
git add .
git commit -m "feature:.gitignore新建忽略文件"

# 推送的过程中,如果本地有该分支,但是线上没有这个分支,则git会自动在远程中创建该分支
git push origin develop
2.5.5合并不同分支代码到main分支中

远程仓库中main分支下没有develop分支的代码,因此,需要把develop的代码合并到main下。这个操作,一般都是develop的功能已经开发完成了,并且经过测试没有问题了,才会合并代码

bash
# 要合并到哪个分支下面,就要切换到对应分支
git checkout main

# 合并代码操作
git merge develop

# 推送master主分支到远程服务器
git push origin main
2.5.6 git commit 提交版本的描述信息,编写前缀规范:
描述前缀描述
feature:本次提交的代码用于开发新功能,新增业务
fix:本次提交的代码用于修复bug问题、漏洞
docs:本次提交的代码用于修改文档,注释等相关
style:本次提交的代码用于修改代码格式,不影响代码逻辑,常见的代码规范:PEP8,PEP484
refactor:本次提交的代码用于项目/代码重构,理论上不影响现有功能(针对代码的重写,改造原来的模块/类/函数/方法)
perf:本次提交的代码用于提升性能,代码优化
test:本次提交的代码用于增加或修改测试用例代码
deps:本次提交的代码用于升级项目依赖(更新依赖模块)

2.6 服务端项目初始化

2.6.1 日志配置(只针对django)

编写新代码之前,切换一下分支到开发分支下。git checkout develop

这里我们基于django开发, Django 使用 Python 内置的logging 模块处理系统日志。

django官方日志配置文档:https://docs.djangoproject.com/zh-hans/3.2/topics/logging/

日志信息从严重程度由高到低,一共分了5个等级。

由loging模块默认提供了5个操作方法,分别可以记录以下5个等级日志的。

bash
CRITICAL(fatal):  致命错误,程序根本跑不起来。
ERROR:              运行错误,程序运行发生错误的地方时就会退出程序。
WARNING:            运行警告,程序运行发生警告的地方时会显示警告提示,但是程序会继续往下执行。
INFO:               运行提示,一般的系统信息,并非日志
DEBUG:              调试信息,排查故障时使用的低级别系统信息

配置步骤

1.在项目根目录下创建logs文件夹

2.在settings/dev.py文件中追加如下配置:

python
# 日志
LOGGING = {
    'version': 1, # 使用的日志模块的版本,目前官方提供的只有版本1,但是官方有可能会升级,为了避免升级出现的版本问题,所以这里固定为1
    'disable_existing_loggers': False, # 是否禁用其他的已经存在的日志功能?肯定不能,有可能有些第三方模块在调用,所以禁用了以后,第三方模块无法捕获自身出现的异常了。
    'formatters': { # 日志格式设置,verbose或者simple都是自定义的
        'verbose': { # 详细格式,适合用于开发人员不在场的情况下的日志记录。
            # 格式定义:https://docs.python.org/3/library/logging.html#logrecord-attributes
            # levelname 日志等级
            # asctime   发生时间
            # module    文件名
            # process   进程ID
            # thread    线程ID
            # message   异常信息
            'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
            'style': '{', # 变量格式分隔符
        },
        'simple': { # 简单格式,适合用于开发人员在场的情况下的终端输出
            'format': '{levelname} {message}',
            'style': '{',
        },
    },
    'filters': {  # 过滤器
        'require_debug_true': {
            '()': 'django.utils.log.RequireDebugTrue',
        },
    },
    'handlers': { # 日志处理流程,console或者mail_admins都是自定义的。
        'console': {
            'level': 'DEBUG', # 设置当前日志处理流程中的日志最低等级
            'filters': ['require_debug_true'], # 当前日志处理流程的日志过滤
            'class': 'logging.StreamHandler',  # 当前日志处理流程的核心类,StreamHandler可以帮我们把日志信息输出到终端下
            'formatter': 'simple'              # 当前日志处理流程的日志格式
        },
        # 'mail_admins': {
        #     'level': 'ERROR',                  # 设置当前日志处理流程中的日志最低等级
        #     'class': 'django.utils.log.AdminEmailHandler',  # AdminEmailHandler可以帮我们把日志信息输出到管理员邮箱中。
        #     'filters': ['special']             # 当前日志处理流程的日志过滤
        # }
        'file': {
            'level': 'INFO',
            'class': 'logging.handlers.RotatingFileHandler',
            # 日志位置,日志文件名,日志保存目录logs必须手动创建
            'filename': BASE_DIR.parent / "logs/luffycity.log",
            # 单个日志文件的最大值,这里我们设置300M
            'maxBytes': 300 * 1024 * 1024,
            # 备份日志文件的数量,设置最大日志数量为10
            'backupCount': 10,
            # 日志格式:详细格式
            'formatter': 'verbose'
        },
    },
    'loggers': {  # 日志处理的命名空间
        'django': {
            'handlers': ['console','file'], # 当基于django命名空间写入日志时,调用那几个日志处理流程
            'propagate': True,   # 是否在django命名空间对应的日志处理流程结束以后,冒泡通知其他的日志功能。True表示允许
        },
    }
}

在终端下提交git版本记录。

bash
cd <项目路>
git add .
git commit -m "feature:日志初始化"
git push origin develop
2.6.2 异常处理

else error!!!

新建utils/exceptions.py用于保存异常处理的工具函数代码。

python
from rest_framework.views import exception_handler

from django.db import DatabaseError
from rest_framework.response import Response
from rest_framework import status

import logging
logger = logging.getLogger('django')


def custom_exception_handler(exc, context):
    """
    自定义异常处理
    :param exc: 异常类
    :param context: 抛出异常的上下文
    :return: Response响应对象
    """
    # 调用drf框架原生的异常处理方法
    response = exception_handler(exc, context)

    if response is None:
        view = context['view']
        if isinstance(exc, DatabaseError):
            # 数据库异常
            logger.error('[%s] %s' % (view, exc))
            response = Response({'message': '服务器内部错误'}, status=status.HTTP_507_INSUFFICIENT_STORAGE)

    return response

settings/dev.py配置文件中添加自定义异常处理的配置。

python
# drf配置
REST_FRAMEWORK = {
    # 自定义异常处理
    'EXCEPTION_HANDLER': 'luffycityapi.utils.exceptions.custom_exception_handler',
}

使用git记录代码版本

bash
cd ~/Desktop/luffycity
git add .
git commit -m "feature:自定义异常处理"
git push origin develop
2.6.3 数据库配置

创建数据库

mysql
mysql -uroot -p
# 输入密码
create database luffycity;

# 如果使用的数据库是低于8.0,使用下面的语句
# create database luffycity default charset=utf8mb4;

为数据库分配管理员

为当前项目创建数据库用户[这个用户只能看到这个数据库]

mysql
# 8.0版本以上的mysql/MariaDB10.3
# 创建用户:create user '用户名'@'主机地址' identified by '密码';
create user 'luffycity_user'@'%' identified by 'luffycity';  # %表示任意主机都可以通过当前账户登录到mysql
# 分配权限:grant 权限选项 on 数据库名.数据表 to 'luffycity_user'@'%' with grant option;
grant all privileges on luffycity.* to 'luffycity_user'@'%' with grant option;

# create user 'xiaoming'@'%' identified by 'xiaoming';
# grant select,insert on homework.* to 'xiaoming'@'%' with grant option;

# mysql8.0/MariaDB10.3版本以下,创建数据库用户并设置数据库权限给当前新用户,并刷新内存中的权限记录
create user luffycity_user identified by 'luffycity';
grant all privileges on luffycity.* to 'luffycity_user'@'%';
flush privileges;

使用pycharm连接mysql数据库。

image-20210709114342742

image-20240213134720142

配置mysql数据库连接

连接池可以提升项目在使用数据库过程中的性能。dbutils和db-connecion-pool

bash
# pip install pymysql  # 常见的数据库连接驱动:如果已经安装了,就不必要执行了。
pip install django-db-connection-pool
pip install cryptography

打开settings/dev.py文件,并配置

python
DATABASES = {
    # 'default': {
    #     'ENGINE': 'django.db.backends.sqlite3',
    #     'NAME': BASE_DIR / 'db.sqlite3',
    # }
    'default': {
        # 'ENGINE': 'django.db.backends.mysql',
        'ENGINE': 'dj_db_conn_pool.backends.mysql',
        'NAME': 'luffycity',
        'PORT': 3306,
        'HOST': '127.0.0.1',
        'USER': 'luffycity_user',
        'PASSWORD': 'luffycity',
        'OPTIONS': {
            'charset': 'utf8mb4', # 连接选项配置,mysql8.0以上无需配置
        },
        'POOL_OPTIONS' : {      # 连接池的配置信息
            'POOL_SIZE': 10,    # 连接池默认创建的链接对象的数量
            'MAX_OVERFLOW': 10  # 连接池默认创建的链接对象的最大数量
        }
    }
}

在项目主应用下的 luffycityapi.__init__.py中导入pymysql,如果使用的是MySQLdb(mysqlclient),不要加下面的这段代码。

python
import pymysql

pymysql.install_as_MySQLdb()

使用git记录代码版本

bash
cd <项目路>
git add .
git commit -m "feature:配置mysql数据库账号与链接"
git push origin develop
2.6.4 缓存配置

文档:https://django-redis-chs.readthedocs.io/zh_CN/latest/

安装django-redis。

python
pip install django-redis

在settings/dev.py配置中添加一下代码:

python
# redis configration
# 设置redis缓存
CACHES = {
    # 默认缓存
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        # 项目上线时,需要调整这里的路径
        # "LOCATION": "redis://:密码@IP地址:端口/库编号",
      
      	#redis最大支持15个库,记得改这里的编号1-15
        "LOCATION": "redis://:123456@127.0.0.1:6379/0", 
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "CONNECTION_POOL_KWARGS": {"max_connections": 100},
        }
    },
    # 提供给admin运营站点的session存储
    "session": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://:123456@127.0.0.1:6379/1", #改编号为1
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "CONNECTION_POOL_KWARGS": {"max_connections": 100},
        }
    },
    # 提供存储短信验证码
    "sms_code":{
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://:123456@127.0.0.1:6379/2",    #改编号为2
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "CONNECTION_POOL_KWARGS": {"max_connections": 100},
        }
    }
}

# 设置用户登录admin站点时,记录登录状态的session保存到redis缓存中

#因为django默认将缓存保存到mysql中,所以需要配置,改为redis
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
# 设置session保存的位置对应的缓存配置项
SESSION_CACHE_ALIAS = "session"

django-redis提供了get_redis_connection的方法,通过调用get_redis_connection方法传递redis的配置名称可获取到redis的连接对象,通过redis连接对象可以执行redis命令

https://redis-py.readthedocs.io/en/latest/

使用范例:

python
from django_redis import get_redis_connection
// 链接redis数据库
redis_conn = get_redis_connection("sms_code")

使用git记录代码版本

bash
cd <项目路>
git add .
git commit -m "feature:配置redis数据库账号与链接"
git push origin develop

2.7 测试服务端项目

创建子应用home,可以根据以下步骤创建,终端操作

bash
cd Luffy/apps/
python ../../manage.py startapp backends

注册应用到项目分2步:

  1. 在settings/dev.py中的INSTALLED_APPS配置项里面新增backends

需要让python能直接对backends进行导包识别,需要给sys.path增加一个路径成员。

**注意:加了apps的系统变量后,导包时就把apps当作根目录导包,不然会报错:**RuntimeError: Model class luffycity.apps.course.models.CourseDirection doesn't declare an explicit app_label and isn't in an application in INSTALLED_APPS.

settings/dev.py,代码:

bash
# 当前项目的主应用开发目录
BASE_DIR = Path(__file__).resolve().parent.parent

# 新增apps作为导包路径
sys.path.insert(0, str( BASE_DIR / "apps") )

#DEBUG应为True
DEBUG = True 

ALLOWED_HOSTS = ['0.0.0.0']

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    "backends",
    'rest_framework',           //别忘了注册restframework !!!!!!!!!
]

home子应用下创建路由文件,home/urls.py,代码:

python
from django.urls import path
from . import views
urlpatterns = [
]

提交Git代码版本

python
cd /Desktop/luffycity
git add .
git commit -m "test: 测试服务端项目配置"
git push origin develop

3.客户端项目搭建

3.1 创建vite项目

步骤

1.创建项目

bash
cd 项目根目录
yarn create vite

#输入项目名,选择vue,javascript

2.测试运行

bash
cd <项目路>
yarn
yarn dev

3.配置启动项

image-20240211203728792

4.配置完后点击动,pycharm右下角有可能提示下载npm依赖包,则下载。

image-20240211204004634

5.git源代码管理。

bash
git add .
git commit -m "feature:vue客户端项目搭建"
git push origin develop

3.2 客户端项目初始化

删除默认提供的HelloWorld.vue组件和src/APP.vue中的默认样式和内容。

App.vue,代码:

vue
<template>

</template>

<script setup>

</script>

<style>

</style>

vite.config.mjs,有部分小伙伴的vite安装项目时因为版本原因,有可能不会自动生成vite配置文件,所以如果没有的话,自己手动创建以下,补充以下,代码:

javascript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()]
})

3.3 全局css初始化

src/App.vue,代码:

vue
<template>
  <router-view></router-view>
</template>

<script setup>

</script>

<style>
/* 声明全局样式和项目的初始化样式 */
body,h1,h2,h3,h4,p,table,tr,td,ul,li,a,form,input,select,option,textarea{
  margin:0;
  padding: 0;
  font-size: 15px;
}
a{
  text-decoration: none;
  color: #333;
  cursor: pointer;
}
ul,li{
  list-style: none;
}
table{
  border-collapse: collapse; /* 合并边框 */
}
img{
  max-width: 100%;
  max-height: 100%;
}
input{
  outline: none;
}
</style>

提交版本

bash
cd ~/Desktop/luffycity
git add .
git commit -m "feature:客户端初始化css样式"
git push origin develop

3.4 安装路由组件vue-router

步骤

1.下载vue-router

bash
cd <项目路>
yarn add vue-router@next

2.配置路由

创建src/router/index.js,代码:

js
import {createRouter, createWebHistory} from 'vue-router'

// 路由列表
const routes = [
{
    meta:{
        title: "luffy2.0-登陆首页",
        keepAlive: true
    },
    path: '/login/',         // uri访问地址
    name: "Login",
    component: ()=> import("../views/Login.vue")  // 记得()=>!!!
  },
]

// 路由对象实例化
const router = createRouter({
  history: createWebHistory(),
  routes:routes,
});

// 暴露路由对象
export default router

3.注册路由组件

在main.js中:

javascript
import { createApp } from 'vue'
import App from './App.vue'
import router from "./router/index.js";
createApp(App).use(router).mount('#app')

4.在App.vue中:

vue
<template>
  <router-view></router-view>
</template>

<script setup>
</script>

<style>
</style>

5.提交版本

bash
cd <项目路>
git add .
git commit -m "feature:安装集成vue-router"
git push origin develop

3.5 引入elementPlus

官方文档:https://element-plus.org/#/zh-CN

步骤

1.下载 elementPlus

bash
cd <项目路>
yarn add element-plus

2.按需使用,文档:https://element-plus.gitee.io/zh-CN/guide/quickstart.html#

下载 unplugin-vue-components

bash
cd <项目路>
yarn add unplugin-vue-components

3.在 luffycityweb/vite.config.js中加载上面刚安装的导入插件,代码:

js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
      vue(),
      Components({
        resolvers: [ElementPlusResolver()],
      }),
  ]
});

4.引入css

在main.js

js
import {createApp} from 'vue'
import App from './App.vue'

import 'element-plus/dist/index.css';
import router from "./router/index.js";

createApp(App).use(router).mount('#app')

5.使用

Home组件中,调用elementPlus的基本样式,测试下是否成功引入。Home.vue,代码:

vue
<template>
  <h1>首页</h1>
  <el-row>
    <el-button round>圆角按钮</el-button>
    <el-button type="primary" round>主要按钮</el-button>
    <el-button type="success" round>成功按钮</el-button>
    <el-button type="info" round>信息按钮</el-button>
    <el-button type="warning" round>警告按钮</el-button>
    <el-button type="danger" round>危险按钮</el-button>
    <el-rate v-model="store.value2" :colors="store.colors"> </el-rate>
  </el-row>
</template>

<script setup>
import {reactive} from "vue";
const store = reactive({
  value2: null,
  colors: ['#99A9BF', '#F7BA2A', '#FF9900'],
})
</script>

<style scoped>

</style>

提交版本

bash
cd <项目路>
git add .
git commit -m "feature:客户端按需加载element-plus前端框架"
git push origin develop

3.6 显示首页界面效果

src/components/Header.vue

vue
<template>
    <div class="header-box">
      <div class="header">
        <div class="content">
          <div class="logo">
            <router-link to="/"><img src="../assets/logo.svg" alt=""></router-link>
          </div>
          <ul class="nav">
              <li><router-link to="">免费课</router-link></li>
              <li><router-link to="">项目课</router-link></li>
              <li><router-link to="">学位课</router-link></li>
              <li><router-link to="">习题库</router-link></li>
              <li><router-link to="">路飞学城</router-link></li>
          </ul>
          <div class="search-warp">
            <div class="search-area">
              <input class="search-input" placeholder="请输入关键字..." type="text" autocomplete="off">
              <div class="hotTags">
                <router-link to="/search/?words=Vue" target="_blank" class="">Vue</router-link>
                <router-link to="/search/?words=Python" target="_blank" class="last">Python</router-link>
              </div>
            </div>
            <div class="showhide-search" data-show="no"><img class="imv2-search2" src="../assets/search.svg" /></div>
          </div>
          <div class="login-bar">
            <div class="shop-cart full-left">
              <img src="../assets/cart.svg" alt="" />
              <span><router-link to="/cart">购物车</router-link></span>
            </div>
            <div class="login-box full-left">
              <span>登录</span>
              &nbsp;/&nbsp;
              <span>注册</span>
            </div>
          </div>
        </div>
      </div>
    </div>
</template>


<script setup>

</script>

<style scoped>
.header-box{
  height: 72px;
}
.header{
  width: 100%;
  height: 72px;
  box-shadow: 0 0.5px 0.5px 0 #c9c9c9;
  position: fixed;
  top:0;
  left: 0;
  right:0;
  margin: auto;
  z-index: 99;
  background: #fff;
}
.header .content{
  max-width: 1366px;
  width: 100%;
  margin: 0 auto;
}
.header .content .logo a{
}
.header .content .logo{
  height: 72px;
  width:180px;
  line-height: 72px;
  margin: 0 20px;
  float: left;
  cursor: pointer; /* 设置光标的形状为爪子 */
}
.header .content .logo img{
  vertical-align: middle;
  margin: -40px;
}
.header .nav li{
  float: left;
  height: 80px;
  line-height: 80px;
  margin-right: 30px;
  font-size: 16px;
  color: #4a4a4a;
  cursor: pointer;
}
.header .nav li span{
  padding-bottom: 16px;
  padding-left: 5px;
  padding-right: 5px;
}
.header .nav li span a{
  display: inline-block;
}
.header .nav li .this{
  color: #4a4a4a;
  border-bottom: 4px solid #ffc210;
}
.header .nav li:hover span{
  color: #000;
}

/*首页导航全局搜索*/
.search-warp {
  position: relative;
  float: left;
  margin-left: 24px;
}
.search-warp .showhide-search {
  width: 20px;
  height: 24px;
  text-align: right;
  position: absolute;
  display: inline-block;
  right: 0;
  bottom: 24px;
  padding: 0 8px;
  border-radius: 18px;
}
.search-warp .showhide-search i {
  display: block;
  height: 24px;
  color: #545C63;
  cursor: pointer;
  font-size: 18px;
  line-height: 24px;
  width: 20px;
}
.search-area {
  float: right;
  position: relative;
  height: 40px;
  padding-right: 36px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.4);
  zoom: 1;
  background: #F3F5F6;
  border-radius: 4px;
  margin: 16px 0;
  width: 324px;
  box-sizing: border-box;
  font-size: 0;
  -webkit-transition: width 0.3s;
  -moz-transition: width 0.3s;
  transition: width 0.3s;
}
.search-area .search-input {
  padding: 8px 12px;
  font-size: 14px;
  color: #9199A1;
  line-height: 24px;
  height: 40px;
  width: 100%;
  float: left;
  border: 0;
  -webkit-transition: background-color 0.3s;
  -moz-transition: background-color 0.3s;
  transition: background-color 0.3s;
  background-color: transparent;
  -moz-box-sizing: border-box;
  -webkit-box-sizing: border-box;
  -ms-box-sizing: border-box;
  box-sizing: border-box;
}
.search-area .search-input.w100 {
  width: 100%;
}
.search-area .hotTags {
  display: inline-block;
  position: absolute;
  top: 0;
  right: 32px;
}
.search-area .hotTags a {
  display: inline-block;
  padding: 4px 8px;
  height: 16px;
  font-size: 14px;
  color: #9199A1;
  line-height: 16px;
  margin-top: 8px;
  max-width: 60px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.search-area .hotTags a:hover {
  color: #F21F1F;
}
.search-area input::-webkit-input-placeholder {
  color: #A6A6A6;
}
.search-area input::-moz-placeholder {
  /* Mozilla Firefox 19+ */
  color: #A6A6A6;
}
.search-area input:-moz-placeholder {
  /* Mozilla Firefox 4 to 18 */
  color: #A6A6A6;
}
.search-area input:-ms-input-placeholder {
  /* Internet Explorer 10-11 */
  color: #A6A6A6;
}
.search-area .btn_search {
  float: left;
  cursor: pointer;
  width: 30px;
  height: 38px;
  text-align: center;
  -webkit-transition: background-color 0.3s;
  -moz-transition: background-color 0.3s;
  transition: background-color 0.3s;
}
.search-area .search-area-result {
  position: absolute;
  left: 0;
  top: 57px;
  width: 300px;
  margin-bottom: 20px;
  border-top: none;
  background-color: #fff;
  box-shadow: 0 8px 16px 0 rgba(7, 17, 27, 0.2);
  font-size: 12px;
  overflow: hidden;
  display: none;
  z-index: 800;
  border-bottom-right-radius: 8px;
  border-bottom-left-radius: 8px;
}
.search-area .search-area-result.hot-hide {
  top: 47px;
}
.search-area .search-area-result.hot-hide .hot {
  display: none;
}
.search-area .search-area-result.hot-hide .history {
  border-top: 0;
}
.search-area .search-area-result h2 {
  font-size: 12px;
  color: #1c1f21;
  line-height: 12px;
  margin-bottom: 8px;
  font-weight: 700;
}
.search-area .search-area-result .hot {
  padding: 12px 0 8px 12px;
  box-sizing: border-box;
}
.search-area .search-area-result .hot .hot-item {
  background: rgba(84, 92, 99, 0.1);
  border-radius: 12px;
  padding: 4px 12px;
  line-height: 16px;
  margin-right: 4px;
  margin-bottom: 4px;
  display: inline-block;
  cursor: pointer;
  font-size: 12px;
  color: #545c63;
}
.search-area .search-area-result .history {
  border-top: 1px solid rgba(28, 31, 33, 0.1);
  box-sizing: border-box;
}
.search-area .search-area-result .history li {
  height: 40px;
  line-height: 40px;
  padding: 0 10px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: #787d82;
  cursor: pointer;
}
.search-area .search-area-result .history li:hover,
.search-area .search-area-result .history li .light {
  color: #1c1f21;
  background-color: #edf0f2;
}


.header .login-bar{
  margin-top: 20px;
  height: 80px;
  float: right;
  margin-right:80px;
}
.header .login-bar .shop-cart{
  float: left;
  margin-right: 20px;
  border-radius: 17px;
  background: #f7f7f7;
  cursor: pointer;
  font-size: 14px;
  height: 28px;
  width: 88px;
  line-height: 32px;
  text-align: center;
}
.header .login-bar .shop-cart:hover{
  background: #f0f0f0;
}
.header .login-bar .shop-cart img{
  width: 15px;
  margin-right: 4px;
  margin-left: 6px;
}
.header .login-bar .shop-cart span{
  margin-right: 6px;
}
.header .login-bar .login-box{
  float: left;
  height: 28px;
  line-height: 30px;
}
.header .login-bar .login-box span{
  color: #4a4a4a;
  cursor: pointer;
}
.header .login-bar .login-box span:hover{
  color: #000000;
}
</style>

components/Footer.vue

vue
<template>
    <div class="footer">
      <ul>
        <li><router-link to="">企业服务</router-link></li>
        <li><router-link to="">关于我们</router-link></li>
        <li><router-link to="">联系我们</router-link></li>
        <li><router-link to="">商务合作</router-link></li>
        <li><router-link to="">帮助中心</router-link></li>
        <li><router-link to="">意见反馈</router-link></li>
        <li><router-link to="">新手指南</router-link></li>
      </ul>
      <p>Copyright © luffycity.com版权所有 | 京ICP备17072161号-1</p>
    </div>
</template>

<script setup>

</script>

<style scoped>
.footer {
  width: 100%;
  height: 128px;
  color: #545C63;
}
.footer ul{
  margin: 0 auto 16px;
  padding-top: 38px;
  width: 930px;
}
.footer ul li{
  float: left;
  width: 112px;
  margin: 0 10px;
  text-align: center;
  font-size: 14px;
}
.footer ul::after{
  content:"";
  display:block;
  clear:both;
}
.footer p{
  text-align: center;
  font-size: 12px;
}
</style>

views/Home.vue中引入头部组件和脚部组件,添加代码:

vue
<template>
  <div class="home">
    <Header></Header>
    
    <Footer></Footer>
  </div>
</template>

<script setup>
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"

</script>

<style scoped>

</style>

提交版本

bash
cd <项目路>
git add .
git commit -m "feature:客户端显示首页"
git push origin develop

4.客户端与服务端交互

4.1 配置本地域名映射

为了解决跨域问题。

位置域名
客户端www.luffycity.cn
服务端api.luffycity.cn

步骤

1.进入计算机根目录下的配置文件/etc/hosts设置本地域名。

bash
sudo vim /etc/hosts  #需要加sudo获取管理员权限,不然这文件是只读文件

2.使用i快捷键,进入vim输入模式,并在hosts文件中增加两行代码,然后保存文件内容。

shell
127.0.0.1   api.luffycity.cn  #后端用api,命名规范
127.0.0.1   www.luffycity.cn  #前端

3.esc后使用:wq保存文件。

bash
:wq

4.在使用pycharm运行web客户端项目时,默认以www.luffycity.cn:3000启动项目。

python
--host=www.luffycity.cn --port=3000

image-20240212135048565

5.在使用pycharm运行luffycityapi服务端项目这边窗口下配置,默认以api.luffycity.cn:8000启动。

python
runserver api.luffycity.cn:8000

image-20240212135114335

6.修改settings文件dev.py,在ALLOWED_HOSTS选项中,增加允许客户端使用任意域名访问django站点。settings/dev.py,代码:

python
ALLOWED_HOSTS = ["*"]

4.2 CORS跨域支持

同源策略:前后端互相访问时,如果域名,协议或端口不同,无法访问。解决它有三种方式:

  1. 服务端代理(Server Proxy) (中介服务器)
  2. CORS跨域资源共享 (node.js作为中介服务器,通过node.js二次发送ajax请求)
  3. jsonp

node.js: javascript只能在前端环境运行,node.js把chrome的V8引擎抽出来重新封装成node.js,使得js代码可以在后端运行。

配置CORS有2种方案(不能同时配置,二选一):

  1. web客户端的vue项目中配置vue.config.js实现跨域(使用vite搭建的vue项目,则配置文件是vite.config.mjs)
  2. api服务端的django项目中配置cors实现跨域

4.2.1 客户端基于nodejs实现跨域代理

配置vue-cli/vite本身内置的nodejs来实现跨域代理,vite.config.mjs,代码:

javascript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

// https://vitejs.dev/config/
export default defineConfig({
    plugins: [
        vue(),
            Components({
            resolvers: [ElementPlusResolver()],
        }),
    ],
    server: {
        port: '3000',           // 客户端的运行端口,此处也可以绑定vue运行的端口,当然也可以写在pycharm下
        host: 'www.luffycity.cn', // 客户端的运行地址,此处也可以绑定vue运行的域名,当然也可以写在pycharm下
        // 跨域代理
        proxy: {
            '/api': {
                // 凡是遇到 /api 路径的请求,都映射到 target 属性  /api/header/  ---> http://api.luffycity.cn:8000/header/
                target: 'http://api.luffycity.cn:8000/',
                changeOrigin: true,
                ws: true,    // 是否支持websocket跨域
                rewrite: path => path.replace(/^\/api/, '')
            }
        }
  }
})

4.2.2Django安装跨域组件实现服务端跨域代理

1.服务端安装跨域组件

python
pip install django-cors-headers

文档:https://github.com/ottoyiu/django-cors-headers/

2.添加子应用,settings/dev.py,代码:

python
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework',
    'corsheaders', # cors跨域子应用
    
    'home',  # apps已经在上面加入到python的系统导包路径列表了,这里还是出现背景颜色,原因是python虽然找到home,pycharm不知道。所以可以鼠标右键,设置apps为mark as source root
]

3.注册CorsMiddleware中间件【必须写在第一个位置】,settings/dev.py,代码:

python
MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',  # cors跨域的中间件
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

4。添加客户端访问服务端的白名单,设置允许哪些客户端客户端跨域访问服务端,settings/dev.py,代码:

python
# CORS的配置信息: 
# 方案1:
# CORS_ORIGIN_WHITELIST = (
#     'http://www.luffycity.cn:3000',
# )
# CORS_ALLOW_CREDENTIALS = False  # 不允许ajax跨域请求时携带cookie

# 方案2:
CORS_ALLOW_ALL_ORIGINS = True

完成了上面的步骤,我们现在就可以通过后端提供数据给前端使用ajax访问了。

提交版本

bash
cd <项目路>
git add .
git commit -m "feature:服务端和客户端分配不同域名,仿真开发,CORS跨域配置"
git push origin develop

4.3 客户端集成axios实现ajax请求

文档:http://www.axios-js.com/zh-cn/docs/

步骤

1.安装axios工具插件,务必保证是在客户端项目根目录下安装。

bash
cd <项目路>
yarn add axios@next

2.axios的初始化

axios的初始化配置一般包括以下3部分:

  1. 创建axios请求实例对象
  2. 配置baseURL设置整个站点ajax请求的api服务端站点公共地址
  3. 配置axios请求拦截器和响应拦截器。

创建src/utils/http.js,代码如下:

javascript
import axios from "axios"

const http = axios.create({
    // timeout: 2500,                          // 请求超时,有大文件上传需要关闭这个配置
    baseURL: "http://api.luffycity.cn:8000",     // 设置api服务端的默认地址[如果基于服务端实现的跨域,这里可以填写api服务端的地址,如果基于nodejs客户端测试服务器实现的跨域,则这里不能填写api服务端地址]
    withCredentials: false,                    // 是否允许客户端ajax请求时携带cookie
})

// 请求拦截器
http.interceptors.request.use((config)=>{
    console.log("http请求之前");
    return config;
}, (error)=>{
    console.log("http请求错误");
    return Promise.reject(error);
});

// 响应拦截器
http.interceptors.response.use((response)=>{
    console.log("服务端响应数据成功以后,返回结果给客户端的第一时间,执行then之前");
    return response;
}, (error)=>{
    console.log("服务端响应错误内容的时候。...");
    return Promise.reject(error);
});

export default http;

3.在服务端发送ajax请求

src/views/Home.vue,代码:

vue
<template>

    <Header></Header>

		// 点击按钮发送请求
    <button @click="send_req">click</button>  

    <Footer></Footer>

</template>

<script setup>
    import Header from '../components/Header.vue'
    import Footer from '../components/Footer.vue'
    import http from "../utils/http";

    const send_req = () => {
        http.get('/api/test/').then(response => {
            console.log(response.data)
        }).catch(error => {
            console.log(error)
        })
    }

</script>

<style scoped>

</style>

4.提交版本

bash
cd <项目路>
git add .
git commit -m "feature:客户端集成并配置axios"
git push origin develop

charpter2:站点首页功能实现

1. 导航功能实现

导航数据的存储依赖数据库,必须先设计表结构

导航位置、导航名称、导航链接、导航序号、是否显示、是否外链、添加时间、更新时间、是否删除

E-R图,如下:

image-20220402021856478

1.1 创建模型

image-20240213121218080

1.写数据表的类

apps/api/models.py,代码:

python
from django.db import models
from utils.Mymodels import BaseModel


class NavModel(BaseModel):
    name = models.CharField(max_length=255)
    link = models.CharField(max_length=255)
    position = models.IntegerField(choices=((1, 'Header'), (0, 'Footer')))

    class Meta:
        db_table = 'luffy_nav'
        verbose_name = '导航菜单'
        verbose_name_plural = verbose_name

2.公共模型【抽象模型,不会在数据迁移的时候为它创建表】,保存项目的公共代码库目录下luffycityapi/utils.py文件中。

luffycity/utils/models.py,代码:

python
from django.db import models


class BaseModel(models.Model):
    created_time = models.DateTimeField(auto_now_add=True)
    updated_time = models.DateTimeField(auto_now=True)
    is_show = models.BooleanField(default=True)
    is_deleted = models.BooleanField(default=False)
    orders = models.IntegerField(default=0)
    is_http = models.BooleanField(default=False)

    class Meta:
        abstract = True

3.由于from city.utils.models import BaseModel导包路径过长,可以在settings/dev.py,中配置导包路径,将utils添加到系统路径中,代码:

python
# 因为项目中子应用已经换了存储目录,所以需要把apps设置为系统导包路径,方便我们后面开发时可以简写子应用相关的导包路径。

import sys, os

#sys.path.insert(0, str( BASE_DIR / "apps") )
#sys.path.insert(0, str( BASE_DIR / "utils") )

BASE_DIR = Path(__file__).resolve().parent.parent
sys.path.append(os.path.join(BASE_DIR, 'apps'))

或者直接将utils放到根目录下(我的方法)

4.数据迁移

bash
cd ~/Desktop/luffycity/luffycityapi
python manage.py makemigrations
python manage.py migrate

5.刚上面仅仅创建的是数据表结构而已,所以接下来我们如果要实现客户端展示导航功能,则还需要在admin后台手动添加测试数据,或者MySQL交互终端下添加测试数据才可以。

sql
INSERT INTO luffycity.luffy_nav (id, name, orders, is_show, is_deleted, created_time, updated_time, link, is_http, position) VALUES (1, '免费课', 1, 1, 0, '2021-07-15 01:27:27.350000', '2021-07-15 01:27:28.690000', '/free', 0, 0);
INSERT INTO luffycity.luffy_nav (id, name, orders, is_show, is_deleted, created_time, updated_time, link, is_http, position) VALUES (2, '项目课', 1, 1, 0, '2021-07-15 01:27:27.350000', '2021-07-15 01:27:28.690000', '/project', 0, 0);
INSERT INTO luffycity.luffy_nav (id, name, orders, is_show, is_deleted, created_time, updated_time, link, is_http, position) VALUES (3, '学位课', 1, 1, 0, '2021-07-15 01:27:27.350000', '2021-07-15 01:27:28.690000', '/position', 0, 0);
INSERT INTO luffycity.luffy_nav (id, name, orders, is_show, is_deleted, created_time, updated_time, link, is_http, position) VALUES (4, '习题库', 1, 1, 0, '2021-07-15 01:27:27.350000', '2021-07-15 01:27:28.690000', '/exam', 0, 0);
INSERT INTO luffycity.luffy_nav (id, name, orders, is_show, is_deleted, created_time, updated_time, link, is_http, position) VALUES (5, '路飞学城', 1, 1, 0, '2021-07-15 01:27:27.350000', '2021-07-15 01:27:28.690000', 'https://www.luffyluffycity.com', 1, 0);
INSERT INTO luffycity.luffy_nav (id, name, orders, is_show, is_deleted, created_time, updated_time, link, is_http, position) VALUES (6, '企业服务', 1, 1, 0, '2021-07-15 01:27:27.350000', '2021-07-15 01:27:28.690000', '/free', 0, 1);
INSERT INTO luffycity.luffy_nav (id, name, orders, is_show, is_deleted, created_time, updated_time, link, is_http, position) VALUES (7, '关于我们', 1, 1, 0, '2021-07-15 01:27:27.350000', '2021-07-15 01:27:28.690000', '/free', 0, 1);
INSERT INTO luffycity.luffy_nav (id, name, orders, is_show, is_deleted, created_time, updated_time, link, is_http, position) VALUES (8, '联系我们', 1, 1, 0, '2021-07-15 01:27:27.350000', '2021-07-15 01:27:28.690000', '/free', 0, 1);
INSERT INTO luffycity.luffy_nav (id, name, orders, is_show, is_deleted, created_time, updated_time, link, is_http, position) VALUES (9, '商务合作', 1, 1, 0, '2021-07-15 01:27:27.350000', '2021-07-15 01:27:28.690000', '/free', 0, 1);
INSERT INTO luffycity.luffy_nav (id, name, orders, is_show, is_deleted, created_time, updated_time, link, is_http, position) VALUES (10, '帮助中心', 1, 1, 0, '2021-07-15 01:27:27.350000', '2021-07-15 01:27:28.690000', '/free', 0, 1);
INSERT INTO luffycity.luffy_nav (id, name, orders, is_show, is_deleted, created_time, updated_time, link, is_http, position) VALUES (11, '意见反馈', 1, 1, 0, '2021-07-15 01:27:27.350000', '2021-07-15 01:27:28.690000', '/free', 0, 1);
INSERT INTO luffycity.luffy_nav (id, name, orders, is_show, is_deleted, created_time, updated_time, link, is_http, position) VALUES (12, '新手指南', 1, 1, 0, '2021-07-15 01:27:27.350000', '2021-07-15 01:27:28.690000', '/free', 0, 1);

1.2 序列化器

apps/api/serializers,代码:

python
from abc import ABC

from rest_framework import serializers
from api.models import NavModel


class NavSerializer(serializers.ModelSerializer):
    class Meta:
        model = NavModel
        fields = ['name', 'link', 'is_http']

1.3 视图代码

apps/api/views/NavViews.py

python
from rest_framework.generics import ListAPIView
from api.models import NavModel
from api.serializers import NavSerializer

POSITION_HEADER = 1
HEADER_MAXIMUM_DISPLAY = 5
POSITION_FOOTER = 0
FOOTER_MAXIMUM_DISPLAY = 5


class NavHeaderListApiView(ListAPIView):
  	"""顶部导航视图"""
    queryset = NavModel.objects.filter(is_show=True, is_deleted=False, position=POSITION_HEADER).order_by('orders',
                                                                                                          '-id')[
               :HEADER_MAXIMUM_DISPLAY]
    serializer_class = NavSerializer


class NavFooterListApiView(ListAPIView):
 		 """脚部导航视图"""
    queryset = NavModel.objects.filter(is_show=True, is_deleted=False, position=POSITION_FOOTER).order_by('orders',
                                                                                                          '-id')[
               :FOOTER_MAXIMUM_DISPLAY]
    serializer_class = NavSerializer

1.3.1 常量配置

utils/constants.py,代码: (我没写,麻烦~)

python
"""常量配置文件"""
# 导航的位置 --> 顶部
NAV_HEADER_POSITION = 0
# 导航的位置 --> 脚部
NAV_FOOTER_POSITION = 1
# 顶部导航显示的最大数量
NAV_HEADER_SIZE = 5
# 脚部导航显示的最大数量
NAV_FOOTER_SIZE = 10

1.4 路由代码

apps/api/urls.py

python
from django.urls import path
from api.views.NavViews import NavHeaderListApiView,NavFooterListApiView

urlpatterns = [
    path('nav/header/', NavHeaderListApiView.as_view(), name='nav_header'),
    path('nav/footer/', NavFooterListApiView.as_view(), name='nav_footer'),
]

提交代码版本

bash
cd <项目路>
git add .
git commit -m "feature:服务端提供导航api接口"
git push origin develop

1.5 客户端获取导航数据

所有与api服务端进行交互的操作代码,可以单独保存api目录下,根据数据表单独创建js文件,方便将来代码复用。

src/api/nav.js,代码:

javascript
import http from "../utils/http"
import {reactive, ref} from "vue"

const nav = reactive({
    header_nav_list: [], // 头部导航列表
    footer_nav_list: [], // 脚部导航列表
    get_header_nav(){
        // 获取头部导航
        return http.get("/home/nav/header/")
    },
    get_footer_nav(){
        // 获取脚部导航
        return http.get("/home/nav/footer/")
    },

})

export default nav;

所有与api服务端进行交互的操作代码,可以单独保存api目录下,根据数据表单独创建js文件,方便将来代码复用。

src/api/nav.js,代码:

javascript
import http from "../utils/http"
import {reactive, ref} from "vue"

const nav = reactive({
    header_nav_list: [], // 头部导航列表
    footer_nav_list: [], // 脚部导航列表
    get_header_nav(){
        // 获取头部导航
        return http.get("/home/nav/header/")
    },
    get_footer_nav(){
        // 获取脚部导航
        return http.get("/home/nav/footer/")
    },

})

export default nav;

components/Header.vue代码:

vue
<template>
    <div class="header-box">
      <div class="header">
        <div class="content">
          <div class="logo">
            <router-link to="/"><img src="../assets/logo.svg" alt=""></router-link>
          </div>
          <ul class="nav">
              <li v-for="nav in nav.header_nav_list">
                <a :href="nav.link" v-if="nav.is_http">{{nav.name}}</a>
                <router-link :to="nav.link" v-else>{{nav.name}}</router-link>
              </li>
          </ul>
          <div class="search-warp">
            <div class="search-area">
              <input class="search-input" placeholder="请输入关键字..." type="text" autocomplete="off">
              <div class="hotTags">
                <router-link to="/search/?words=Vue" target="_blank" class="">Vue</router-link>
                <router-link to="/search/?words=Python" target="_blank" class="last">Python</router-link>
              </div>
            </div>
            <div class="showhide-search" data-show="no"><img class="imv2-search2" src="../assets/search.svg" /></div>
          </div>
          <div class="login-bar">
            <div class="shop-cart full-left">
              <img src="../assets/cart.svg" alt="" />
              <span><router-link to="/cart">购物车</router-link></span>
            </div>
            <div class="login-box full-left">
              <span>登录</span>
              &nbsp;/&nbsp;
              <span>注册</span>
            </div>
          </div>
        </div>
      </div>
    </div>
</template>
vue
<script setup>
import nav from "../api/nav";

// 请求头部导航列表
nav.get_header_nav().then(response=>{
  nav.header_nav_list = response.data
})


</script>

components/Footer.vue代码:

vue
<template>
    <div class="footer">
      <ul>
        <li v-for="nav in nav.footer_nav_list">
          <a :href="nav.link" v-if="nav.is_http">{{nav.name}}</a>
          <router-link :to="nav.link" v-else>{{nav.name}}</router-link>
        </li>
      </ul>
      <p>Copyright © luffycity.com版权所有 | 京ICP备17072161号-1</p>
    </div>
</template>
vue
<script setup>
import nav from "../api/nav";

// 获取脚部导航列表
nav.get_footer_nav().then(response=>{
  nav.footer_nav_list = response.data
})

</script>

提交代码版本

bash
cd ~/Desktop/luffycity
git add .
git commit -m "feature:客户端实现导航信息展示"
git push origin develop

2. 轮播图功能实现

Banner.vue,代码:

vue
<template>
   <div class="bk"></div>
   <div class="bgfff banner-box">
    <div class="g-banner pr" @mouseleave="state.current_menu=-1">
     <!-- 商品课程分类信息 -->
     <div class="submenu" v-if="state.current_menu==0">
      <div class="inner-box">
       <h2 class="type">前端开发</h2>
       <div class="tag clearfix">
       </div>
       <div class="lore">
        <span class="title">知识点:</span>
        <p class="lores clearfix"><a target="_blank" href="">Vue.js</a>
          <a target="_blank" href="">Typescript</a>
          <a target="_blank" href="">React.JS</a>
          <a target="_blank" href="">HTML/CSS</a>
          <a target="_blank" href="">JavaScript</a>
          <a target="_blank" href="">Angular</a>
          <a target="_blank" href="">Node.js</a>
          <a target="_blank" href="">jQuery</a>
          <a target="_blank" href="">Bootstrap</a>
          <a target="_blank" href="">Sass/Less</a>
          <a target="_blank" href="">WebApp</a>
          <a target="_blank" href="">小程序</a>
          <a target="_blank" href="">前端工具</a>
          <a target="_blank" href="">CSS</a>
          <a target="_blank" href="">Html5</a>
          <a target="_blank" href="">CSS3</a>
        </p>
       </div>
      </div>
      <div class="recomment clearfix">
        <a href="" target="_blank" title="" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/60a7779909e3fc1206960344.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">前端工程师2021</span> <span class="tag tixi">体系</span> </p>
         </div>
         <div class="bottom">
          <span class="discount-name">优惠价</span>
          <span class="price">¥4599.00</span> &middot;
          <span class="difficulty"> 零基础 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 19322</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="前端框架及项目面试 聚焦Vue3/React/Webpack" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/5e3cfea008e9a61b06000338-360-202.jpg')"></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">前端框架及项目面试 聚焦Vue3/React/Webpack</span> <span class="tag shizhan">实战</span> </p>
         </div>
         <div class="bottom">
          <span class="price">399.00</span> &middot;
          <span class="difficulty"> 中级 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 2946</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="从0打造微前端框架,实战汽车资讯平台,系统掌握微前端架构设计与落地能力" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/60d44ec8084b799712000676-360-202.jpg')"></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"><span class="text">从0打造微前端框架,实战汽车资讯平台,系统掌握微前端架构设计与落地能力</span><span class="tag shizhan">实战</span> </p>
         </div>
         <div class="bottom">
          <span class="discount-name">限时优惠</span>
          <span class="price">¥328.00</span> &middot;
          <span class="difficulty"> 高级 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 109</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/604f2bab0952610803240324-140-140.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">Vue.js 从入门到精通</span> <span class="tag lujing">路线</span> </p>
         </div>
         <div class="bottom">
          <span class="difficulty">4步骤</span> &middot;
          <span class="difficulty">4门课</span> &middot;
          <span class="num">19697人收藏</span>
         </div>
        </div> </a>
      </div>
     </div>
     <div class="submenu" v-if="state.current_menu==1">
      <div class="inner-box">
       <h2 class="type">后端开发</h2>
       <div class="tag clearfix">
       </div>
       <div class="lore">
        <span class="title">知识点:</span>
        <p class="lores clearfix">
          <a target="_blank" href="">Java</a>
          <a target="_blank" href="">SpringBoot</a>
          <a target="_blank" href="">Spring Cloud</a>
          <a target="_blank" href="">SSM</a>
          <a target="_blank" href="">PHP</a>
          <a target="_blank" href="">.net</a>
          <a target="_blank" href="">Python</a>
          <a target="_blank" href="">爬虫</a>
          <a target="_blank" href="">Django</a>
          <a target="_blank" href="">Flask</a>
          <a target="_blank" href="">Tornado</a>
          <a target="_blank" href="">Go</a>
          <a target="_blank" href="">C</a>
          <a target="_blank" href="">C++</a>
          <a target="_blank" href="">C#</a>
          <a target="_blank" href="">Ruby</a></p>
       </div>
      </div>
      <div class="recomment clearfix">
        <a href="" target="_blank" title="Java工程师2021" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/60a777ef0942d7bf06960344.png'); background-size: 100%; "></div>
        <div class="details">
         <div class="title-box">
          <p class="title"> <span class="text">Java工程师2021</span> <span class="tag tixi">体系</span> </p>
         </div>
         <div class="bottom">
          <span class="discount-name">优惠价</span>
          <span class="price">¥4399.00</span> &middot;
          <span class="difficulty"> 零基础 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 15052</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="Python工程师(全能型)" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/60a77721093df37606960344.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">Python工程师(全能型)</span> <span class="tag tixi">体系</span> </p>
         </div>
         <div class="bottom">
          <span class="discount-name">优惠价</span>
          <span class="price">¥4366.00</span> &middot;
          <span class="difficulty"> 零基础 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 10786</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="Java全栈工程师" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/5dd6567b09d9d01c06000338.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">Java全栈工程师</span> <span class="tag tixi">体系</span> </p>
         </div>
         <div class="bottom">
          <span class="discount-name">优惠价</span>
          <span class="price">¥3380.00</span> &middot;
          <span class="difficulty"> 进阶 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 1853</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/604f2bb6099d6a8803240324-140-140.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">SpringBoot从入门到精通</span> <span class="tag lujing">路线</span> </p>
         </div>
         <div class="bottom">
          <span class="difficulty">3步骤</span> &middot;
          <span class="difficulty">5门课</span> &middot;
          <span class="num">11092人收藏</span>
         </div>
        </div> </a>
      </div>
     </div>
     <div class="submenu" v-if="state.current_menu==2">
      <div class="inner-box">
       <h2 class="type">移动开发</h2>
       <div class="tag clearfix">
       </div>
       <div class="lore">
        <span class="title">知识点:</span>
        <p class="lores clearfix"></p>
       </div>
      </div>
      <div class="recomment clearfix">
       <a href="" target="_blank" title="移动端架构师成长体系课" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/5ec5ddf209cd2c8606000338.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">移动端架构师成长体系课</span> <span class="tag tixi">体系</span> </p>
         </div>
         <div class="bottom">
          <span class="discount-name">优惠价</span>
          <span class="price">¥4888.00</span> &middot;
          <span class="difficulty"> 进阶 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 402</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="Flutter高级进阶实战  仿哔哩哔哩APP 一次性深度掌握Flutter高阶技能" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/60497caf0971842912000676-360-202.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">Flutter高级进阶实战 仿哔哩哔哩APP 一次性深度掌握Flutter高阶技能</span> <span class="tag shizhan">实战</span> </p>
         </div>
         <div class="bottom">
          <span class="price">368.00</span> &middot;
          <span class="difficulty"> 高级 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 646</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="音视频基础+ffmpeg原理+项目实战 一课完成音视频技术开发入门" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/5e5621d0092c054612000676-360-202.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">音视频基础+ffmpeg原理+项目实战 一课完成音视频技术开发入门</span> <span class="tag shizhan">实战</span> </p>
         </div>
         <div class="bottom">
          <span class="price">288.00</span> &middot;
          <span class="difficulty"> 入门 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 1303</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/604f2b52090de67603240324-140-140.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">Android工程师高薪面试突破路线</span> <span class="tag lujing">路线</span> </p>
         </div>
         <div class="bottom">
          <span class="difficulty">3步骤</span> &middot;
          <span class="difficulty">3门课</span> &middot;
          <span class="num">1471人收藏</span>
         </div>
        </div> </a>
      </div>
     </div>
     <div class="menuContent">
      <div class="item" :class="{'js-menu-item-on': state.current_menu==0}" @mouseover="state.current_menu=0">
       <span class="title">前端开发:</span>
       <span class="sub-title">HTML5 / Vue.js / Node.js</span>
       <i class="imv2-arrow1_r"></i>
      </div>
      <div class="item" :class="{'js-menu-item-on': state.current_menu==1}" @mouseover="state.current_menu=1">
       <span class="title">后端开发:</span>
       <span class="sub-title">Java / Python / Go</span>
       <i class="imv2-arrow1_r"></i>
      </div>
      <div class="item" :class="{'js-menu-item-on': state.current_menu==2}" @mouseover="state.current_menu=2">
       <span class="title">移动开发:</span>
       <span class="sub-title">Flutter / Android / iOS </span>
       <i class="imv2-arrow1_r"></i>
      </div>
     </div>
      <!-- 轮播图-->
      <div class="g-banner-content"  @mouseover="state.current_menu=-1">
        <el-carousel :interval="5000" arrow="always" height="482px">
          <el-carousel-item>
            <img src="http://fuguangapi.oss-cn-beijing.aliyuncs.com/1.jpg" alt="" style="width: 100%;height: 100%;">
          </el-carousel-item>
          <el-carousel-item>
            <img src="http://fuguangapi.oss-cn-beijing.aliyuncs.com/2.jpg" alt="" style="width: 100%;height: 100%;">
          </el-carousel-item>
          <el-carousel-item>
            <img src="http://fuguangapi.oss-cn-beijing.aliyuncs.com/3.jpg" alt="" style="width: 100%;height: 100%;">
          </el-carousel-item>
          <el-carousel-item>
            <img src="http://fuguangapi.oss-cn-beijing.aliyuncs.com/4.jpg" alt="" style="width: 100%;height: 100%;">
          </el-carousel-item>
          <el-carousel-item>
            <img src="http://fuguangapi.oss-cn-beijing.aliyuncs.com/5.jpg" alt="" style="width: 100%;height: 100%;">
          </el-carousel-item>
        </el-carousel>
     </div>
    </div>
   </div>
</template>

<script setup>
import {reactive} from "vue"
const state = reactive({
  current_menu: -1,
})
</script>

<style scoped>
.banner-box {
  padding: 32px 0;
}
.system-class-show {
  width: 1152px;
  height: 100px;
  margin: 0 auto;
  background: #FFFFFF;
  box-shadow: 0 5px 20px 0 rgba(0, 0, 0, 0.3);
  border-radius: 0 0 8px 8px;
}
.system-class-show .show-box {
  display: block;
  width: 192px;
  height: 45px;
  float: left;
  margin: 28px 0 0 16px;
  cursor: pointer;
}
.system-class-show .show-box .system-class-icon {
  float: left;
  width: 45px;
  height: 45px;
  border-radius: 50%;
  background-size: cover;
  margin-right: 8px;
  transition: all .2s;
}
.system-class-show .show-box .describe {
  float: left;
}
.system-class-show .show-box .describe h4 {
  width: 139px;
  font-family: PingFangSC-Medium;
  font-size: 16px;
  color: #1C1F21;
  letter-spacing: 0.76px;
  line-height: 22px;
  margin-bottom: 4px;
  white-space: nowrap;
  overflow: hidden;
}
.system-class-show .show-box .describe p {
  width: 139px;
  font-family: PingFangSC-Regular;
  font-size: 12px;
  color: #545C63;
  line-height: 18px;
  white-space: nowrap;
  overflow: hidden;
}
.system-class-show .show-box:hover .system-class-icon {
  transform: translateY(-2px);
}
.system-class-show .show-box:hover .describe h4 {
  color: #F01414;
}
.system-class-show .line {
  float: left;
  height: 36px;
  border: 1px solid #E8E8E8;
  margin-left: 16px;
  margin-top: 33px;
}
.system-class-show .all-btn {
  position: relative;
  display: block;
  height: 100%;
  cursor: pointer;
  overflow: hidden;
}
.system-class-show .all-btn .mini-title {
  font-family: PingFangSC-Medium;
  font-size: 12px;
  color: #1C1F21;
  text-align: center;
  line-height: 14px;
  margin-top: 40px;
}
.system-class-show .all-btn .more-btn {
  font-family: PingFangSC-Regular;
  font-size: 12px;
  color: #545C63;
  line-height: 12px;
  margin-left: 30px;
  position: relative;
}
.system-class-show .all-btn .more-btn .icon-right2 {
  position: absolute;
  top: 1px;
  left: 28px;
  transition: all .2s;
}
.system-class-show .all-btn:hover .more-btn {
  color: #1C1F21;
}
.system-class-show .all-btn:hover .more-btn .icon-right2 {
  transform: translateX(3px);
}
.g-banner {
  position: relative;
  overflow: hidden;
  width: 1400px;
  margin: auto;
  border-radius: 8px 8px 0 0;
}
.g-banner .g-banner-content {
  position: relative;
  float: left;
  width: 1142px;
}
.g-banner .g-banner-content .g-banner-box {
  position: relative;
  height: 316px;
}
.g-banner .g-banner-content .notice {
  position: absolute;
  top: 8px;
  left: 0;
  background: #FF9900;
  box-shadow: 0 2px 4px 0 rgba(7, 17, 27, 0.2);
  padding: 6px 12px 6px 8px;
  z-index: 1;
  border-top-right-radius: 20px;
  border-bottom-right-radius: 20px;
}
.g-banner .g-banner-content .notice .imv2-vol_up {
  font-size: 16px;
  color: #FFFFFF;
  display: inline-block;
  line-height: 20px;
  margin-top: 1px;
  margin-right: 4px;
  vertical-align: sub;
}
.g-banner .g-banner-content .notice .notice-txt {
  display: inline-block;
  width: auto;
  font-size: 12px;
  color: #FFFFFF;
  line-height: 20px;
  z-index: 1;
  white-space: nowrap;
}
.g-banner .g-banner-content .notice .notice-close {
  font-size: 16px;
  margin: 6px 0 6px 12px;
  color: rgba(255, 255, 255, 0.6);
  line-height: 20px;
}
.g-banner .g-banner-content .notice .notice-close:hover {
  color: #fff;
}
.g-banner .g-banner-content .notice.closed {
  transition: all .3s;
  background: rgba(255, 153, 0, 0.6);
  box-shadow: 0 2px 4px 0 rgba(7, 17, 27, 0.2);
}
.g-banner .g-banner-content .notice.closed .notice-txt {
  overflow: hidden;
}
.g-banner .g-banner-content .notice.closed .notice-close {
  display: none;
}
.g-banner .banner-anchor {
  position: absolute;
  top: 50%;
  margin-top: -24px;
  width: 48px;
  height: 48px;
  background: rgba(28, 31, 33, 0.1) url(/src/assets/icon-left-small.png) no-repeat center / 16px auto;
  border-radius: 50%;
  color: #FFFFFF;
  transition: all .2s;
}
.g-banner .banner-anchor:hover {
  background-color: rgba(28, 31, 33, 0.5);
}
.g-banner .next {
  right: 16px;
  transform: rotate(180deg);
}
.g-banner .prev {
  left: 16px;
}
.g-banner .g-banner-box > a:first-child .banner-slide {
  display: block;
}
.g-banner .banner-slide {
  position: absolute;
  display: none;
  width: 896px;
  height: 316px;
  /*margin: auto;*/
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  background-repeat: no-repeat;
  background-position: center 0;
}
.g-banner .banner-slide .festival {
  position: absolute;
  top: 450px;
  right: 75px;
}
.g-banner .banner-slide .festival a {
  display: block;
  width: 190px;
  height: 120px;
}
.g-banner .banner-slide .festival a:hover {
  background-position: 0 0;
}
.g-banner .banner-slide img {
  width: 100%;
  height: 100%;
}
.g-banner .inner {
  position: relative;
  width: 1200px;
  margin: 0 auto;
}
.g-banner .banner-dots {
  position: absolute;
  bottom: 20px;
  left: 0;
  right: 0;
  text-align: right;
  padding-right: 24px;
  line-height: 12px;
}
.g-banner .banner-dots span {
  display: inline-block;
  *display: inline;
  *zoom: 1;
  width: 8px;
  height: 8px;
  border-radius: 4px;
  margin-left: 8px;
  background: rgba(255, 255, 255, 0.75);
  transition: all .2s;
  cursor: pointer;
}
.g-banner .banner-dots span.active {
  width: 20px;
}

.submenu {
  position: absolute;
  left: 256px;
  width: 776px;
  height: 482px;
  background: #FFFFFF;
  box-shadow: 0 4px 8px 0 rgba(7, 17, 27, 0.1);
  border-radius: 0 12px 12px 0;
  z-index: 33;
  box-sizing: border-box;
}
.submenu .inner-box {
  height: 188px;
  padding: 28px 36px 0;
  box-sizing: border-box;
}
.submenu .inner-box .type {
  margin-bottom: 10px;
  font-size: 16px;
  color: #1C1F21;
  line-height: 22px;
  font-weight: bold;
}
.submenu .inner-box .tag {
  margin-bottom: 12px;
}
.submenu .inner-box .tag a {
  float: left;
  font-size: 12px;
  line-height: 1;
  color: #E02020;
  border-radius: 100px;
  border: 1px solid #E02020;
  padding: 5px 10px;
  margin-right: 10px;
}
.submenu .inner-box .tag a:last-child {
  margin-right: 0;
}
.submenu .inner-box .lore {
  font-size: 12px;
  line-height: 24px;
  color: #6D7278;
  margin-bottom: 8px;
  display: -webkit-box;
  display: -ms-flexbox;
  display: -webkit-flex;
  display: flex;
}
.submenu .inner-box .lore .title {
  color: #1C1F21;
  font-weight: bold;
}
.submenu .inner-box .lore .lores {
  width: 0;
  -webkit-box-flex: 1;
  -ms-flex: 1;
  -webkit-flex: 1;
  flex: 1;
}
.submenu .inner-box .lore .lores a {
  float: left;
  color: #6D7278;
  margin-right: 24px;
}
.submenu .inner-box .lore .lores a:last-child {
  margin-right: 0;
}
.submenu .recomment {
  padding: 35px 36px;
  height: 204px;
  background-color: #F3F5F6;
  box-sizing: border-box;
}
.submenu .recomment .recomment-item {
  width: 329px;
  float: left;
  display: -webkit-box;
  display: -ms-flexbox;
  display: -webkit-flex;
  display: flex;
}
.submenu .recomment .recomment-item:nth-child(2n) {
  margin-left: 30px;
}
.submenu .recomment .recomment-item:nth-child(-n+2) {
  margin-bottom: 30px;
}
.submenu .recomment .recomment-item .img {
  width: 90px;
  height: 50px;
  margin-right: 11px;
  border-radius: 4px;
  background-position: center;
  image-rendering: -moz-crisp-edges;
  /* Firefox */
  image-rendering: -o-crisp-edges;
  /* Opera */
  image-rendering: -webkit-optimize-contrast;
  /*Webkit (non-standard naming) */
  image-rendering: crisp-edges;
  -ms-interpolation-mode: nearest-neighbor;
  /* IE (non-standard property) */
  box-shadow: 0 6px 10px 0 rgba(95, 101, 105, 0.15);
}
.submenu .recomment .recomment-item .details {
  height: 50px;
  font-size: 12px;
  width: 0;
  -webkit-box-flex: 1;
  -ms-flex: 1;
  -webkit-flex: 1;
  flex: 1;
}
.submenu .recomment .recomment-item .details .title-box {
  margin-bottom: 10px;
  display: -webkit-box;
  display: -ms-flexbox;
  display: -webkit-flex;
  display: flex;
  -webkit-box-align: center;
  -ms-flex-align: center;
  -webkit-align-items: center;
  align-items: center;
}
.submenu .recomment .recomment-item .details .title-box .title {
  display: flex;
  align-items: center;
  color: #1C1F21;
  width: 228px;
}
.submenu .recomment .recomment-item .details .title-box .title .text {
  display: inline-block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: calc(100% - 4em);
}
.submenu .recomment .recomment-item .details .title-box .title .tag {
  display: inline-block;
  width: 2em;
  color: #fff;
  opacity: .6;
  border-radius: 2px;
  line-height: 1;
  padding: 2px 4px;
  margin-left: 5px;
}
.submenu .recomment .recomment-item .details .title-box .title .tag.shizhan {
  background-color: #FA6400;
}
.submenu .recomment .recomment-item .details .title-box .title .tag.tixi {
  background-color: #E02020;
}
.submenu .recomment .recomment-item .details .title-box .title .tag.lujing {
  background-color: #0091FF;
}
.submenu .recomment .recomment-item .details .bottom {
  color: #9199A1;
  line-height: 18px;
}
.submenu .recomment .recomment-item .details .bottom .discount-name,
.submenu .recomment .recomment-item .details .bottom .tag {
  display: inline-block;
  color: #fff;
  background-color: rgba(242, 13, 13, 0.6);
  border-radius: 2px;
  padding: 2px 4px;
  line-height: 1;
}
.submenu .recomment .recomment-item .details .bottom .discount-name {
  background: rgba(242, 13, 13, 0.6);
}
.submenu .recomment .recomment-item .details .bottom .price:not(.free) {
  font-weight: bold;
  color: #F01414;
}
.menuContent {
  position: relative;
  float: left;
  width: 256px;
  height: 482px;
  z-index: 2;
  padding-top: 17px;
  box-sizing: border-box;
  background: #39364d;
  border-bottom-left-radius: 4px;
  font-weight: 400;
}
.menuContent .item {
  line-height: 50px;
  cursor: pointer;
  position: relative;
  color: #fff;
  padding: 0 14px;
  border-top-left-radius: 4px;
  border-bottom-left-radius: 4px;
  height: 50px;
  transition: all .1s;
  font-size: 14px;
}
.menuContent .item .sub-title {
  font-size: 12px;
}
.menuContent .item i {
  position: absolute;
  right: 4px;
  top: 16px;
  color: rgba(255, 255, 255, 0.5);
  font-size: 16px;
}
.menuContent .item.js-menu-item-on {
  color: #fff;
  background-color: rgba(255, 255, 255, 0.1);
}
</style>

Home.vue,代码:

vue
<template>
<div class="home">
    <Header></Header>
    <div id="main">
      <Banner></Banner>
    </div>
    <Footer></Footer>
</div>
</template>

<script setup>
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import Banner from "../components/Banner.vue"

</script>

<style scoped>

</style>

提交版本

bash
cd ~/Desktop/luffycity
git add .
git commit -m "feature:客户端展示轮播图界面效果"
git push origin develop

2.1 安装依赖模块和配置

安装图片处理模块,前面已经安装了,如果没有安装则需要安装

shell
pip install pillow

填写上传文件的相关配置,settings/dev.py

python
# 访问静态文件的url地址前缀
STATIC_URL = '/static/'
# 设置django的静态文件目录[手动创建]
STATICFILES_DIRS = [
    BASE_DIR / "static",
]

# 项目中存储上传文件的根目录[手动创建],注意,uploads目录需要手动创建否则上传文件时报错
MEDIA_ROOT = BASE_DIR / "uploads"
# 访问上传文件的url地址前缀
MEDIA_URL = "/uploads/"

总路由luffycityapi.urls.py新增代码:

python
from django.contrib import admin
from django.urls import path,re_path,include

from django.conf import settings
from django.views.static import serve # 静态文件代理访问模块

urlpatterns = [
    path('admin/', admin.site.urls),
    re_path(r'uploads/(?P<path>.*)', serve, {"document_root": settings.MEDIA_ROOT}),
    path("", include("home.urls")),
]

提交版本

bash
cd ~/Desktop/luffycity
git add .
git commit -m "feature:服务端提供访问静态文件(static和uploads)的url配置"
git push origin develop

2.2 创建轮播图的模型

home/models.py,代码:

python
from models import BaseModel,models
# Create your models here.

class Banner(BaseModel):
  	# 有oss的话,upload_to,就是oss的存储图片路径,没有就是本地的图片存储路径
    image = models.ImageField(upload_to="banner/%Y/", verbose_name="图片地址")
    link = models.CharField(max_length=500, verbose_name="链接地址")
    note = models.CharField(max_length=150, verbose_name='备注信息')
    is_http = models.BooleanField(default=False, verbose_name="是否外链地址", help_text="站点链接地址:http://www.baidu.com/book<br>站点链接地址:/book/")

    class Meta:
        db_table = "fg_banner"
        verbose_name = "轮播广告"
        verbose_name_plural = verbose_name

数据迁移

python
cd luffycityapi
python manage.py makemigrations
python manage.py migrate

把课件中素材目录下的图片保存到项目上传文件存储目录下luffycityapi/uploads/banner/2021/,并添加测试数据到MySQL。

sql
INSERT INTO luffycity.lf_banner (id, name, orders, is_show, is_deleted, created_time, updated_time, image, note, link, is_http) VALUES (1, '1', 1, 1, 0, '2021-07-15 03:39:49.859000', '2021-07-15 03:39:51.437000', 'banner/2022/1.jpg', '暂无', '/project', 0);
INSERT INTO luffycity.lf_banner (id, name, orders, is_show, is_deleted, created_time, updated_time, image, note, link, is_http) VALUES (2, '2', 1, 1, 0, '2021-07-15 03:39:49.859000', '2021-07-15 03:39:51.437000', 'banner/2022/2.jpg', '暂无', '/project', 0);
INSERT INTO luffycity.lf_banner (id, name, orders, is_show, is_deleted, created_time, updated_time, image, note, link, is_http) VALUES (3, '3', 1, 1, 0, '2021-07-15 03:39:49.859000', '2021-07-15 03:39:51.437000', 'banner/2022/3.jpg', '暂无', '/project', 0);
INSERT INTO luffycity.lf_banner (id, name, orders, is_show, is_deleted, created_time, updated_time, image, note, link, is_http) VALUES (4, '4', 1, 1, 0, '2021-07-15 03:39:49.859000', '2021-07-15 03:39:51.437000', 'banner/2022/4.jpg', '暂无', '/project', 0);
INSERT INTO luffycity.lf_banner (id, name, orders, is_show, is_deleted, created_time, updated_time, image, note, link, is_http) VALUES (5, '5', 1, 1, 0, '2021-07-15 03:39:49.859000', '2021-07-15 03:39:51.437000', 'banner/2022/5.jpg', '暂无', '/project', 0);

2.3 序列化器

home/serializers.py

python
class BannerModelSerializer(serializers.ModelSerializer):
    """
    轮播广告的序列化器
    """
    class Meta:
        model = Banner
        fields = ["image", "name", "link", "is_http"]

2.4 视图代码

home/views.py

python
import constants
from rest_framework.generics import ListAPIView
from .models import Nav, Banner
from .serializers import NavModelSerializer, BannerModelSerializer

# 中间代码省略

class BannerListAPIView(ListAPIView):
    """轮播广告视图"""
    queryset = Banner.objects.filter(is_show=True, is_deleted=False).order_by("orders", "-id")[:constants.BANNER_SIZE]
    serializer_class = BannerModelSerializer

2.5 路由代码

home/urls.py,代码:

python
from django.urls import path
from . import views
urlpatterns = [
    path("nav/header/", views.NavHeaderListAPIView.as_view()),
    path("nav/footer/", views.NavFooterListAPIView.as_view()),
    path("banner/", views.BannerListAPIView.as_view()),
]

utils/constants.py,常量文件:

python
# 轮播广告显示的最大数量
BANNER_SIZE = 10

提交git版本

bash
cd ~/Desktop/luffycity
git add .
git commit -m "feature:api服务端实现轮播广告接口"
git push origin develop

2.6 客户端获取轮播广告的数据

src/api/banner.js,代码:

javascript
import http from "../utils/http"
import {reactive, ref} from "vue"

const banner = reactive({
    banner_list: [], // 轮播广告列表
    get_banner_list(){
        // 获取轮播广告
        return http.get("/home/banner/")
    },

})

export default banner;

src/components/Banner.vue,代码:

vue
<template>
   <div class="bk"></div>
   <div class="bgfff banner-box">
    <div class="g-banner pr" @mouseleave="state.current_menu=-1">
     <!-- 商品课程分类信息 -->
     <div class="submenu" v-if="state.current_menu==0">
      <div class="inner-box">
       <h2 class="type">前端开发</h2>
       <div class="tag clearfix">
       </div>
       <div class="lore">
        <span class="title">知识点:</span>
        <p class="lores clearfix"><a target="_blank" href="">Vue.js</a>
          <a target="_blank" href="">Typescript</a>
          <a target="_blank" href="">React.JS</a>
          <a target="_blank" href="">HTML/CSS</a>
          <a target="_blank" href="">JavaScript</a>
          <a target="_blank" href="">Angular</a>
          <a target="_blank" href="">Node.js</a>
          <a target="_blank" href="">jQuery</a>
          <a target="_blank" href="">Bootstrap</a>
          <a target="_blank" href="">Sass/Less</a>
          <a target="_blank" href="">WebApp</a>
          <a target="_blank" href="">小程序</a>
          <a target="_blank" href="">前端工具</a>
          <a target="_blank" href="">CSS</a>
          <a target="_blank" href="">Html5</a>
          <a target="_blank" href="">CSS3</a>
        </p>
       </div>
      </div>
      <div class="recomment clearfix">
        <a href="" target="_blank" title="" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/60a7779909e3fc1206960344.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">前端工程师2021</span> <span class="tag tixi">体系</span> </p>
         </div>
         <div class="bottom">
          <span class="discount-name">优惠价</span>
          <span class="price">¥4599.00</span> &middot;
          <span class="difficulty"> 零基础 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 19322</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="前端框架及项目面试 聚焦Vue3/React/Webpack" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/5e3cfea008e9a61b06000338-360-202.jpg')"></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">前端框架及项目面试 聚焦Vue3/React/Webpack</span> <span class="tag shizhan">实战</span> </p>
         </div>
         <div class="bottom">
          <span class="price">399.00</span> &middot;
          <span class="difficulty"> 中级 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 2946</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="从0打造微前端框架,实战汽车资讯平台,系统掌握微前端架构设计与落地能力" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/60d44ec8084b799712000676-360-202.jpg')"></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"><span class="text">从0打造微前端框架,实战汽车资讯平台,系统掌握微前端架构设计与落地能力</span><span class="tag shizhan">实战</span> </p>
         </div>
         <div class="bottom">
          <span class="discount-name">限时优惠</span>
          <span class="price">¥328.00</span> &middot;
          <span class="difficulty"> 高级 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 109</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/604f2bab0952610803240324-140-140.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">Vue.js 从入门到精通</span> <span class="tag lujing">路线</span> </p>
         </div>
         <div class="bottom">
          <span class="difficulty">4步骤</span> &middot;
          <span class="difficulty">4门课</span> &middot;
          <span class="num">19697人收藏</span>
         </div>
        </div> </a>
      </div>
     </div>
     <div class="submenu" v-if="state.current_menu==1">
      <div class="inner-box">
       <h2 class="type">后端开发</h2>
       <div class="tag clearfix">
       </div>
       <div class="lore">
        <span class="title">知识点:</span>
        <p class="lores clearfix">
          <a target="_blank" href="">Java</a>
          <a target="_blank" href="">SpringBoot</a>
          <a target="_blank" href="">Spring Cloud</a>
          <a target="_blank" href="">SSM</a>
          <a target="_blank" href="">PHP</a>
          <a target="_blank" href="">.net</a>
          <a target="_blank" href="">Python</a>
          <a target="_blank" href="">爬虫</a>
          <a target="_blank" href="">Django</a>
          <a target="_blank" href="">Flask</a>
          <a target="_blank" href="">Tornado</a>
          <a target="_blank" href="">Go</a>
          <a target="_blank" href="">C</a>
          <a target="_blank" href="">C++</a>
          <a target="_blank" href="">C#</a>
          <a target="_blank" href="">Ruby</a></p>
       </div>
      </div>
      <div class="recomment clearfix">
        <a href="" target="_blank" title="Java工程师2021" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/60a777ef0942d7bf06960344.png'); background-size: 100%; "></div>
        <div class="details">
         <div class="title-box">
          <p class="title"> <span class="text">Java工程师2021</span> <span class="tag tixi">体系</span> </p>
         </div>
         <div class="bottom">
          <span class="discount-name">优惠价</span>
          <span class="price">¥4399.00</span> &middot;
          <span class="difficulty"> 零基础 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 15052</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="Python工程师(全能型)" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/60a77721093df37606960344.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">Python工程师(全能型)</span> <span class="tag tixi">体系</span> </p>
         </div>
         <div class="bottom">
          <span class="discount-name">优惠价</span>
          <span class="price">¥4366.00</span> &middot;
          <span class="difficulty"> 零基础 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 10786</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="Java全栈工程师" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/5dd6567b09d9d01c06000338.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">Java全栈工程师</span> <span class="tag tixi">体系</span> </p>
         </div>
         <div class="bottom">
          <span class="discount-name">优惠价</span>
          <span class="price">¥3380.00</span> &middot;
          <span class="difficulty"> 进阶 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 1853</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/604f2bb6099d6a8803240324-140-140.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">SpringBoot从入门到精通</span> <span class="tag lujing">路线</span> </p>
         </div>
         <div class="bottom">
          <span class="difficulty">3步骤</span> &middot;
          <span class="difficulty">5门课</span> &middot;
          <span class="num">11092人收藏</span>
         </div>
        </div> </a>
      </div>
     </div>
     <div class="submenu" v-if="state.current_menu==2">
      <div class="inner-box">
       <h2 class="type">移动开发</h2>
       <div class="tag clearfix">
       </div>
       <div class="lore">
        <span class="title">知识点:</span>
        <p class="lores clearfix"></p>
       </div>
      </div>
      <div class="recomment clearfix">
       <a href="" target="_blank" title="移动端架构师成长体系课" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/5ec5ddf209cd2c8606000338.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">移动端架构师成长体系课</span> <span class="tag tixi">体系</span> </p>
         </div>
         <div class="bottom">
          <span class="discount-name">优惠价</span>
          <span class="price">¥4888.00</span> &middot;
          <span class="difficulty"> 进阶 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 402</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="Flutter高级进阶实战  仿哔哩哔哩APP 一次性深度掌握Flutter高阶技能" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/60497caf0971842912000676-360-202.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">Flutter高级进阶实战 仿哔哩哔哩APP 一次性深度掌握Flutter高阶技能</span> <span class="tag shizhan">实战</span> </p>
         </div>
         <div class="bottom">
          <span class="price">368.00</span> &middot;
          <span class="difficulty"> 高级 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 646</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="音视频基础+ffmpeg原理+项目实战 一课完成音视频技术开发入门" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/5e5621d0092c054612000676-360-202.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">音视频基础+ffmpeg原理+项目实战 一课完成音视频技术开发入门</span> <span class="tag shizhan">实战</span> </p>
         </div>
         <div class="bottom">
          <span class="price">288.00</span> &middot;
          <span class="difficulty"> 入门 </span> &middot;
          <span class="num"><i class="imv2-set-sns"></i> 1303</span>
         </div>
        </div> </a>
       <a href="" target="_blank" title="" class="recomment-item">
        <div class="img" style="background-image: url('/src/assets/604f2b52090de67603240324-140-140.png'); background-size: 100%; "></div>
        <div class="details">
         <!--路径单独写-->
         <div class="title-box">
          <p class="title"> <span class="text">Android工程师高薪面试突破路线</span> <span class="tag lujing">路线</span> </p>
         </div>
         <div class="bottom">
          <span class="difficulty">3步骤</span> &middot;
          <span class="difficulty">3门课</span> &middot;
          <span class="num">1471人收藏</span>
         </div>
        </div> </a>
      </div>
     </div>
     <div class="menuContent">
      <div class="item" :class="{'js-menu-item-on': state.current_menu==0}" @mouseover="state.current_menu=0">
       <span class="title">前端开发:</span>
       <span class="sub-title">HTML5 / Vue.js / Node.js</span>
       <i class="imv2-arrow1_r"></i>
      </div>
      <div class="item" :class="{'js-menu-item-on': state.current_menu==1}" @mouseover="state.current_menu=1">
       <span class="title">后端开发:</span>
       <span class="sub-title">Java / Python / Go</span>
       <i class="imv2-arrow1_r"></i>
      </div>
      <div class="item" :class="{'js-menu-item-on': state.current_menu==2}" @mouseover="state.current_menu=2">
       <span class="title">移动开发:</span>
       <span class="sub-title">Flutter / Android / iOS </span>
       <i class="imv2-arrow1_r"></i>
      </div>
     </div>
      <!-- 轮播图-->
      <div class="g-banner-content"  @mouseover="state.current_menu=-1">
        <el-carousel :interval="5000" arrow="always" height="482px" v-if="banner.banner_list[0]">
          <el-carousel-item v-for="item,key in banner.banner_list" :key="key">
            <a :href="item.link" v-if="item.is_http"><img :src="item.image" alt="" style="width: 100%;height: 100%;"></a>
            <router-link :to="item.link" v-else><img :src="item.image" alt="" style="width: 100%;height: 100%;"></router-link>
          </el-carousel-item>
        </el-carousel>
     </div>
    </div>
   </div>
</template>

<script setup>
import {reactive} from "vue"
import banner from "../api/banner";

// 获取轮播广告列表
banner.get_banner_list().then(response=>{
  banner.banner_list = response.data
})

const state = reactive({
  current_menu: -1,
})
</script>

提交git版本

bash
cd ~/Desktop/luffycity
git add .
git commit -m "feature:客户端展示轮播广告数据"
git push origin develop

3. 缓存导航与轮播图数据

因为导航菜单或轮播广告在项目中每一个页面都会被用户访问到,所以我们可以实现缓存,减少MySQL数据库的查询压力,使用内存缓存可以加快数据查询速度。

视图缓存:https://docs.djangoproject.com/zh-hans/3.2/topics/cache/#the-per-view-cache

装饰类视图:https://docs.djangoproject.com/zh-hans/3.2/topics/class-based-views/intro/#decorating-the-class

utils/views.py,代码:

python
import constants
from rest_framework.generics import ListAPIView
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page


class CacheListAPIView(ListAPIView):
    """列表缓存视图"""
    @method_decorator(cache_page(constants.LIST_PAGE_CACHE_TIME))
    def get(self,request, *args, **kwargs):
        # 重写ListAPIView的get方法,但是不改动源代码。仅仅装饰而已
        return super().get(request, *args, **kwargs)

utils/constants.py,代码:

python
# 列表页数据的缓存周期,单位:秒
LIST_PAGE_CACHE_TIME = 24 * 60 * 60

home/views.py,代码:

python
import constants
from views import CacheListAPIView
from .models import Nav, Banner
from .serializers import NavModelSerializer, BannerModelSerializer



class NavHeaderListAPIView(CacheListAPIView):
    """顶部导航视图"""
    queryset = Nav.objects.filter(position=constants.NAV_HEADER_POSITION, is_show=True, is_deleted=False).order_by("orders", "-id")[:constants.NAV_HEADER_SIZE]
    serializer_class = NavModelSerializer


class NavFooterListAPIView(CacheListAPIView):
    """脚部导航视图"""
    queryset = Nav.objects.filter(position=constants.NAV_FOOTER_POSITION, is_show=True, is_deleted=False).order_by("orders", "-id")[:constants.NAV_FOOTER_SIZE]
    serializer_class = NavModelSerializer


class BannerListAPIView(CacheListAPIView):
    """轮播广告视图"""
    queryset = Banner.objects.filter(is_show=True, is_deleted=False).order_by("orders", "-id")[:constants.BANNER_SIZE]
    serializer_class = BannerModelSerializer

注意:此处数据使用了缓存,那么将来admin站点在修改此处相关的数据库的数据时,admin站点中我们就需要在更新数据时对缓存进行删除,这块业务逻辑等我们后面登陆注册功能以后搭建admin后面时会带着小伙伴们完成。

提交git版本

bash
cd ~/Desktop/luffycity
git add .
git commit -m "perf:服务端实现导航和轮播的数据缓存"
git push origin develop

chapter 3 基于jwt的分布式认证流程

1.用户的登陆认证(3个步骤)

接下来,因为是新开发一个功能模块,那么我们可以在新的分支下进行开发,将来方便对这部分代码进行单独管理,等开发完成了以后再合并分支到develop也是可以的。

bash
cd ~/Desktop/luffycity
git checkout -b feature/user

1.0 写登陆的3个步骤

  1. 利用vuex搞一个大容器,存放全局数据,主要是token,存别的也行。
  2. 但vuex的数据是保存在内存的,刷新就没,所以利用vuex-persistedstate,将数据写在本地存储,localStorage中。
  3. 由于全局维持登陆状态,所以每次跳转前要判断是否登陆,写路由守卫。

1.1 前端显示登陆页面

方法一:登陆弹窗

登录页组件

components/Login.vue

vue
<template>
  <div class="title">
    <span :class="{active:state.login_type==0}" @click="state.login_type=0">密码登录</span>
    <span :class="{active:state.login_type==1}" @click="state.login_type=1">短信登录</span>
  </div>
  <div class="inp" v-if="state.login_type==0">
    <input v-model="state.username" type="text" placeholder="用户名 / 手机号码" class="user">
    <input v-model="state.password" type="password" class="pwd" placeholder="密码">
    <div id="geetest1"></div>
    <div class="rember">
      <label>
        <input type="checkbox" class="no" name="a"/>
        <span>记住密码</span>
      </label>
      <p>忘记密码</p>
    </div>
    <button class="login_btn">登录</button>
    <p class="go_login" >没有账号 <span>立即注册</span></p>
  </div>
  <div class="inp" v-show="state.login_type==1">
    <input v-model="state.username" type="text" placeholder="手机号码" class="user">
    <input v-model="state.password"  type="text" class="code" placeholder="短信验证码">
    <el-button id="get_code" type="primary">获取验证码</el-button>
    <button class="login_btn">登录</button>
    <p class="go_login" >没有账号 <span>立即注册</span></p>
  </div>
</template>

<script setup>
import {reactive} from "vue";

const state = reactive({
  login_type: 0,
  username:"",
  password:"",
})
</script>

<style scoped>
.title{
    font-size: 20px;
    color: #9b9b9b;
    letter-spacing: .32px;
    border-bottom: 1px solid #e6e6e6;
    display: flex;
    justify-content: space-around;
    padding: 0px 60px 0 60px;
    margin-bottom: 20px;
    cursor: pointer;
}
.title span.active{
	color: #4a4a4a;
    border-bottom: 2px solid #84cc39;
}

.inp{
	width: 350px;
	margin: 0 auto;
}
.inp .code{
    width: 220px;
    margin-right: 16px;
}
#get_code{
   margin-top: 6px;
}
.inp input{
    outline: 0;
    width: 100%;
    height: 45px;
    border-radius: 4px;
    border: 1px solid #d9d9d9;
    text-indent: 20px;
    font-size: 14px;
    background: #fff !important;
}
.inp input.user{
    margin-bottom: 16px;
}
.inp .rember{
    display: flex;
    justify-content: space-between;
    align-items: center;
    position: relative;
    margin-top: 10px;
}
.inp .rember p:first-of-type{
    font-size: 12px;
    color: #4a4a4a;
    letter-spacing: .19px;
    margin-left: 22px;
    display: -ms-flexbox;
    display: flex;
    -ms-flex-align: center;
    align-items: center;
    /*position: relative;*/
}
.inp .rember p:nth-of-type(2){
    font-size: 14px;
    color: #9b9b9b;
    letter-spacing: .19px;
    cursor: pointer;
}

.inp .rember input{
    outline: 0;
    width: 30px;
    height: 45px;
    border-radius: 4px;
    border: 1px solid #d9d9d9;
    text-indent: 20px;
    font-size: 14px;
    background: #fff !important;
    vertical-align: middle;
    margin-right: 4px;
}

.inp .rember p span{
    display: inline-block;
    font-size: 12px;
    width: 100px;
}
.login_btn{
    cursor: pointer;
    width: 100%;
    height: 45px;
    background: #84cc39;
    border-radius: 5px;
    font-size: 16px;
    color: #fff;
    letter-spacing: .26px;
    margin-top: 30px;
    border: none;
    outline: none;
}
.inp .go_login{
    text-align: center;
    font-size: 14px;
    color: #9b9b9b;
    letter-spacing: .26px;
    padding-top: 20px;
}
.inp .go_login span{
    color: #84cc39;
    cursor: pointer;
}
</style>

components/Header.vue,代码:

vue
<template>
    <div class="header-box">
      <div class="header">
        <div class="content">
          <div class="logo">
            <router-link to="/"><img src="../assets/logo.png" alt=""></router-link>
          </div>
          <ul class="nav">
              <li v-for="item in nav.header_nav_list">
                <a :href="item.link" v-if="item.is_http">{{item.name}}</a>
                <router-link :to="item.link" v-else>{{item.name}}</router-link>
              </li>
          </ul>
          <div class="search-warp">
            <div class="search-area">
              <input class="search-input" placeholder="请输入关键字..." type="text" autocomplete="off">
              <div class="hotTags">
                <router-link to="/search/?words=Vue" target="_blank" class="">Vue</router-link>
                <router-link to="/search/?words=Python" target="_blank" class="last">Python</router-link>
              </div>
            </div>
            <div class="showhide-search" data-show="no"><img class="imv2-search2" src="../assets/search.svg" /></div>
          </div>
          <div class="login-bar">
            <div class="shop-cart full-left">
              <img src="../assets/cart.svg" alt="" />
              <span><router-link to="/cart">购物车</router-link></span>
            </div>
            <div class="login-box full-left">
              <span @click="state.show_login=true">登录</span>
              &nbsp;/&nbsp;
              <span>注册</span>
            </div>
          </div>
        </div>
      </div>
    </div>
    <el-dialog :width="600" v-model="state.show_login">
      <Login></Login>
    </el-dialog>
</template>


<script setup>
import Login from "./Login.vue"
import {reactive} from "vue";
import nav from "../api/nav";

const state = reactive({
  show_login: false,
})

// 获取头部导航
nav.get_header_nav().then(response=>{
  nav.header_nav_list = response.data;
}).catch(error=>{
  console.log(error);
});

</script>

方法二:登陆是个单独的页面

views/Login.vue,代码:

vue
<template>
	<div class="login box">
		<img src="../assets/Loginbg.3377d0c.jpg" alt="">
		<div class="login">
			<div class="login-title">
				<img src="../assets/logo.svg" alt="">
				<p>帮助有志向的年轻人通过努力学习获得体面的工作和生活!</p>
			</div>
      <div class="login_box">
          <Login></Login>
      </div>
		</div>
	</div>
</template>

<script setup>
import Login from "../components/Login.vue"

</script>

<style scoped>
.box{
	width: 100%;
  height: 100%;
	position: relative;
  overflow: hidden;
}
.box img{
	width: 100%;
  min-height: 100%;
}
.box .login {
	position: absolute;
	width: 500px;
	height: 400px;
	left: 0;
  margin: auto;
  right: 0;
  bottom: 0;
  top: -438px;
}

.login-title{
     width: 100%;
    text-align: center;
}
.login-title img{
    width: 190px;
    height: auto;
}
.login-title p{
    font-size: 18px;
    color: #fff;
    letter-spacing: .29px;
    padding-top: 10px;
    padding-bottom: 50px;
}
.login_box{
    width: 400px;
    height: auto;
    background: #fff;
    box-shadow: 0 2px 4px 0 rgba(0,0,0,.5);
    border-radius: 4px;
    margin: 0 auto;
    padding-bottom: 40px;
    padding-top: 50px;
}
</style>

绑定登陆页面路由地址

src/router/index.js,代码:

javascript
import {createRouter, createWebHistory} from 'vue-router'

// 路由列表
const routes = [
  {
    meta:{
        title: "浮光在线教育-首页",
        keepAlive: true
    },
    path: '/',         // uri访问地址
    name: "Home",
    component: ()=> import("../views/Home.vue")
  },
  {
    meta:{
        title: "浮光在线教育-用户登录",
        keepAlive: true
    },
    path:'/login',      // uri访问地址
    name: "Login",
    component: ()=> import("../views/Login.vue")
  }
]

// 路由对象实例化
const router = createRouter({
  // history, 指定路由的模式
  history: createWebHistory(),
  // 路由列表
  routes,
});


// 暴露路由对象
export default router

最后,git管理

bash
git add .
git commit -m "feature:自定义用户模型"
# 执行 git push 以后会提示如下,跟着执行提示的命令即可。
git push --set-upstream origin feature/user  
# 这句命令表示提交的时候,同步创建线上分支

1.2 后端实现登陆认证

image-20240215122247081

Django提供了认证系统Auth模块,包含:

  • 用户管理
  • 权限管理[RBAC]
  • 用户组管理(就是权限里面的角色)
  • 密码哈希系统(就是密码加密和验证密码)
  • 用户登录或内容显示的表单和视图
  • 一个可插拔的后台系统(admin站点)

但Auth模块依赖Session机制,而session认证机制:

  1. session默认会把session_id 作为cookie保存到客户端。有些客户端的是默认禁用cookie/或者没法使用cookie的。(eg:游戏)
  2. session的数据默认是保存到服务端的,带来一定的存储要求。

所以,引入JWT认证机制。jwt将用户的身份凭据存放在一个Token(认证令牌,本质上就是一个经过处理的字符串)中,然后把token发送给客户端,客户端可以选择采用自己的技术来保存这个token。

因此,写用户认证系统的逻辑:

Auth模块通过用户模型类User保存的用户的数据。可以改写此类并加上jwt,从而在Auth基础上,改写自己的用户认证系统。

在django中如果要实现jwt认证,有一个常用的第三方jwt模块,我们可以通过jwt对接Django的认证系统,来快速实现:

  • 用户的数据模型
  • 用户密码的加密与验证
  • 用户的权限系统

1.2.1 Django用户模型类

python
from django.contrib.auth.models import User

Django的Auth认证系统中提供了用户模型类User保存用户的数据,默认的User包含以下常见的基本字段:

字段名字段描述
username必选。150个字符以内。 用户名可能包含字母数字,_@+ .-个字符。
first_name可选(blank=True)。 少于等于30个字符。
last_name可选(blank=True)。 少于等于30个字符。
email可选(blank=True)。 邮箱地址。
password必选。 密码的哈希加密串。 (Django 不保存原始密码)。 原始密码可以无限长而且可以包含任意字符。
groupsGroup 之间的多对多关系。对接权限功能的。
user_permissionsPermission 之间的多对多关系。对接权限功能的。
is_staff布尔值。 设置用户是否可以访问Admin 站点。
is_active布尔值。 指示用户的账号是否激活。 它不是用来控制用户是否能够登录,而是描述一种帐号的使用状态。值为False的时候,是无法登录的。
is_superuser是否是超级用户。超级用户具有所有权限。
last_login用户最后一次登录的时间。
date_joined账户创建的时间。 当账号创建时,默认设置为当前的date/time。
模型提供的常用方法:

模型常用方法可以通过user实例对象.方法名来进行调用。

  • set_password(raw_password)

    设置用户的密码为给定的原始字符串,并负责密码的。 不会保存User 对象。当Noneraw_password 时,密码将设置为一个不可用的密码。

  • check_password(raw_password)

    如果给定的raw_password是用户的真实密码,则返回True,可以在校验用户密码时使用。

管理器的常用方法:

管理器方法可以通过User.objects. 进行调用。

  • create_user(username, email=None, password=None, **extra_fields)

    创建、保存并返回一个User对象。

  • create_superuser(username, email, password, **extra_fields)

    create_user() 相同,但是设置is_staffis_superuserTrue

因此,自定义一个新的users子应用并在django原有功能的基础上,完善用户的登录注册功能。

1.2.2 创建用户模块的子应用

shell
cd luffycityapi/apps/
python ../../manage.py startapp users

在settings/dev.py文件中注册子应用。

python
INSTALLED_APPS = [
    ...
  	'users',
]

创建users/urls.py子路由并在总路由中进行注册。

users/urls.py,代码:

python
from django.urls import path
from . import views
urlpatterns = [

]

luffycityapi/urls.py,总路由,代码:

python
from django.contrib import admin
from django.urls import path,re_path,include

from django.conf import settings
from django.views.static import serve # 静态文件代理访问模块

urlpatterns = [
    path('admin/', admin.site.urls),
    re_path(r'uploads/(?P<path>.*)', serve, {"document_root": settings.MEDIA_ROOT}),
    path("", include("home.urls")),
    path("users/", include("users.urls")),
]

1.2.3 创建自定义的用户模型类

Django认证系统中提供的用户模型类及方法很方便,我们可以使用这个模型类,但是字段有些无法满足项目需求,如本项目中需要保存用户的手机号,需要给模型类添加额外的字段。

Django提供了django.contrib.auth.models.AbstractUser用户抽象模型类允许我们继承,扩展字段来使用Django认证系统的用户模型类。

我们可以在apps中创建Django应用users,并在配置文件中注册users应用。

在创建好的应用models.py中定义用户的用户模型类。

apps/users/models.py

python
from django.db import models
from django.contrib.auth.models import AbstractUser

# Create your models here.

class User(AbstractUser):
    mobile = models.CharField(max_length=15, unique=True, verbose_name='手机号')
    money = models.DecimalField(max_digits=9, default=0.0, decimal_places=2, verbose_name="钱包余额")
    credit = models.IntegerField(default=0, verbose_name="积分")
    avatar = models.ImageField(upload_to="avatar/%Y", null=True, default="", verbose_name="个人头像")               #upload_to用于指定上传文件的存储路径
    nickname = models.CharField(max_length=50, default="", null=True, verbose_name="用户昵称")

    class Meta:
        db_table = 'lf_users'
        verbose_name = '用户信息'
        verbose_name_plural = verbose_name

我们自定义的用户模型类还不能直接被Django的认证系统所识别,需要在配置文件中告知Django认证系统使用我们自定义的模型类。

在settings/dev.py配置文件中进行设置

python
AUTH_USER_MODEL = 'users.User'   #应用名.类名,中间不需要加models,源码限制的

AUTH_USER_MODEL 参数的设置以点.来分隔,表示应用名.模型类名,,中间不需要加models,源码限制的

注意:Django建议我们对于AUTH_USER_MODEL参数的设置一定要在第一次数据库迁移之前就设置好,否则后续使用可能出现未知错误。

image-20240215123730981

这是表示有一个叫admin的子应用使用了原来的废弃的auth.User模型,但是目前数据库已经设置了默认的子应用为users的模型了,所以产生了冲突。那么这种冲突,我们需要重置下原来的auth模块的迁移操作,再次迁移就可以解决了。

bash
解决步骤:
1. 备份数据库[如果刚开始开发,无需备份。]
   cd /home/moluo/Desktop/luffycity/docs
   mysqldump -uroot -p123 luffycity > 03_20_luffycity.sql

2. 注释掉users.User代码以及AUTH_USER_MODEL配置项,然后执行数据迁移回滚操作,把冲突的所有表迁移记录全部归零
   cd ~/Desktop/luffycity/luffycityapi
   # python manage.py migrate <子应用目录> zero
   python manage.py migrate auth zero

3. 恢复users.User代码以及AUTH_USER_MODEL配置项,执行数据迁移。
   python manage.py makemigrations
   python manage.py migrate
4. 创建管理员查看auth功能是否能正常使用。
   python manage.py createsuperuser

提交版本

bash
cd ~/Desktop/luffycity/luffycityapi
git add .
git commit -m "feature:自定义用户模型"
git push origin feature/user

1.2.4 实现jwt认证分布式认证流程

在用户注册或登录后,我们想记录用户的登录状态,或者为用户创建身份认证的凭证。我们不再使用Session认证机制,而使用Json Web Token认证机制。

Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者(客户端)和服务提供者(服务端)间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于身份认证,也可被数据加密传输。

1.2.5 JWT的构成

JWT就一段字符串,由三段信息构成的,将这三段信息文本用.拼接一起就构成了Jwt token字符串。就像这样:

eyJ0eXAiOiAiand0IiwgImFsZyI6ICJIUzI1NiJ9.eyJzdWIiOiAicm9vdCIsICJleHAiOiAiMTUwMTIzNDU1IiwgImlhdCI6ICIxNTAxMDM0NTUiLCAibmFtZSI6ICJ3YW5neGlhb21pbmciLCAiYWRtaW4iOiB0cnVlLCAiYWNjX3B3ZCI6ICJRaUxDSmhiR2NpT2lKSVV6STFOaUo5UWlMQ0poYkdjaU9pSklVekkxTmlKOVFpTENKaGJHY2lPaUpJVXpJMU5pSjkifQ==.815ce0e4e15fff813c5c9b66cfc3791c35745349f68530bc862f7f63c9553f4b

第一部分我们称它为头部(header),第二部分我们称其为载荷(payload),第三部分是签证(signature).

header

jwt的头部承载两部分信息:

  • typ: 声明token类型,这里是jwt ,typ的值也可以是:Bear
  • alg: 声明签证的加密的算法 通常直接使用 HMAC SHA256

完整的头部就像下面这样的JSON:

{
  'typ': 'JWT',
  'alg': 'HS256'
}

然后将头部进行base64编码,构成了jwt的第一部分头部

python代码举例:

python
import base64, json
header_data = {"typ": "jwt", "alg": "HS256"}
header = base64.b64encode( json.dumps(header_data).encode() ).decode()
print(header) # eyJ0eXAiOiAiand0IiwgImFsZyI6ICJIUzI1NiJ9

payload

载荷就是存放有效信息的地方,这些有效信息包含三个部分:

  • 标准声明
  • 公共声明
  • 私有声明

标准声明指定jwt实现规范中要求的属性。 (官方建议但不强制使用) :

  • iss: jwt签发者
  • sub: jwt所面向的用户
  • aud: 接收jwt的一方
  • exp: jwt的过期时间,这个过期时间必须要大于签发时间
  • nbf: 定义在什么时间之后,该jwt才可以使用
  • iat: jwt的签发时间
  • jti: jwt的唯一身份标识,主要用来作为一次性token, 从而回避重放攻击。

公共声明 : 公共的声明可以添加任何的公开信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可直接读取.(eg:用户id,头像,用户名等)

私有声明 : 私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,里面存放的是一些可以在服务端或者客户端通过秘钥进行加密和解密的加密信息。往往采用的RSA非对称加密算法。

举例,定义一个payload载荷信息,demo/jwtdemo.py:

python
import base64, json, time

if __name__ == '__main__':
    # 载荷
    iat = int(time.time())
    payload_data = {
        "sub": "root",
        "exp": iat + 3600,  # 假设一小时过期
        "iat": iat,
        "name": "wangxiaoming",
        "avatar": "1.png",
        "user_id": 1,
        "admin": True,
        "acc_pwd": "QiLCJhbGciOiJIUzI1NiJ9QiLCJhbGciOiJIUzI1NiJ9QiLCJhbGciOiJIUzI1NiJ9",
    }
    # 将其进行base64编码,得到JWT的第二部分。
    payload = base64.b64encode(json.dumps(payload_data).encode()).decode()
    print(payload)
    # eyJzdWIiOiAicm9vdCIsICJleHAiOiAxNjQ3Nzc0Mjk1LCAiaWF0IjogMTY0Nzc3MDY5NSwgIm5hbWUiOiAid2FuZ3hpYW9taW5nIiwgImF2YXRhciI6ICIxLnBuZyIsICJ1c2VyX2lkIjogMSwgImFkbWluIjogdHJ1ZSwgImFjY19wd2QiOiAiUWlMQ0poYkdjaU9pSklVekkxTmlKOVFpTENKaGJHY2lPaUpJVXpJMU5pSjlRaUxDSmhiR2NpT2lKSVV6STFOaUo5In0=

signature

JWT的第三部分是一个签证信息,用于辨真伪,防篡改。这个签证信息由三部分组成:

  • header (base64后的头部)
  • payload (base64后的载荷)
  • secret(保存在服务端的秘钥字符串,不会提供给客户端的,这样可以保证客户端没有签发token的能力)

举例,定义一个完整的jwt token,demo/jwtdemo.py:

python
import base64, json, hashlib

if __name__ == '__main__':
    """jwt 头部的生成"""
    header_data = {"typ": "jwt", "alg": "HS256"}
    header = base64.b64encode( json.dumps(header_data).encode() ).decode()
    print(header) # eyJ0eXAiOiAiand0IiwgImFsZyI6ICJIUzI1NiJ9

    """jwt 载荷的生成"""
    payload_data = {
        "sub": "root",
        "exp": "150123455",
        "iat": "150103455",
        "name": "wangxiaoming",
        "admin": True,
        "acc_pwd": "QiLCJhbGciOiJIUzI1NiJ9QiLCJhbGciOiJIUzI1NiJ9QiLCJhbGciOiJIUzI1NiJ9",
    }
    # 将其进行base64编码,得到JWT的第二部分。
    payload = base64.b64encode(json.dumps(payload_data).encode()).decode()
    print(payload) # eyJzdWIiOiAicm9vdCIsICJleHAiOiAiMTUwMTIzNDU1IiwgImlhdCI6ICIxNTAxMDM0NTUiLCAibmFtZSI6ICJ3YW5neGlhb21pbmciLCAiYWRtaW4iOiB0cnVlLCAiYWNjX3B3ZCI6ICJRaUxDSmhiR2NpT2lKSVV6STFOaUo5UWlMQ0poYkdjaU9pSklVekkxTmlKOVFpTENKaGJHY2lPaUpJVXpJMU5pSjkifQ==

    # from django.conf import settings
    # secret = settings.SECRET_KEY
    secret = 'django-insecure-hbcv-y9ux0&8qhtkgmh1skvw#v7ru%t(z-#chw#9g5x1r3z=$p'
    data = header + payload + secret  # 秘钥绝对不能提供给客户端。
    HS256 = hashlib.sha256()
    HS256.update(data.encode('utf-8'))
    signature = HS256.hexdigest()
    print(signature) # 815ce0e4e15fff813c5c9b66cfc3791c35745349f68530bc862f7f63c9553f4b

    # jwt 最终的生成
    token = f"{header}.{payload}.{signature}"
    print(token)
    # eyJ0eXAiOiAiand0IiwgImFsZyI6ICJIUzI1NiJ9.eyJzdWIiOiAicm9vdCIsICJleHAiOiAiMTUwMTIzNDU1IiwgImlhdCI6ICIxNTAxMDM0NTUiLCAibmFtZSI6ICJ3YW5neGlhb21pbmciLCAiYWRtaW4iOiB0cnVlLCAiYWNjX3B3ZCI6ICJRaUxDSmhiR2NpT2lKSVV6STFOaUo5UWlMQ0poYkdjaU9pSklVekkxTmlKOVFpTENKaGJHY2lPaUpJVXpJMU5pSjkifQ==.815ce0e4e15fff813c5c9b66cfc3791c35745349f68530bc862f7f63c9553f4b

注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。

验证逻辑:

1.用户第一次登陆,验证通过后,服务器给用户返回一个jwt token。

2.用户访问其他页面时,携带jwt token,服务器拿到后切割为三部分,使用前两部分和存放在服务器的secret key

生成jwt token ,将生成的jwt token和用户携带的jwt token对比,若相同则返回给用户他请求的数据,不相同,验证失败,返回登陆页面,或登陆提示等,如下图。

image-20240215135615865

举例,定义一个完整的jwt token,并认证token,demo/jwtdemo.py:

python
import base64, json, hashlib
from datetime import datetime

if __name__ == '__main__':
    # 头部生成原理
    header_data = {
        "typ": "jwt",
        "alg": "HS256"
    }
    # print( json.dumps(header_data).encode() )
    # json转成字符串,接着base64编码处理
    header = base64.b64encode(json.dumps(header_data).encode()).decode()
    print(header)  # eyJ0eXAiOiAiand0IiwgImFsZyI6ICJIUzI1NiJ9


    # 载荷生成原理
    iat = int(datetime.now().timestamp()) # 签发时间
    payload_data = {
        "sub": "root",
        "exp": iat + 3600,  # 假设一小时过期
        "iat": iat,
        "name": "wangxiaoming",
        "admin": True,
        "acc_pwd": "QiLCJhbGciOiJIUzI1NiJ9QiLCJhbGciOiJIUzI1NiJ9QiLCJhbGciOiJIUzI1NiJ9",
    }

    payload = base64.b64encode(json.dumps(payload_data).encode()).decode()
    print(payload)
    # eyJzdWIiOiAicm9vdCIsICJleHAiOiAxNjM2NTk3OTAzLCAiaWF0IjogMTYzNjU5NDMwMywgIm5hbWUiOiAid2FuZ3hpYW9taW5nIiwgImFkbWluIjogdHJ1ZSwgImFjY19wd2QiOiAiUWlMQ0poYkdjaU9pSklVekkxTmlKOVFpTENKaGJHY2lPaUpJVXpJMU5pSjlRaUxDSmhiR2NpT2lKSVV6STFOaUo5In0=

    # from django.conf import settings
    # secret = settings.SECRET_KEY
    secret = 'django-insecure-hbcv-y9ux0&8qhtkgmh1skvw#v7ru%t(z-#chw#9g5x1r3z=$p'

    data = header + payload + secret  # 秘钥绝对不能提供给客户端。

    HS256 = hashlib.sha256()
    HS256.update(data.encode('utf-8'))
    signature = HS256.hexdigest()
    print(signature) # ce46f9d350be6b72287beb4f5f9b1bc4c42fc1a1f8c8db006e9e99fd46961156

    # jwt 最终的生成
    token = f"{header}.{payload}.{signature}"
    print(token)
    # eyJ0eXAiOiAiand0IiwgImFsZyI6ICJIUzI1NiJ9.eyJzdWIiOiAicm9vdCIsICJleHAiOiAxNjM2NTk3OTAzLCAiaWF0IjogMTYzNjU5NDMwMywgIm5hbWUiOiAid2FuZ3hpYW9taW5nIiwgImFkbWluIjogdHJ1ZSwgImFjY19wd2QiOiAiUWlMQ0poYkdjaU9pSklVekkxTmlKOVFpTENKaGJHY2lPaUpJVXpJMU5pSjlRaUxDSmhiR2NpT2lKSVV6STFOaUo5In0=.ce46f9d350be6b72287beb4f5f9b1bc4c42fc1a1f8c8db006e9e99fd46961156


    # 认证环节
    token = "eyJ0eXAiOiAiand0IiwgImFsZyI6ICJIUzI1NiJ9.eyJzdWIiOiAicm9vdCIsICJleHAiOiAxNjM2NTk3OTAzLCAiaWF0IjogMTYzNjU5NDMwMywgIm5hbWUiOiAid2FuZ3hpYW9taW5nIiwgImFkbWluIjogdHJ1ZSwgImFjY19wd2QiOiAiUWlMQ0poYkdjaU9pSklVekkxTmlKOVFpTENKaGJHY2lPaUpJVXpJMU5pSjlRaUxDSmhiR2NpT2lKSVV6STFOaUo5In0=.ce46f9d350be6b72287beb4f5f9b1bc4c42fc1a1f8c8db006e9e99fd46961156"
    # token = "eyJ0eXAiOiAiand0IiwgImFsZyI6ICJIUzI1NiJ9.eyJzdWIiOiJyb290IiwiZXhwIjoxNjMxNTI5MDg4LCJpYXQiOjE2MzE1MjU0ODgsIm5hbWUiOiJ3YW5neGlhb2hvbmciLCJhZG1pbiI6dHJ1ZSwiYWNjX3B3ZCI6IlFpTENKaGJHY2lPaUpJVXpJMU5pSjlRaUxDSmhiR2NpT2lKSVV6STFOaUo5UWlMQ0poYkdjaU9pSklVekkxTmlKOSJ9.b533c5515444c51058557017e433d411379862d91640c8beed6f2617b1da2feb"
    header, payload, signature = token.split(".")

    # 验证是否过期了
    # 先基于base64,接着使用json解码
    payload_data = json.loads( base64.b64decode(payload.encode()) )
    print(payload_data)
    exp = payload_data.get("exp", None)
    if exp is not None and int(exp) < int(datetime.now().timestamp()):
        print("token过期!!!")
    else:
        print("没有过期")

    # 验证token是否有效,是否被篡改
    # from django.conf import settings
    # secret = settings.SECRET_KEY
    secret = 'django-insecure-hbcv-y9ux0&8qhtkgmh1skvw#v7ru%t(z-#chw#9g5x1r3z=$p'
    data = header + payload + secret  # 秘钥绝对不能提供给客户端。
    HS256 = hashlib.sha256()
    HS256.update(data.encode('utf-8'))
    new_signature = HS256.hexdigest()

    if new_signature != signature:
        print("认证失败")
    else:
        print("认证通过")

提交版本

bash
cd ~/Desktop/luffycity/luffycityapi
git add .
git commit -m "test:jwt构成原理、jwt签发和验证流程"
git push origin feature/user

关于签发和核验JWT,python中提供了一个PyJWT模块帮我们实现jwt的整体流程。我们可以使用Django REST framework JWT扩展来完成。

文档网站:https://jpadilla.github.io/django-rest-framework-jwt/

1.2.6 安装配置JWT

安装

shell
pip install djangorestframework-jwt

settings/dev.py,配置jwt

python
# drf配置
REST_FRAMEWORK = {
    # 自定义异常处理
    'EXCEPTION_HANDLER': 'luffycityapi.utils.exceptions.exception_handler',
    # 自定义认证
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_jwt.authentication.JSONWebTokenAuthentication',  # jwt认证
        'rest_framework.authentication.SessionAuthentication',           # session认证
        'rest_framework.authentication.BasicAuthentication',
    ),
}

import datetime
# jwt认证相关配置项
JWT_AUTH = {
    # 设置jwt的有效期
    # 如果内部站点,例如:运维开发系统,OA,往往配置的access_token有效期基本就是15分钟,30分钟,1~2个小时
    'JWT_EXPIRATION_DELTA': datetime.timedelta(weeks=1), # 一周有效,
}
  • JWT_EXPIRATION_DELTA 指明token的有效期

1.2.7 生成jwt

Django REST framework JWT 扩展的说明文档中提供了手动签发JWT的方法

官方文档:https://jpadilla.github.io/django-rest-framework-jwt/#creating-a-new-token-manually

python
# 可以进入到django的终端下测试生成token的逻辑
python manage.py shell

from users.models import User
from rest_framework_jwt.settings import api_settings # 引入jwt配置

# 获取载荷生成函数
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
# 获取token生成函数
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
# 生成载荷需要的字典数据
# 此处,拿数据库中的用户信息进行测试

user = User.objects.first()
payload = jwt_payload_handler(user)  # user用户模型对象
# 生成token
token = jwt_encode_handler(payload)

在用户注册或登录成功后,在序列化器中返回用户信息以后同时返回token即可。

1.2.8 后端实现登陆认证接口

Django REST framework-JWT为了方便开发者使用jwt提供了登录获取token的视图,开发者可以直接使用它绑定一个url地址即可。

在users/urls.py中绑定登陆视图

python
from django.urls import path
from rest_framework_jwt.views import obtain_jwt_token
from . import views

urlpatterns = [
    path("login/", obtain_jwt_token, name="login"),
]

# obtain_jwt_token实际上就是 rest_framework_jwt.views.ObtainJSONWebToken.as_view()

# 登录视图,获取access_token
# obtain_jwt_token = ObtainJSONWebToken.as_view()
# 刷新token视图,依靠旧的access_token生成新的access_token
# refresh_jwt_token = RefreshJSONWebToken.as_view()
# 验证现有的access_token是否有效
# verify_jwt_token = VerifyJSONWebToken.as_view()

接下来,我们可以通过postman来测试下功能,可以发送form表单,也可以发送json,username和password是必填字段:

image-20240215141711291

1.3 前端实现登陆功能

在登陆组件中找到登陆按钮,绑定点击事件,调用登录处理方法loginhandle。

components/Login.vue

html
<template>
    <div class="title">
        <span :class="{active:User.login_type===0}" @click="User.login_type=0">密码登录</span>
        <span :class="{active:User.login_type===1}" @click="User.login_type=1">短信登录</span>
    </div>
    <div class="inp" v-if="User.login_type===0">
        <input v-model="User.username" type="text" placeholder="用户名 / 手机号码" class="user">
        <input v-model="User.password" type="password" class="pwd" placeholder="密码">
        <div id="geetest1"></div>
        <div class="remember">
            <label>
                <input type="checkbox" class="no" name="a" v-model="User.remember"/>
                <span>记住密码</span>
            </label>
            <p>忘记密码</p>
        </div>
        <button class="login_btn" @click="LoginFunc">登录</button>
        <p class="go_login">没有账号 <span>立即注册</span></p>
    </div>
    <div class="inp" v-show="User.login_type==1">
        <input v-model="User.username" type="text" placeholder="手机号码" class="user">
        <input v-model="User.password" type="text" class="code" placeholder="短信验证码">
        <el-button id="get_code" type="primary">获取验证码</el-button>
        <button class="login_btn">登录</button>
        <p class="go_login">没有账号 <span>立即注册</span></p>
    </div>
</template>
vue
<script setup>
    import {reactive} from "vue";
    import User from "../utils/login";
    import {ElMessage} from 'element-plus'

    const LoginFunc = () => {

        if (User.username === '' || User.password === '') {
            ElMessage.error('用户名或密码不能为空')
            return;
        }
        User.login({
            "username": User.username,
            "password": User.password
        }).then(response => {
            ElMessage.success('登陆成功')
            if (User.remember) {
                sessionStorage.removeItem('token')
                localStorage.setItem('token', response.data['token'])
            } else {
                localStorage.removeItem('token')
                sessionStorage.setItem('token', response.data['token'])
            }
        }).catch(error => {
            console.log(error)
            ElMessage.success('用户名或密码错误')
        })
    }

</script>

在api中请求后端,api/user.js,代码:

javascript
import {ref, reactive} from 'vue'
import http from "./http";


const User = reactive({
    username: '',
    password: '',
    remember: false,
    login_type: 0,
  
    //login方法不是字典格式!!!!!,报错Uncaught TypeError: Cannot read properties of undefined (reading 'username')
    login() {
        return http.post('/users/login/',
            {
                "username": this.username,
                "password": this.password,
            }
        )
    }
})
export default User

解决elementplus显示错误提示框没有样式的问题。src/main.js,代码:

javascript
import { createApp } from 'vue'
import App from './App.vue'

import 'element-plus/dist/index.css';

import router from "./router/index.js";

createApp(App).use(router).mount('#app')

提交版本

bash
cd ~/Desktop/luffycity/luffycityapi
git add .
git commit -m "feature:客户端请求登陆功能基本实现"
git push origin feature/user

1.3.1 前端保存jwt

我们保存在浏览器的HTML5提供的本地存储对象中。

浏览器的本地存储提供了2个全局的js对象,给我们用于保存数据的,分别是sessionStorage 和 localStorage :

  • sessionStorage 会话存储,浏览器关闭即数据丢失。
  • localStorage 永久存储,长期有效,浏览器关闭了也不会丢失。

我们可以通过浏览器提供的Application调试选项中的界面查看到保存在本地存储的数据。

image-20240216132049733

注意:不同的域名或IP下的数据,互不干扰的,相互独立,也调用或访问不了其他域名下的数据。

sessionStorage和localStorage提供的操作一模一样,基本使用:

js
// 添加/修改数据
sessionStorage.setItem("变量名","变量值")
// 简写:sessionStorage.变量名 = 变量值

// 读取数据
sessionStorage.getItem("变量名")
// 简写:sessionStorage.变量名

// 删除一条数据
sessionStorage.removeItem("变量名")
// 清空所有数据
sessionStorage.clear()  // 慎用,会清空当前域名下所有的存储在本地的数据



// 添加/修改数据
localStorage.setItem("变量名","变量值")
// 简写:localStorage.变量名 = 变量值

// 读取数据
localStorage.getItem("变量名")
// 简写:localStorage.变量名

// 删除数据
localStorage.removeItem("变量名")
// 清空数据
localStorage.clear()  // 慎用,会清空当前域名下所有的存储在本地的数据

实现代码和1.3一样

提交版本

bash
cd ~/Desktop/luffycity
git add .
git commit -m "feature:客户端使用本地存储保存token"
git push origin feature/user

1.3.2 首页登录成功以后关闭登录弹窗

方法一:登陆弹窗

在components/Login.vue中,基于emit发送自定义事件通知父组件关闭当前登录窗口。components/Login.vue,代码:

vue
<script setup>
import user from "../api/user"
import { ElMessage } from 'element-plus'
const emit = defineEmits(["successhandle",])

const loginhandler = ()=>{
  // 登录处理
  if(user.username.length<1 || user.password.length<1){
    // 错误提示
    ElMessage.error('错了哦,用户名或密码不能为空!');
    return false // 在函数/方法中,可以阻止代码继续往下执行
  }

  // 发送请求
  user.user_login({
    username: user.username,
    password: user.password
  }).then(response=>{
    // 保存token,并根据用户的选择,是否记住密码
    localStorage.removeItem("token")
    sessionStorage.removeItem("token")
    if(user.remember){ // 判断是否记住登录状态
      // 记住登录
      localStorage.token = response.data.token
    }else{
      // 不记住登录,关闭浏览器以后就删除状态
      sessionStorage.token = response.data.token
    }
    // 保存token,并根据用户的选择,是否记住密码
    // 成功提示
    ElMessage.success("登录成功!")
    // 关闭登录弹窗,对外发送一个登录成功的信息
    user.account = ""
    user.password = ""
    user.mobile = ""
    user.code = ""
    user.remember = false
    emit("successhandle")

  }).catch(error=>{
    ElMessage.error("登录异常!")
  })
}

</script>

在首页中是通过Header子组件调用的component/Login.vue,所以我们需要在Header子组件中监听自定义事件shutFunc并关闭登陆弹窗即可。components/Header.vue,代码:

vue
<el-dialog v-model="showLogin" >
        <Login @shutDialog="shutFunc"></Login>   //给定义的emit事件绑定函数
    </el-dialog>
vue
<script setup>
    import nav from "../utils/getNav";
    import {ref} from 'vue'

    let showLogin = ref(false);

    nav.getHeader().then(response => {
        nav.headerList = response.data
    }).catch(error => {
        console.log(error)
    })
    const shutFunc = () => {

        showLogin.value = false;   // .value = false !!! Value !!! 
    }
    
</script>

方法二:独立的登陆页面:

views/Login.vue登陆页面中,则监听Login子组件登陆成功的自定义事件以后直接路由跳转到首页即可。views/Login.vue,代码:

vue
<template>
	<div class="login box">
		<img src="../assets/Loginbg.3377d0c.jpg" alt="">
		<div class="login">
			<div class="login-title">
				<img src="../assets/logo.png" alt="">
				<p>帮助有志向的年轻人通过努力学习获得体面的工作和生活!</p>
			</div>
      <div class="login_box">
          <Login @successhandle="login_success"></Login>
      </div>
		</div>
	</div>
</template>
vue
<script setup>
import Login from "../components/Login.vue"
import router from "../router";

// 用户登录成功以后的处理
const login_success = ()=>{
  // 跳转到首页
  router.push("/");
}

</script>

提交版本

bash
cd ~/Desktop/luffycity
git add .
git commit -m "feature:客户端登陆成功以后关闭窗口或登陆页面"
git push origin feature/user

1.3.3 自定义载荷

默认返回值的token只有username和user_id以及email,我们如果还需在客户端页面中显示当前登陆用户的其他信息(例如:头像),则可以把额外的用户信息添加到jwt的返回结果中。通过修改该视图的返回值可以完成我们的需求。

在utils/authenticate.py 中,创建jwt_payload_handler函数重写返回值。

python
from rest_framework_jwt.utils import jwt_payload_handler as payload_handler


def jwt_payload_handler(user):
    """
    自定义载荷信息
    :params user  用户模型实例对象
    """
    # 先让jwt模块生成自己的载荷信息
    payload = payload_handler(user)
    # 追加自己要返回的内容
    if hasattr(user, 'avatar'):
        payload['avatar'] = user.avatar.url if user.avatar else ""
    if hasattr(user, 'nickname'):
        payload['nickname'] = user.nickname

    if hasattr(user, 'money'):
        payload['money'] = float(user.money)
    if hasattr(user, 'credit'):
        payload['credit'] = user.credit

    return payload

修改settings/dev.py配置文件

python
import datetime
# jwt认证相关配置项
JWT_AUTH = {
    # 设置jwt的有效期
    # 如果内部站点,例如:运维开发系统,OA,往往配置的access_token有效期基本就是15分钟,30分钟,1~2个小时
    'JWT_EXPIRATION_DELTA': datetime.timedelta(weeks=1), # 一周有效,
    # 自定义载荷
    'JWT_PAYLOAD_HANDLER': 'utils.authenticate.jwt_payload_handler',
}

解决atob(token)报错的DOMException: Failed to execute 'atob' on 'Window': The string to be decoded is not correctly encoded问题:

jwt的3段一起,没发解码,切割后把第二段有效信息拿出来解码:

js

token="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6InJvb3QiLCJleHAiOjE3MDg2NzY4MzIsImVtYWlsIjoiMTc4NjYyODUzMkBxcS5jb20iLCJhdmF0YXIiOiIiLCJuaWNrbmFtZSI6ImRhaG9uZyIsIm1vbmV5IjowLjAsImNyZWRpdCI6MH0.sdv2KQ07hW9-WLHuK2D764yFcAqYqlgJFBVb5wZFC1g
"   
let message  =  token.split('.')[1]
console.log(atob(message));

提交版本

bash
cd ~/Desktop/luffycity
git add .
git commit -m "feature:服务端重写jwt的自定义载荷生成函数增加token载荷信息"
git push origin feature/user

1.3.4 多条件登录

JWT扩展的登录视图,在收到用户名与密码时,也是调用Django的认证系统中提供的**authenticate()**来检查用户名与密码是否正确。

我们可以通过修改Django认证系统的认证后端(主要是authenticate方法)来支持登录账号既可以是用户名也可以是手机号。

修改Django认证系统的认证后端需要继承django.contrib.auth.backends.ModelBackend,并重写authenticate方法。

我们想要让用户既可以以用户名登录,也可以以手机号登录,那么对于authenticate方法而言,username参数即表示用户名或者手机号。

重写authenticate方法的思路:

  1. 根据username参数查找用户User对象,username参数可能是用户名,也可能是手机号
  2. 若查找到User对象,调用User对象的check_password方法检查密码是否正确

在utils/authenticate.py中重新编写CustomAuthBackend类:

python
from rest_framework_jwt.utils import jwt_payload_handler as payload_handler
from django.contrib.auth.backends import ModelBackend, UserModel
from django.db.models import Q


def jwt_payload_handler(user):
    """
    自定义载荷信息
    :params user  用户模型实例对象
    """
    # 先让jwt模块生成自己的载荷信息
    payload = payload_handler(user)
    # 追加自己要返回的字段内容
    if hasattr(user, 'avatar'):
        payload['avatar'] = user.avatar.url if user.avatar else ""
    if hasattr(user, 'nickname'):
        payload['nickname'] = user.nickname
    if hasattr(user, 'money'):
        payload['money'] = float(user.money)
    if hasattr(user, 'credit'):
        payload['credit'] = user.credit

    return payload


def get_user_by_username(username):

    """
    根据帐号信息获取user模型实例对象
    :param account: 账号信息,可以是用户名,也可以是手机号,甚至其他的可用于识别用户身份的字段信息
    :return: User对象 或者 None
    """
    user = UserModel.objects.filter(Q(mobile=username) | Q(username=username) | Q(email=username)).first()
    return user


class CustomAuthBackend(ModelBackend):
    """
    自定义用户认证类[实现多条件登录]
    """

    def authenticate(self, request, username=None, password=None, **kwargs):
        '''
        :param username: 是前段username那一栏传过来的数据,可能为用户名或手机号。
        '''
        if username is None:
            username = kwargs.get(UserModel.USERNAME_FIELD)

        if username is None or password is None:
            return
            # 根据用户名信息useranme获取账户信息
        user = get_user_by_username(username)
        if user and user.check_password(password) and self.user_can_authenticate(user):
            return user

在配置文件settings/dev.py中告知Django使用我们自定义的认证后端,注意不是给drf添加设置。

python
# django自定义认证
AUTHENTICATION_BACKENDS = ['utils.authenticate.CustomAuthBackend', ]

提交版本

bash
cd <项目路>
git add .
git commit -m "feature:服务端实现jwt多条件登陆认证"
git push origin feature/user

1.4 客户端实现用户登陆状态的判断

components/Header.vue,更新代码:

vue
<template>
    <div class="header-box">
        <div class="header">
            <div class="content">
                <div class="logo">
                    <router-link to="/"><img src="../assets/logo.svg" alt=""></router-link>
                </div>
                <ul class="nav">
                    <li v-for="header in nav.headerList">
                        <a :href="header.link" v-if="header.is_http">{{header.name}}</a>
                        <router-link :to="header.link" v-else>{{header.name}}</router-link>
                    </li>
                </ul>
                <div class="search-warp">
                    <div class="search-area">
                        <input class="search-input" placeholder="请输入关键字..." type="text" autocomplete="off">
                        <div class="hotTags">
                            <router-link to="/search/?words=Vue" target="_blank" class="">Vue</router-link>
                            <router-link to="/search/?words=Python" target="_blank" class="last">Python</router-link>
                        </div>
                    </div>
                    <div class="showhide-search" data-show="no"><img class="imv2-search2" src="../assets/search.svg"/>
                    </div>
                </div>
                <div class="login-bar" v-show="!User.is_login">
                    <div class="shop-cart full-left">
                        <img src="../assets/cart.svg" alt=""/>
                        <span><router-link to="/cart">购物车</router-link></span>
                    </div>
                    <div class="login-box full-left">
                        <span @click="showLogin=true">登录</span>
                        &nbsp;/
                        <span>注册</span>
                    </div>
                </div>
                <div class="login-bar logined-bar" v-show="User.is_login">
                    <div class="shop-cart ">
                        <img src="../assets/cart.svg" alt=""/>
                        <span><router-link to="/cart">购物车</router-link></span>
                    </div>
                    <div class="login-box ">
                        <router-link to="">我的课堂</router-link>
                        <el-dropdown>
                <span class="el-dropdown-link">
                  <el-avatar class="avatar" size="50"
                             src="https://fuguangapi.oss-cn-beijing.aliyuncs.com/avatar.jpg"></el-avatar>
                </span>
                            <template #dropdown>
                                <el-dropdown-menu>
                                    <el-dropdown-item :icon="UserFilled">学习中心</el-dropdown-item>
                                    <el-dropdown-item :icon="List">订单列表</el-dropdown-item>
                                    <el-dropdown-item :icon="Setting">个人设置</el-dropdown-item>
                                    <el-dropdown-item :icon="Position">注销登录</el-dropdown-item>
                                </el-dropdown-menu>
                            </template>
                        </el-dropdown>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <el-dialog v-model="showLogin">
        <Login @shutDialog="shutFunc"></Login>
    </el-dialog>

</template>
vue
<script setup>
    import nav from "../utils/getNav";
    import {ref} from 'vue';
    import User from "../utils/login";
    import {UserFilled, List, Setting, Position} from '@element-plus/icons-vue';

    let showLogin = ref(false);

    nav.getHeader().then(response => {
        nav.headerList = response.data
    }).catch(error => {
        console.log(error)
    })
    const shutFunc = () => {
        showLogin.value = false;
    }

</script>
css
<style scoped>
    .header-box {
        height: 72px;
    }

    .header {
        width: 100%;
        height: 72px;
        box-shadow: 0 0.5px 0.5px 0 #c9c9c9;
        position: fixed;
        top: 0;
        left: 0;
        right: 0;
        margin: auto;
        z-index: 99;
        background: #fff;
    }

    .header .content {
        max-width: 1366px;
        width: 100%;
        margin: 0 auto;
    }

    .header .content .logo a {
    }

    .header .content .logo {
        height: 72px;
        width: 120px;
        line-height: 72px;
        margin: 0 20px;
        float: left;
        cursor: pointer; /* 设置光标的形状为爪子 */
        margin-left: 80px;
    }

    .header .content .logo img {
        vertical-align: middle;
        margin: -40px;
    }

    .header .nav li {
        float: left;
        height: 80px;
        line-height: 80px;
        margin-right: 30px;
        font-size: 16px;
        color: #4a4a4a;
        cursor: pointer;
    }

    .header .nav li span {
        padding-bottom: 16px;
        padding-left: 5px;
        padding-right: 5px;
    }

    .header .nav li span a {
        display: inline-block;
    }

    .header .nav li .this {
        color: #4a4a4a;
        border-bottom: 4px solid #ffc210;
    }

    .header .nav li:hover span {
        color: #000;
    }

    /*首页导航全局搜索*/
    .search-warp {
        position: relative;
        float: left;
        margin-left: -10px;
    }

    .search-warp .showhide-search {
        width: 20px;
        height: 24px;
        text-align: right;
        position: absolute;
        display: inline-block;
        right: 0;
        bottom: 24px;
        padding: 0 8px;
        border-radius: 18px;
    }

    .search-warp .showhide-search i {
        display: block;
        height: 24px;
        color: #545C63;
        cursor: pointer;
        font-size: 18px;
        line-height: 24px;
        width: 20px;
    }

    .search-area {
        float: right;
        position: relative;
        height: 40px;
        padding-right: 36px;
        border-bottom: 1px solid rgba(255, 255, 255, 0.4);
        zoom: 1;
        background: #F3F5F6;
        border-radius: 4px;
        margin: 16px 0;
        width: 324px;
        box-sizing: border-box;
        font-size: 0;
        -webkit-transition: width 0.3s;
        -moz-transition: width 0.3s;
        transition: width 0.3s;
    }

    .search-area .search-input {
        padding: 8px 12px;
        font-size: 14px;
        color: #9199A1;
        line-height: 24px;
        height: 40px;
        width: 100%;
        float: left;
        border: 0;
        -webkit-transition: background-color 0.3s;
        -moz-transition: background-color 0.3s;
        transition: background-color 0.3s;
        background-color: transparent;
        -moz-box-sizing: border-box;
        -webkit-box-sizing: border-box;
        -ms-box-sizing: border-box;
        box-sizing: border-box;
    }

    .search-area .search-input.w100 {
        width: 100%;
    }

    .search-area .hotTags {
        display: inline-block;
        position: absolute;
        top: 0;
        right: 32px;
    }

    .search-area .hotTags a {
        display: inline-block;
        padding: 4px 8px;
        height: 16px;
        font-size: 14px;
        color: #9199A1;
        line-height: 16px;
        margin-top: 8px;
        max-width: 60px;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }

    .search-area .hotTags a:hover {
        color: #F21F1F;
    }

    .search-area input::-webkit-input-placeholder {
        color: #A6A6A6;
    }

    .search-area input::-moz-placeholder {
        /* Mozilla Firefox 19+ */
        color: #A6A6A6;
    }

    .search-area input:-moz-placeholder {
        /* Mozilla Firefox 4 to 18 */
        color: #A6A6A6;
    }

    .search-area input:-ms-input-placeholder {
        /* Internet Explorer 10-11 */
        color: #A6A6A6;
    }

    .search-area .btn_search {
        float: left;
        cursor: pointer;
        width: 30px;
        height: 38px;
        text-align: center;
        -webkit-transition: background-color 0.3s;
        -moz-transition: background-color 0.3s;
        transition: background-color 0.3s;
    }

    .search-area .search-area-result {
        position: absolute;
        left: 0;
        top: 57px;
        width: 300px;
        margin-bottom: 20px;
        border-top: none;
        background-color: #fff;
        box-shadow: 0 8px 16px 0 rgba(7, 17, 27, 0.2);
        font-size: 12px;
        overflow: hidden;
        display: none;
        z-index: 800;
        border-bottom-right-radius: 8px;
        border-bottom-left-radius: 8px;
    }

    .search-area .search-area-result.hot-hide {
        top: 47px;
    }

    .search-area .search-area-result.hot-hide .hot {
        display: none;
    }

    .search-area .search-area-result.hot-hide .history {
        border-top: 0;
    }

    .search-area .search-area-result h2 {
        font-size: 12px;
        color: #1c1f21;
        line-height: 12px;
        margin-bottom: 8px;
        font-weight: 700;
    }

    .search-area .search-area-result .hot {
        padding: 12px 0 8px 12px;
        box-sizing: border-box;
    }

    .search-area .search-area-result .hot .hot-item {
        background: rgba(84, 92, 99, 0.1);
        border-radius: 12px;
        padding: 4px 12px;
        line-height: 16px;
        margin-right: 4px;
        margin-bottom: 4px;
        display: inline-block;
        cursor: pointer;
        font-size: 12px;
        color: #545c63;
    }

    .search-area .search-area-result .history {
        border-top: 1px solid rgba(28, 31, 33, 0.1);
        box-sizing: border-box;
    }

    .search-area .search-area-result .history li {
        height: 40px;
        line-height: 40px;
        padding: 0 10px;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
        color: #787d82;
        cursor: pointer;
    }

    .search-area .search-area-result .history li:hover,
    .search-area .search-area-result .history li .light {
        color: #1c1f21;
        background-color: #edf0f2;
    }


    .header .login-bar {
        margin-top: 20px;
        height: 80px;
        float: right;
        margin-right: 80px;
    }

    .header .login-bar .shop-cart {
        float: left;
        margin-right: 20px;
        border-radius: 17px;
        background: #f7f7f7;
        cursor: pointer;
        font-size: 14px;
        height: 28px;
        width: 88px;
        line-height: 32px;
        text-align: center;
    }

    .header .login-bar .shop-cart:hover {
        background: #f0f0f0;
    }

    .header .login-bar .shop-cart img {
        width: 15px;
        margin-right: 4px;
        margin-left: 6px;
    }

    .header .login-bar .shop-cart span {
        margin-right: 6px;
    }

    .header .login-bar .login-box {
        float: left;
        height: 28px;
        line-height: 30px;
    }

    .header .login-bar .login-box span {
        color: #4a4a4a;
        cursor: pointer;
    }

    .header .login-bar .login-box span:hover {
        color: #000000;
    }

    /* 登陆后状态栏 */
    .logined-bar {
        margin-top: 0;
        height: 72px;
        line-height: 72px;
    }

    .header .logined-bar .shop-cart {
        height: 32px;
        line-height: 32px;
    }

    .logined-bar .login-box {
        height: 72px;
        line-height: 72px;
        position: relative;
    }

    .logined-bar .el-avatar {
        float: right;
        width: 50px;
        height: 50px;
        position: absolute;
        top: -10px;
        left: 10px;
        transition: transform .5s ease-in .1s;
    }

    .logined-bar .el-avatar:hover {
        transform: scale(1.3);
    }

</style>

如果图标没有显示,可以采用安装以下组件:

bash
yarn add @element-plus/icons-vue

1.4.1 使用Vuex保存用户登录状态并判断是否在登陆栏显示用户信息

Vuex是Vue框架生态的一环,用于实现全局数据状态的统一管理。

官方地址:https://next.vuex.vuejs.org/zh/index.html

bash
cd ~/Desktop/luffycity/luffycityweb
# 在客户端项目根目录下执行安装命令
yarn add vuex@next

Vuex初始化,是在src目录下创建store目录,store目录下创建index.js文件对vuex进行初始化:

javascript
import {createStore} from "vuex"

// 实例化一个vuex存储库
export default createStore({
    state () {  // 数据存储位置,相当于组件中的data
        return {
          user: {

          }
        }
    },
    mutations: { // 操作数据的方法,相当于methods
        login (state, user) {  // state 就是上面的state   state.user 就是上面的数据
          state.user = user
        }
    }
})

main.js中注册vuex,代码:

javascript
import { createApp } from 'vue'
import App from './App.vue'
import router from "./router";
import store from "./store"

import 'element-plus/theme-chalk/index.css'

createApp(App).use(router).use(store).mount('#app')

在components/Login.vue子组件中登录成功以后,记录用户信息到vuex中。

vue
<script setup>
    import {defineEmits} from "vue";
    import User from "../utils/login";
    import {ElMessage} from 'element-plus'
    import {useStore} from 'vuex'

    const emits = defineEmits(['shutDialog'])
    let store = useStore()


    const LoginFunc = () => {
        if (User.username === '' || User.password === '') {
            ElMessage.error('用户名或密码不能为空')
            return;
        }
        User.login({
            "username": User.username,
            "password": User.password
        }).then(response => {
            

            //记住密码
            ElMessage.success('登陆成功')

            //清空用户数据
            User.username = ''
            User.password = ''
            User.remember = false

            //登陆成功,关掉登陆框
            emits('shutDialog')

            //使用vuex记住登陆状态和用户信息

            let payload = response.data['token'].split('.')[1]
            let payload_data = JSON.parse(atob(payload))
            store.commit('login', payload_data)

        }).catch(error => {
            console.log(error)
            ElMessage.error('用户名或密码错误')
        })
    }

</script>

记录下来了以后,我们就可以直接components/Header.vue中读取Vuex中的用户信息。

vue
<template>
    <div class="header-box">
        <div class="header">
            <div class="content">
                <div class="logo">
                    <router-link to="/"><img src="../assets/logo.svg" alt=""></router-link>
                </div>
                <ul class="nav">
                    <li v-for="header in nav.headerList">
                        <a :href="header.link" v-if="header.is_http">{{header.name}}</a>
                        <router-link :to="header.link" v-else>{{header.name}}</router-link>
                    </li>


                </ul>
                <div class="search-warp">
                    <div class="search-area">
                        <input class="search-input" placeholder="请输入关键字..." type="text" autocomplete="off">
                        <div class="hotTags">
                            <router-link to="/search/?words=Vue" target="_blank" class="">Vue</router-link>
                            <router-link to="/search/?words=Python" target="_blank" class="last">Python</router-link>
                        </div>
                    </div>
                    <div class="showhide-search" data-show="no"><img class="imv2-search2" src="../assets/search.svg"/>
                    </div>
                </div>
                <div class="login-bar" v-show="!store.state.user.user_id">
                    <div class="shop-cart full-left">
                        <img src="../assets/cart.svg" alt=""/>
                        <span><router-link to="/cart">购物车</router-link></span>
                    </div>
                    <div class="login-box full-left">

                        <span @click="showLogin=true">登录</span>
                        &nbsp;/
                        <span>注册</span>
                    </div>
                </div>
                <div class="login-bar logged-in-bar" v-show="store.state.user.user_id">
                    <div class="shop-cart ">
                        <img src="../assets/cart.svg" alt=""/>
                        <span><router-link to="/cart">购物车</router-link></span>
                    </div>
                    <div class="login-box ">
                        <router-link to="">我的课堂</router-link>
                        <el-dropdown>
                <span class="el-dropdown-link">
                  <el-avatar class="avatar" size="50"
                             src="https://fuguangapi.oss-cn-beijing.aliyuncs.com/avatar.jpg"></el-avatar>
                </span>
                            <template #dropdown>
                                <el-dropdown-menu>
                                    <router-link :to="{name:'User'}">
                                        <el-dropdown-item :icon="UserFilled">个人中心</el-dropdown-item>
                                    </router-link>

                                    <el-dropdown-item :icon="List">订单列表</el-dropdown-item>
                                    <el-dropdown-item :icon="Setting">设 置</el-dropdown-item>
                                    <el-dropdown-item :icon="Position">注销登录</el-dropdown-item>
                                </el-dropdown-menu>
                            </template>
                        </el-dropdown>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <el-dialog v-model="showLogin">
        <Login @shutDialog="shutFunc"></Login>
    </el-dialog>

</template>
vue
<script setup>
    import nav from "../utils/getNav";
    import {ref} from 'vue';
    import {UserFilled, List, Setting, Position} from '@element-plus/icons-vue';
    import {useStore} from 'vuex'

    let showLogin = ref(false);
    let store = useStore()


    nav.getHeader().then(response => {
        nav.headerList = response.data
    }).catch(error => {
        console.log(error)
    })
    const shutFunc = () => {

        showLogin.value = false;
    }

</script>

因为vuex默认是保存数据在内存中的,所以基于浏览器开发的网页,如果在F5刷新网页时会存在数据丢失的情况。所以我们可以把store数据永久存储到localStorage中。这里就需要使用插件vuex-persistedstate来实现。

在前端项目的根目录下执行安装命令

bash
cd ~/Desktop/luffycity/luffycityweb
yarn add vuex-persistedstate

在vuex的store/index.js文件中导入此插件。

js
import {createStore} from "vuex"
import createPersistedState from "vuex-persistedstate"

// 实例化一个vuex存储库
export default createStore({
    // 调用永久存储vuex数据的插件,localstorage里会多一个名叫vuex的Key,里面就是vuex的数据
    plugins: [createPersistedState()],
    state(){  // 相当于组件中的data,用于保存全局状态数据
        return {
            user: {}
        }
    },
    getters: {
        getUserInfo(state){
            // 从jwt的载荷中提取用户信息
            let now = parseInt( (new Date() - 0) / 1000 );
            if(state.user.exp === undefined) {
                // 没登录
                state.user = {}
                localStorage.token = null;
                sessionStorage.token = null;
                return null
            }

            if(parseInt(state.user.exp) < now) {
                // 过期处理
                state.user = {}
                localStorage.token = null;
                sessionStorage.token = null;
                return null
            }
            return state.user;
        }
    },
    mutations: { // 相当于组件中的methods,用于操作state全局数据
        login(state, payload){
            state.user = payload; // state.user 就是上面声明的user
        }
    }
})

完成了登录功能以后,我们要防止用户翻墙访问需要认证身份的页面时,可以基于vue-router的导航守卫(路由守卫)来完成。

src/router/index.js,代码:

js
import {createRouter, createWebHistory} from 'vue-router'
import store from '../store/index'

// 路由列表
const routes = [
    {
        meta: {
            title: "luffy2.0-登陆首页",
            keepAlive: true
        },
        path: '/home/',         // uri访问地址
        name: "Home",
        component: () => import("../views/Home.vue")  // 记得()=>!!!
    },
    {
        meta: {
            title: "luffy2.0-登陆首页",
            keepAlive: true
        },
        path: '/login/',         // uri访问地址
        name: "Login",
        component: () => import("../views/Login.vue")  // 记得()=>!!!
    },

    {
        path: '/test/',         // uri访问地址
        name: "test",
        component: () => import("../views/test.vue")  // 记得()=>!!!
    },
    {
        meta: {
            title: "luffy2.0-个人中心",
            keepAlive: true,
            authorization: true
        },
        path: '/user/',         // uri访问地址
        name: "User",
        component: () => import("../views/User.vue")  // 记得()=>!!!
    },


]

// 路由对象实例化
const router = createRouter({
    history: createWebHistory(),
    routes: routes,
});
router.beforeEach((to, from, next) => {
    if (to.meta.authorization && !store.getters.getUserInfo) {
        next({name: 'Login'})
    } else {
        next()
    }
})

// 暴露路由对象
export default router

src/views/User.vue,代码:

vue
<template>
用户中心
</template>

<script>
export default {
  name: "User"
}
</script>

<style scoped>

</style>

提交版本

bash
cd ~/Desktop/luffycity
git add .
git commit -m "feature:客户端基于vuex存储本地全局数据并判断登陆状态"
git push origin feature/user

1.5 退出登录功能

在vuex的store/index.js中编写一个登录注销的方法logout,代码:

vue
import {createStore} from "vuex"
import createPersistedState from "vuex-persistedstate"

// 实例化一个vuex存储库
export default createStore({
    // 调用永久存储vuex数据的插件,localstorage里会多一个名叫vuex的Key,里面就是vuex的数据
    plugins: [createPersistedState()],
    state(){  // 相当于组件中的data,用于保存全局状态数据
        return {
            user: {}
        }
    },
    getters: {
        getUserInfo(state){
            let now = parseInt( (new Date() - 0) / 1000 );
            if(state.user.exp === undefined) {
                // 没登录
                state.user = {}
                localStorage.token = null;
                sessionStorage.token = null;
                return null
            }

            if(parseInt(state.user.exp) < now) {
                // 过期处理
                state.user = {}
                localStorage.token = null;
                sessionStorage.token = null;
                return null
            }
            return state.user;
        }
    },
    mutations: { // 相当于组件中的methods,用于操作state全局数据
        login(state, payload){
            state.user = payload; // state.user 就是上面声明的user
        },
        logout(state){ // 退出登录
            state.user = {}
            localStorage.token = null;
            sessionStorage.token = null;
        }
    }
})

在用户点击头部登录栏的注销登录时绑定登录注销操作。components/Header.vue,代码:

vue
<el-dropdown-item :icon="Position" @click="logout">注销登录</el-dropdown-item>
vue
<script setup>
import {UserFilled, List, Setting, Position} from '@element-plus/icons-vue'
import Login from "./Login.vue"
import nav from "../api/nav";
import {reactive} from "vue";

import {useStore} from "vuex"
const store = useStore()

const state = reactive({
  show_login: false,
})

// 请求头部导航列表
nav.get_header_nav().then(response=>{
  nav.header_nav_list = response.data
})

// 用户登录成功以后的处理
const login_success = ()=>{
  state.show_login = false
}

// 登录注销的处理
const logout = ()=>{
  store.commit("logout");
}


</script>

提交版本

bash
cd /home/moluo/Desktop/luffycity
git add .
git commit -m "客户端注销登录状态"
git push

1.6 在登录认证中接入防水墙验证码

1.获得各种密钥

使用腾讯提供的验证码

登录腾讯云天御:https://cloud.tencent.com/product/captcha

1.进入控制台,建立验证,获得CaptchaAppid和AppSecretKey:

image-20240219131055845

2.右上角头像,访问密钥,获得api密钥,SecretId和SecretKey:

image-20240219131239647

2.前端获取显示并校验验证码

image-20240219131431121

components/Login.vue,代码:

vue
<template>
    <div class="title">
        <span :class="{active:User.login_type===0}" @click="User.login_type=0">密码登录</span>
        <span :class="{active:User.login_type===1}" @click="User.login_type=1">短信登录</span>
    </div>
    <div class="inp" v-if="User.login_type===0">
        <input v-model="User.username" type="text" placeholder="用户名 / 手机号码" class="user">
        <input v-model="User.password" type="password" class="pwd" placeholder="密码">
        <div id="geetest1"></div>
        <div class="remember">
            <label>
                <input type="checkbox" class="no" name="a" v-model="User.remember"/>
                <span>记住密码</span>
            </label>
            <p>忘记密码</p>
        </div>
        <button class="login_btn" @click="showCaptcha">登录</button>

        <p class="go_login">没有账号
            <router-link :to="{name:'Register'}"><span>立即注册</span></router-link>
        </p>
    </div>
    <div class="inp" v-show="User.login_type==1">
        <input v-model="User.username" type="text" placeholder="手机号码" class="user">
        <input v-model="User.password" type="text" class="code" placeholder="短信验证码">
        <el-button id="get_code" type="primary">获取验证码</el-button>
        <button class="login_btn">登录</button>
        <p class="go_login">没有账号 <span>立即注册</span></p>
    </div>
</template>
vue
<script setup>

    import {defineEmits} from "vue";
    import User from "../utils/login";
    import {ElMessage} from 'element-plus'
    import {useStore} from 'vuex'
    import '../utils/TCaptcha.js'

    const emits = defineEmits(['shutDialog'])
    let store = useStore()

    function LoginFunc(ticket, randstr, appid) {
        if (User.username === '' || User.password === '') {
            ElMessage.error('用户名或密码不能为空')
            return;
        }
        User.login({
            "ticket": ticket,
            "randstr": randstr,
            "appid": appid,
        }).then(response => {
            console.log(response.data, 'response')

            if (response.data.token) {
                //记住密码
                ElMessage.success('登陆成功')
                //清空用户数据
                User.username = ''
                User.password = ''
                User.remember = false
                //登陆成功,关掉登陆框
                emits('shutDialog')
                //使用vuex记住登陆状态和用户信息
                let payload = response.data['token'].split('.')[1]
                let payload_data = JSON.parse(atob(payload))
                store.commit('login', payload_data)
            }
        }).catch(error => {
            console.log(error, 'error')
            ElMessage.error('用户名或密码错误')
        })
    }
  
    // 定义回调函数
    function callback(res) {
        if (res.ret === 0) {
            let ticket = res.ticket
            let randstr = res.randstr
            let appid = res.appid
            //拿到票据后,后台验证票据+登陆
            LoginFunc(ticket, randstr, appid)
        }
    }

    function loadErrorCallback() {
        var appid = '190269980';
        // 生成容灾票据或自行做其它处理
        var ticket = 'terror_1001_' + appid + '_' + Math.floor(new Date().getTime() / 1000);
        callback({
            ret: 0,
            randstr: '@' + Math.random().toString(36).substr(2),
            ticket: ticket,
            errorCode: 1001,
            errorMessage: 'jsload_error'
        });
    }

    const showCaptcha = () => {
        try {
            var captcha = new TencentCaptcha('190269980', callback, {});
            captcha.show();
        } catch (error) {
            loadErrorCallback();
        }
    }

</script>

src/api/user.js,代码:

js
import {ref, reactive} from 'vue'
import http from "./http";

const User = reactive({
    username: '',
    password: '',
    remember: false,
    login_type: 0,
    
    login(params) {
        return http.post('/users/login/',
            {
                "username": this.username,
                "password": this.password,
                "ticket": params.ticket,
                "randstr": params.randstr,
                "appid": params.appid,
            }
        )
    }
})
export default User

3. 服务端登录功能中校验验证码结果

Web客户端接入地址:https://cloud.tencent.com/document/product/1110/36841

安装腾讯云PythonSKD扩展模块到项目中

bash
pip install --upgrade tencentcloud-sdk-python

生成代码的API操作界面:https://console.cloud.tencent.com/api/explorer?Product=cvm&Version=2017-03-12&Action=DescribeRegions

utils/TCaptcha,封装一个操作腾讯云SDK的API工具类,代码:

python
import json
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
from tencentcloud.captcha.v20190722 import captcha_client, models

CaptchaAppId = '190269980'
AppSecretKey = 'ays6Rr4rUp00yJGdeCmaWtmpo'
SECRET_ID = 'AKIDd8UcVBj7HG3eHjWJubBZorgJkxxrY7Uf'
SECRET_KEY = 'Po8W8iB476sjXMHEBEJJZlvGaopkUGuW'

class TCaptcha(object):
    def __init__(self):
        self.SECRET_ID = SECRET_ID
        self.SECRET_KEY = SECRET_KEY
        self.CaptchaAppId = CaptchaAppId
        self.AppSecretKey = AppSecretKey

    def verify(self, ticket, randstr, appid):

        try:
            cred = credential.Credential(self.SECRET_ID, self.SECRET_KEY)
            # 实例化一个http选项,可选的,没有特殊需求可以跳过
            httpProfile = HttpProfile()
            httpProfile.endpoint = "captcha.tencentcloudapi.com"

            # 实例化一个client选项,可选的,没有特殊需求可以跳过
            clientProfile = ClientProfile()
            clientProfile.httpProfile = httpProfile
            # 实例化要请求产品的client对象,clientProfile是可选的
            client = captcha_client.CaptchaClient(cred, "", clientProfile)

            # 实例化一个请求对象,每个接口都会对应一个request对象
            req = models.DescribeCaptchaResultRequest()
            params = {
                "CaptchaType": 9,
                "Ticket": ticket,
                "UserIp": "127.0.0.1",
                "Randstr": randstr,
                "CaptchaAppId": int(appid),
                "AppSecretKey": self.AppSecretKey
            }
            req.from_json_string(json.dumps(params))

            # 返回的resp是一个DescribeCaptchaResultResponse的实例,与请求对象对应
            resp = client.DescribeCaptchaResult(req)
            # 输出json格式的字符串回包
            print(resp.to_json_string(),'t')
            return resp.to_json_string()

        except TencentCloudSDKException as err:
            print(err,'t')
            return err

users/views.py,重写登陆视图,先校验验证码,接着再调用jwt原来提供的视图来校验用户账号信息,代码:

python
from rest_framework_jwt.views import ObtainJSONWebToken
from utils.TChapta import TCaptcha, TencentCloudSDKException
import json

class LoginAPIView(ObtainJSONWebToken):

    def post(self, request, *args, **kwargs):
        try:
            ticket = request.data.get('ticket')
            randstr = request.data.get('randstr')
            appid = request.data.get('appid')
            tcaptcha = TCaptcha()
            result = tcaptcha.verify(ticket, randstr, appid)
            result = json.loads(result)

            CaptchaCode = result.get('CaptchaCode', 'None')
            if CaptchaCode:
                print('ok')
                return super().post(request, *args, **kwargs)
            else:
                return TencentCloudSDKException

        except:
            return TencentCloudSDKException

users/urls.py,代码:

python
from django.urls import path
from . import views

urlpatterns = [
    path("login/", LoginAPIView.as_view(), name="login"),
]

提交版本

bash
cd /home/moluo/Desktop/luffycity
git add .
git commit -m "服务端重写登录视图实现验证码的操作结果验证"
git push

1.7 用户的注册认证

前端显示注册页面并调整首页头部和登陆页面的注册按钮的链接。

创建一个注册页面views/Register.vue,主要是通过登录窗口组件进行改成而成,组件代码:

vue
<template>
	<div class="login box">
		<img src="../assets/Loginbg.3377d0c.jpg" alt="">
		<div class="login">
			<div class="login-title">
				<img src="../assets/logo.svg" alt="">
				<p>帮助有志向的年轻人通过努力学习获得体面的工作和生活!</p>
			</div>
      <div class="login_box">
          <div class="title">
            <span class="active">用户注册</span>
          </div>
          <div class="inp">
            <input v-model="state.mobile" type="text" placeholder="手机号码" class="user">
            <input v-model="state.password" type="password" placeholder="登录密码" class="user">
            <input v-model="state.re_password" type="password" placeholder="确认密码" class="user">
            <input v-model="state.code"  type="text" class="code" placeholder="短信验证码">
            <el-button id="get_code" type="primary">获取验证码</el-button>
            <button class="login_btn">注册</button>
            <p class="go_login" >已有账号 <router-link to="/login">立即登录</router-link></p>
          </div>
      </div>
		</div>
	</div>
</template>

<script setup>
import {reactive, defineEmits} from "vue"
import { ElMessage } from 'element-plus'
import {useStore} from "vuex"
import "../utils/TCaptcha"

const store = useStore()

const state = reactive({
  password:"",    // 密码
  re_password: "",// 确认密码
  mobile: "",     // 手机号
  code: "",       // 验证码
})
</script>

<style scoped>
.box{
	width: 100%;
  height: 100%;
	position: relative;
  overflow: hidden;
}
.box img{
	width: 100%;
  min-height: 100%;
}
.box .login {
	position: absolute;
	width: 500px;
	height: 400px;
	left: 0;
  margin: auto;
  right: 0;
  bottom: 0;
  top: -438px;
}

.login-title{
     width: 100%;
    text-align: center;
}
.login-title img{
    width: 190px;
    height: auto;
}
.login-title p{
    font-size: 18px;
    color: #fff;
    letter-spacing: .29px;
    padding-top: 10px;
    padding-bottom: 50px;
}
.login_box{
    width: 400px;
    height: auto;
    background: #fff;
    box-shadow: 0 2px 4px 0 rgba(0,0,0,.5);
    border-radius: 4px;
    margin: 0 auto;
    padding-bottom: 40px;
    padding-top: 50px;
}
.title{
	font-size: 20px;
	color: #9b9b9b;
	letter-spacing: .32px;
	border-bottom: 1px solid #e6e6e6;
  display: flex;
  justify-content: space-around;
  padding: 0px 60px 0 60px;
  margin-bottom: 20px;
  cursor: pointer;
}
.title span.active{
	color: #4a4a4a;
}

.inp{
	width: 350px;
	margin: 0 auto;
}
.inp .code{
  width: 190px;
  margin-right: 16px;
}
#get_code{
 margin-top: 6px;
}
.inp input{
    outline: 0;
    width: 100%;
    height: 45px;
    border-radius: 4px;
    border: 1px solid #d9d9d9;
    text-indent: 20px;
    font-size: 14px;
    background: #fff !important;
}
.inp input.user{
    margin-bottom: 16px;
}
.inp .rember{
     display: flex;
    justify-content: space-between;
    align-items: center;
    position: relative;
    margin-top: 10px;
}
.inp .rember p:first-of-type{
    font-size: 12px;
    color: #4a4a4a;
    letter-spacing: .19px;
    margin-left: 22px;
    display: -ms-flexbox;
    display: flex;
    -ms-flex-align: center;
    align-items: center;
    /*position: relative;*/
}
.inp .rember p:nth-of-type(2){
    font-size: 14px;
    color: #9b9b9b;
    letter-spacing: .19px;
    cursor: pointer;
}

.inp .rember input{
    outline: 0;
    width: 30px;
    height: 45px;
    border-radius: 4px;
    border: 1px solid #d9d9d9;
    text-indent: 20px;
    font-size: 14px;
    background: #fff !important;
    vertical-align: middle;
    margin-right: 4px;
}

.inp .rember p span{
    display: inline-block;
  font-size: 12px;
  width: 100px;
}
.login_btn{
    cursor: pointer;
    width: 100%;
    height: 45px;
    background: #84cc39;
    border-radius: 5px;
    font-size: 16px;
    color: #fff;
    letter-spacing: .26px;
    margin-top: 30px;
    border: none;
    outline: none;
}
.inp .go_login{
    text-align: center;
    font-size: 14px;
    color: #9b9b9b;
    letter-spacing: .26px;
    padding-top: 20px;
}
.inp .go_login span{
    color: #84cc39;
    cursor: pointer;
}
</style>

客户端注册路由,src/router/index.js,代码:

javascript
import {createRouter, createWebHistory, createWebHashHistory} from 'vue-router'
import store from "../store";

// 路由列表
const routes = [
  {
    meta:{
        title: "luffy2.0-站点首页",
        keepAlive: true
    },
    path: '/',         // uri访问地址
    name: "Home",
    component: ()=> import("../views/Home.vue")
  },
  {
    meta:{
        title: "luffy2.0-用户登录",
        keepAlive: true
    },
    path:'/login',      // uri访问地址
    name: "Login",
    component: ()=> import("../views/Login.vue")
  },
  {
      meta:{
        title: "luffy2.0-用户注册",
        keepAlive: true
      },
      path: '/register',
      name: "Register",            // 路由名称
      component: ()=> import("../views/Register.vue"),         // uri绑定的组件页面
  },
  {
    meta:{
        title: "luffy2.0-个人中心",
        keepAlive: true,
        authorization: true,
    },
    path: '/user',
    name: "User",
    component: ()=> import("../views/User.vue"),
  },
]


// 路由对象实例化
const router = createRouter({
  // history, 指定路由的模式
  history: createWebHistory(),
  // 路由列表
  routes,
});

// 导航守卫
router.beforeEach((to, from, next)=>{
  document.title=to.meta.title
  // 登录状态验证
  if (to.meta.authorization && !store.getters.getUserInfo) {
    next({"name": "Login"})
  }else{
    next()
  }
})


// 暴露路由对象
export default router

修改首页头部的连接和登录窗口中登录和注册的链接。代码:

html
# components/Header.vue
<router-link to="/register">注册</router-link>
#components/Login.vue
<p class="go_login" >没有账号 <router-link to="/register">立即注册</router-link></p>

1.7.1 注册功能的实现流程

image-20240218112650967

综合上图所示,我们需要在服务端完成3个接口:

python
1. 验证手机号是否注册了
2. 发送验证码
3. 校验验证码,并保存用户提交的注册信息

所以,除了短信发送功能以外,其他2个接口功能,我们完全不需要依赖第三方,直接可以先实现了。

1.7.2 用户手机号码的校验

用户填写手机号的时候,我们可以监听手机号格式正确的情况下,通过ajax提前告诉用户手机号是否已经被注册了。

1.客户端监听手机号格式是否正确

src/views/register.vue:

vue
<template>
    <div class="login box">
        <img src="../assets/Loginbg.3377d0c.jpg" alt="">
        <div class="login">
            <div class="login-title">
                <img src="../assets/logo.svg" alt="">
                <p>帮助有志向的年轻人通过努力学习获得体面的工作和生活!</p>
            </div>
            <div class="login_box">
                <div class="title">
                    <span class="active">用户注册</span>
                </div>
                <div class="inp">
                    <input v-model="register.mobile" type="text" placeholder="手机号码" class="user">
                    <input v-model="register.password" type="password" placeholder="登录密码" class="user">
                    <input v-model="register.re_password" type="password" placeholder="确认密码" class="user">
                    <input v-model="register.code" type="text" class="code" placeholder="短信验证码">
                    <el-button id="get_code" type="primary">获取验证码</el-button>
                    <button class="login_btn">注册</button>
                    <p class="go_login">已有账号
                        <router-link to="/login">立即登录</router-link>
                    </p>
                </div>
            </div>
        </div>
    </div>
</template>
vue
<script setup>
    import {reactive, defineEmits, watch} from "vue"
    import {ElMessage} from 'element-plus'
    import {useStore} from "vuex"
    import register from "../utils/register";

    const store = useStore()
    watch(() => register.mobile, (new_mobile, old_mobile) => {

        //发送get给后端校验手机号是否存在
        if (/^1[3-9]\d{9}$/.test(new_mobile)) {
            register.check_mobile().then(response => {
                console.log(response.data.message);
                if (response.data.message === '已注册') {
                    ElMessage.warning('手机号已注册,请直接登陆')
                } else if (response.data.message === '手机号格式错误') {
                    ElMessage.error('手机号格式错误')
                } else {
                    return
                }

            }).catch(error => {
                console.log(error)
            })
        }
    })

</script>

2.服务端提供验证手机号的api接口

luffycity/apps/users/views.py

python
from rest_framework.views import APIView
from users.models import User
from rest_framework.response import Response
from rest_framework import status
import re

class MobileAPIView(APIView):
    def get(self, request, mobile):

        is_mobile_format = re.match(r"^1[3-9]\d{9}$", str(mobile))
        if not is_mobile_format:
            return Response({'message': '手机号格式错误'}, status=status.HTTP_200_OK)

        is_exist = User.objects.filter(mobile=mobile).exists()
        if is_exist:
            return Response({'message': '已注册'}, status=status.HTTP_200_OK)
        else:
            return Response({'message': 'okk'}, status=status.HTTP_200_OK)

luffycity/apps/users/urls.py:

python
from django.urls import path, re_path
from rest_framework_jwt.views import obtain_jwt_token
from users.views import MobileAPIView

urlpatterns = [
    path("login/", obtain_jwt_token, name="login"),
    path('mobile/<int:mobile>/', MobileAPIView.as_view(), name='mobile'),
]

3.客户端发送ajax请求验证手机号是否已注册

src/utils/register/js:

js
import {reactive} from 'vue'
import http from "./http";

const register = reactive({
    password: "",    // 密码
    re_password: "",// 确认密码
    mobile: "",     // 手机号
    code: "",       // 验证码
    check_mobile() {
        return http.get(`http://api.luffycity.cn:8000/users/mobile/${this.mobile}/`) //后端写一个view校验手机号是否存在
    },
})

export default register

提交版本

bash
cd /home/moluo/Desktop/luffycity
git add .
git commit -m "注册功能实现流程-验证手机号是否已经注册!"
git push

1.7.3 注册功能的基本实现

服务端实现用户注册的api接口

序列化器,users/serializers,代码:

python
from rest_framework import serializers
import re
from users.models import User
from rest_framework_jwt.settings import api_settings


class UserRegisterModelSerializer(serializers.ModelSerializer):
    password = serializers.CharField(max_length=16, min_length=6, required=True, write_only=True)
    re_password = serializers.CharField(max_length=16, min_length=6, write_only=True)
    sms_code = serializers.CharField(max_length=4, min_length=4, write_only=True)
    token = serializers.CharField(max_length=255, read_only=True)

    class Meta:
        model = User
        fields = ['mobile', 'password', 're_password', 'sms_code', 'token']

    def validate(self, attrs):
        mobile = attrs.get('mobile', None)
        # 验证手机号格式
        is_mobile_match = re.match(r'^1[3-9]\d{9}$', str(mobile))
        if not is_mobile_match:
            raise serializers.ValidationError('手机号码不匹配')

        # 验证手机号是否已注册
        is_exists = User.objects.filter(mobile=mobile).exists()
        if is_exists:
            raise serializers.ValidationError('手机号已注册,请直接登陆')
        password = attrs.get('password')
        re_password = attrs.get('re_password')
        if str(password) != str(re_password):
            raise serializers.ValidationError('密码不一致,请重新输入')
        sms_code = attrs.get('sms_code')
        # todo:验证短信
        print(sms_code, 'sms_code')
        return attrs

    def create(self, validated_data):
        mobile = validated_data.get('mobile')
        password = validated_data.get('password')
        user = User.objects.create_user(username=mobile, mobile=mobile, password=password)
        print(user, 'user')

        # 生成token
        jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
        jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
        payload = jwt_payload_handler(user)
        user.token = jwt_encode_handler(payload)
        return user

默认头像配置,settings.constants,代码:

python
# 默认头像
DEFAULT_USER_AVATAR = "avatar/2021/avatar.jpg"
# 手动在uploads下创建avatar/2021/并把客户端的头像保存到该目录下。

视图,users.views,代码,

python
from rest_framework.views import APIView
from rest_framework.generics import CreateAPIView
from rest_framework_jwt.views import ObtainJSONWebToken
from utils.TChapta import TCaptcha, TencentCloudSDKException
from users.serializer import UserRegisterModelSerializer
from rest_framework.response import Response
from rest_framework import status
from users.models import User
from ronglian_sms_sdk import SmsSDK
from luffycity.settings.dev import SMS_MESSAGE
from django_redis import get_redis_connection
from utils.jwt_token import generate_jwt_token
import random
import re
import json


class LoginAPIView(ObtainJSONWebToken):

    def post(self, request, *args, **kwargs):
        try:
            ticket = request.data.get('ticket')
            randstr = request.data.get('randstr')
            appid = request.data.get('appid')
            tcaptcha = TCaptcha()
            result = tcaptcha.verify(ticket, randstr, appid)
            result = json.loads(result)

            CaptchaCode = result.get('CaptchaCode', 'None')
            if CaptchaCode:
                print('ok')
                return super().post(request, *args, **kwargs)
            else:
                return TencentCloudSDKException

        except:
            return TencentCloudSDKException


class CheckMobileAPIView(APIView):
    def get(self, request, mobile):
        # 校验手机号格式是否正确
        res = re.match(r'^1[3-9]\d{9}$', str(mobile))
        if not res:
            return Response({'message': '手机号格式错误,请重新输入', 'code': 0})
        # 校验手机号是否存在
        is_exist = User.objects.filter(mobile=mobile).exists()
        if not is_exist:
            return Response({'message': 'ok', 'code': 1})
        return Response({'message': '手机号已注册,请直接登陆', 'code': 0})


class RegisterCreateAPIView(CreateAPIView):
    queryset = User.objects.all()
    serializer_class = UserRegisterModelSerializer

    def post(self, request):
        print(request.data, 'data')
        sms_code = request.data.get('sms_code')
        mobile = request.data.get('mobile')
        password = request.data.get('password')

        conn = get_redis_connection('sms_code')
        sms_code_redis = conn.get(mobile).decode()
        if not sms_code:
            return Response({'message': '验证码已超时,请重新发送', 'code': 0}, status=status.HTTP_200_OK)

        print('------')
        print(sms_code, type(sms_code))
        print(sms_code_redis, type(sms_code_redis))
        if sms_code != sms_code_redis:
            return Response({'message': '验证码错误,请重新输入', 'code': 0}, status=status.HTTP_200_OK)
        user = User.objects.create_user(username=mobile, mobile=mobile, password=password)
        token = generate_jwt_token(user)
        return Response({'message': '注册成功', 'code': 1, 'token': token}, status=status.HTTP_200_OK)


class sendSMSCodeAPIView(APIView):
    def get(self, request, mobile):
        # 检查是否可以发送短信验证码,60秒之内?
        conn = get_redis_connection('sms_code')
        is_exist = conn.get(mobile)
        print(is_exist, 'exist')
        if is_exist:
            return Response({'message': '短信发送太频繁,请稍后重试', 'code': 0}, status=status.HTTP_200_OK)

        # 发送短信验证码
        code = random.randint(1000, 9999)
        print(code, 'code')
        # accToken
        sdk = SmsSDK(SMS_MESSAGE.get('accId'), SMS_MESSAGE.get('accToken'), SMS_MESSAGE.get('appId'))
        data = (code, 5,)
        resp = sdk.sendMessage(SMS_MESSAGE.get('tid'), mobile, data)
        resp = json.loads(resp)

        if resp.get('statusCode') == "000000":
            conn.set(mobile, code, ex=60 * 5)
            return Response({'code': resp.get('statusCode'), 'message': '短信发送成功'}, status=status.HTTP_200_OK)
        else:
            print(resp)
            return Response({'code': resp.get('statusCode'), 'message': resp.get('statusMsg')},
                            status=status.HTTP_200_OK)

路由,users/urls,代码:

python
from django.urls import path
from users.views import LoginAPIView, sendSMSCodeAPIView, CheckMobileAPIView, RegisterCreateAPIView

urlpatterns = [
    path("login/", LoginAPIView.as_view(), name="login"),
    path("mobile/<int:mobile>/", CheckMobileAPIView.as_view(), name="check_mobile"),
    path("register/", RegisterCreateAPIView.as_view(), name="register"),
    path("sms/<int:mobile>/", sendSMSCodeAPIView.as_view(), name="checkSMSCode"),
]

客户端提交用户注册信息

src/views/Register.vue,代码:

vue
<template>
    <div class="login box">
        <img src="../assets/Loginbg.3377d0c.jpg" alt="">
        <div class="login">
            <div class="login-title">
                <img src="../assets/logo.svg" alt="">
                <p>帮助有志向的年轻人通过努力学习获得体面的工作和生活!</p>
            </div>
            <div class="login_box">
                <div class="title">
                    <span class="active">用户注册</span>
                </div>
                <div class="inp">
                    <input v-model="register.mobile" type="text" placeholder="手机号码" class="user" @blur="checkMobile">
                    <input v-model="register.password" type="password" placeholder="登录密码" class="user">
                    <input v-model="register.re_password" type="password" placeholder="确认密码" class="user">
                    <input v-model="register.sms_code" type="text" class="code" placeholder="短信验证码">

                    <el-button id="get_code" type="primary" v-if="register.is_counting" disabled>
                        {{register.countDown}}秒后重新发送
                    </el-button>
                    <el-button id="get_code" type="primary" @click="sendSMSCode" v-else>点击获取验证码</el-button>
                    <button class="login_btn" @click="clickRegister">注册</button>
                    <p class="go_login">已有账号
                        <router-link to="/login">立即登录</router-link>
                    </p>
                </div>
            </div>
        </div>
    </div>
</template>
vue
<script setup>

    import {ElMessage} from 'element-plus'
    import {useStore} from "vuex"
    import "../utils/TCaptcha"
    import register from "../utils/register";
    import router from "../router";
    import settings from "../settings";

    let store = useStore()

    const checkMobile = () => {
        console.log(register.mobile, 'send_mobile')
        register.check_mobile().then(response => {
            if (response.data.code === 1) {
                console.log(response.data.message)
                register.is_exist = false
            } else {
                ElMessage.error(response.data.message);
                register.is_exist = true

            }
            ret
        }).catch(error => {
            console.log(error)
        })
    }

    const sendSMSCode = () => {
        //检查电话号是否注册
        if (register.is_exist) {
            return
        }
        console.log('can send sms')


        //按钮倒计时
        register.startCountDown()
        //发送ajax
        register.sendSMSCode().then(response => {
            console.log(response)

            //校验是否发送成功
            if (response.data.code === '000000') {
                ElMessage.success('短信发送成功')
            } else {
                ElMessage.error(response.data.message)
            }

        }).catch(error => {
            console.log(error)
        })
    }


    const clickRegister = () => {
        register.send_register({
            mobile: register.mobile,
            password: register.password,
            re_password: register.re_password,
            sms_code: register.sms_code,
        }).then(response => {
            console.log(response.data)
            if (response.data.code) {
                //保存token
                let payload = response.data.token.split('.')[1]
                let payload_data = JSON.parse(atob(payload))
                store.commit("login", payload_data)

                //清空注册表单
                register.mobile = ''
                register.password = ''
                register.re_password = ''
                register.sms_code = ''

                //跳转到home
                router.push({'name': 'Home'})

                ElMessage.success('注册成功')
            }
            // router.push({'name': 'Home'})
        }).catch(error => {
            console.log(error.data)
        })

    }

</script>
vue
<style scoped>
    .box {
        width: 100%;
        height: 100%;
        position: relative;
        overflow: hidden;
    }

    .box img {
        width: 100%;
        min-height: 100%;
    }

    .box .login {
        position: absolute;
        width: 500px;
        height: 400px;
        left: 0;
        margin: auto;
        right: 0;
        bottom: 0;
        top: -438px;
    }

    .login-title {
        width: 100%;
        text-align: center;
    }

    .login-title img {
        width: 190px;
        height: auto;
    }

    .login-title p {
        font-size: 18px;
        color: #fff;
        letter-spacing: .29px;
        padding-top: 10px;
        padding-bottom: 50px;
    }

    .login_box {
        width: 400px;
        height: auto;
        background: #fff;
        box-shadow: 0 2px 4px 0 rgba(0, 0, 0, .5);
        border-radius: 4px;
        margin: 0 auto;
        padding-bottom: 40px;
        padding-top: 50px;
    }

    .title {
        font-size: 20px;
        color: #9b9b9b;
        letter-spacing: .32px;
        border-bottom: 1px solid #e6e6e6;
        display: flex;
        justify-content: space-around;
        padding: 0px 60px 0 60px;
        margin-bottom: 20px;
        cursor: pointer;
    }

    .title span.active {
        color: #4a4a4a;
    }

    .inp {
        width: 350px;
        margin: 0 auto;
    }

    .inp .code {
        width: 190px;
        margin-right: 16px;
    }

    #get_code {
        margin-top: 6px;
    }

    .inp input {
        outline: 0;
        width: 100%;
        height: 45px;
        border-radius: 4px;
        border: 1px solid #d9d9d9;
        text-indent: 20px;
        font-size: 14px;
        background: #fff !important;
    }

    .inp input.user {
        margin-bottom: 16px;
    }

    .inp .rember {
        display: flex;
        justify-content: space-between;
        align-items: center;
        position: relative;
        margin-top: 10px;
    }

    .inp .rember p:first-of-type {
        font-size: 12px;
        color: #4a4a4a;
        letter-spacing: .19px;
        margin-left: 22px;
        display: -ms-flexbox;
        display: flex;
        -ms-flex-align: center;
        align-items: center;
        /*position: relative;*/
    }

    .inp .rember p:nth-of-type(2) {
        font-size: 14px;
        color: #9b9b9b;
        letter-spacing: .19px;
        cursor: pointer;
    }

    .inp .rember input {
        outline: 0;
        width: 30px;
        height: 45px;
        border-radius: 4px;
        border: 1px solid #d9d9d9;
        text-indent: 20px;
        font-size: 14px;
        background: #fff !important;
        vertical-align: middle;
        margin-right: 4px;
    }

    .inp .rember p span {
        display: inline-block;
        font-size: 12px;
        width: 100px;
    }

    .login_btn {
        cursor: pointer;
        width: 100%;
        height: 45px;
        background: #84cc39;
        border-radius: 5px;
        font-size: 16px;
        color: #fff;
        letter-spacing: .26px;
        margin-top: 30px;
        border: none;
        outline: none;
    }

    .inp .go_login {
        text-align: center;
        font-size: 14px;
        color: #9b9b9b;
        letter-spacing: .26px;
        padding-top: 20px;
    }

    .inp .go_login span {
        color: #84cc39;
        cursor: pointer;
    }
</style>

src/utils/register.js,代码:

js
import {reactive} from 'vue'
import http from "./http";

const register = reactive({
    mobile: '',
    password: '',
    re_password: '',
    sms_code: '',
    is_counting: false,
    countDown: 60,
    is_exist: false,  //电话号是否已注册

    check_mobile() {
        return http.get(`http://api.luffycity.cn:8000/users/mobile/${this.mobile}/`)
    },
    send_register(data) {
        return http.post('http://api.luffycity.cn:8000/users/register/', data)
    },
    sendSMSCode(data) {
        return http.get(`http://api.luffycity.cn:8000/users/sms/${this.mobile}/`)
    },
    startCountDown() {
        this.is_counting = true
        let timer = setInterval(() => {
            if (this.countDown === 0) {
                clearInterval(timer)
                this.countDown = 60
                this.is_counting = false
            } else {
                this.countDown--;
            }
        }, 1000)
    }

})
export default register

components/Login.vue,登录组件修改代码后:

vue
<template>
    <div class="title">
        <span :class="{active:User.login_type===0}" @click="User.login_type=0">密码登录</span>
        <span :class="{active:User.login_type===1}" @click="User.login_type=1">短信登录</span>
    </div>
    <div class="inp" v-if="User.login_type===0">
        <input v-model="User.username" type="text" placeholder="用户名 / 手机号码" class="user">
        <input v-model="User.password" type="password" class="pwd" placeholder="密码">
        <div id="geetest1"></div>
        <div class="remember">
            <label>
                <input type="checkbox" class="no" name="a" v-model="User.remember"/>
                <span>记住密码</span>
            </label>
            <p>忘记密码</p>
        </div>
        <button class="login_btn" @click="showCaptcha">登录</button>

        <p class="go_login">没有账号
            <router-link :to="{name:'Register'}"><span>立即注册</span></router-link>
        </p>
    </div>
    <div class="inp" v-show="User.login_type==1">
        <input v-model="User.username" type="text" placeholder="手机号码" class="user">
        <input v-model="User.password" type="text" class="code" placeholder="短信验证码">
        <el-button id="get_code" type="primary">获取验证码</el-button>
        <button class="login_btn">登录</button>
        <p class="go_login">没有账号 <span>立即注册</span></p>
    </div>
</template>
vue
<script setup>

    import {defineEmits} from "vue";
    import User from "../utils/login";
    import {ElMessage} from 'element-plus'
    import {useStore} from 'vuex'
    import '../utils/TCaptcha.js'

    const emits = defineEmits(['shutDialog'])
    let store = useStore()

    function LoginFunc(ticket, randstr, appid) {
        if (User.username === '' || User.password === '') {
            ElMessage.error('用户名或密码不能为空')
            return;
        }
        User.login({
            "ticket": ticket,
            "randstr": randstr,
            "appid": appid,
        }).then(response => {
            console.log(response.data, 'response')

            if (response.data.token) {
                //记住密码
                ElMessage.success('登陆成功')
                //清空用户数据
                User.username = ''
                User.password = ''
                User.remember = false
                //登陆成功,关掉登陆框
                emits('shutDialog')
                //使用vuex记住登陆状态和用户信息
                let payload = response.data['token'].split('.')[1]
                let payload_data = JSON.parse(atob(payload))
                store.commit('login', payload_data)
            }
        }).catch(error => {
            console.log(error, 'error')
            ElMessage.error('用户名或密码错误')
        })
    }


    // 定义回调函数
    function callback(res) {
        if (res.ret === 0) {
            let ticket = res.ticket
            let randstr = res.randstr
            let appid = res.appid
            //拿到票据后,后台验证票据+登陆
            LoginFunc(ticket, randstr, appid)
        }
    }

    function loadErrorCallback() {
        var appid = '190269980';
        // 生成容灾票据或自行做其它处理
        var ticket = 'terror_1001_' + appid + '_' + Math.floor(new Date().getTime() / 1000);
        callback({
            ret: 0,
            randstr: '@' + Math.random().toString(36).substr(2),
            ticket: ticket,
            errorCode: 1001,
            errorMessage: 'jsload_error'
        });
    }

    const showCaptcha = () => {
        try {
            var captcha = new TencentCaptcha('190269980', callback, {});
            captcha.show();
        } catch (error) {
            loadErrorCallback();
        }
    }

</script>

提交版本

bash
cd /home/moluo/Desktop/luffycity
git add .
git commit -m "注册功能实现流程-保存用户注册信息!"
git push

chapter 4:Redis缓存&Celery异步任务

4.1 Redis

4.1.1 数据库类型

关系型数据库(RMDBS)

数据库中表与表的数据之间存在某种关联的内在关系,因为这种关系,所以我们称这种数据库为关系型数据库。

典型:Mysql/MariaDB、postgreSQL、Oracle、SQLServer、DB2、Access、SQLlite3

特点:

  1. 全部使用SQL(结构化查询语言)进行数据库操作。
  2. 都存在主外键关系,表,等等关系特征。
  3. 大部分都支持各种关系型的数据库的特性:存储过程、触发器、视图、临时表、模式、函数

非关系型数据库(NoSQL)

NOSQL:not only sql,泛指非关系型数据库。

泛指那些不使用SQL语句进行数据操作的数据库,所有数据库中只要不使用SQL语句的都是非关系型数据库。

典型:Redis、MongoDB、hbase、 Hadoop、elasticsearch、图数据库。。。。

特点:

  1. 每一款都不一样。用途不一致,功能不一致,各有各的操作方式。
  2. 基本不支持主外键关系,也没有事务的概念。(MongoDB号称最接近关系型数据库的,所以MongoDB有这些的。)

4.1.2 Redis的安装(Mac)

Redis(Remote Dictionary Server ,远程字典服务) 是一个高性能的key-value数据格式的内存数据库,是NoSQL数据库。redis的出现主要是为了替代早起的Memcache缓存系统的。 内存型(数据存放在内存中)的非关系型(nosql)key-value(键值存储)数据库, 支持数据的持久化(基于RDB和AOF,注: 数据持久化时将数据存放到文件中,每次启动redis之后会先将文 件中数据加载到内存),经常用来做缓存、数据共享、购物车、消息队列、计数器、限流等。(最基本的就是缓存一些经常用到的数据,提高读写速度)。

redis的官方只提供了linux版本的redis,window系统的redis是微软团队根据官方的linux版本高仿的。

官方原版: https://redis.io/

中文官网:http://www.redis.cn

Redis的Mac安装

Redis的配置(Linux)

bash
sudo cat /etc/redis/redis.conf

redis 安装成功以后,window下的配置文件保存在软件 安装目录下,如果是mac或者linux,则默认安装/etc/redis/redis.conf

redis的核心配置选项

redis与mysql类似,也是C/S架构的软件,所以存在客户端和服务端,默认的redis的服务端时redis-server,默认提供的redis客户端是redis-cli。

绑定ip:如果需要远程访问,可将此注释,或绑定1个真实ip

bash
bind 127.0.0.1

端⼝,默认为6379

bash
port 6379

是否以守护进程运行 [windows下需要设置]

  • 如果以守护进程运行,则不会在命令阻塞,类似于服务
  • 如果以守护进程运行,则当前终端被阻塞
  • 设置为yes表示守护进程,设置为no表示⾮守护进程
  • 推荐设置为yes
bash
daemonize yes

RDB持久化的备份文件

bash
dbfilename dump.rdb

RDB持久化数据库数据文件的所在目录

bash
dir /var/lib/redis

日志等级和日期文件的所在目录

bash
loglevel notice
logfile /var/log/redis/redis-server.log

进程ID文件

bash
pidfile /var/run/redis/redis-server.pid

数据库,默认有16个,数据名是不能自定义的,只能是0-15之间,当然这个15是数据库数量-1

bash
database 16

redis的登录密码,生产阶段打开,开发阶段避免麻烦,一般都是注释的。

# requirepass foobared

注意:开启了以后,redis-cli终端下使用 auth 密码来认证登录。

image-20211108102339592

RDB持久化的备份频率,文件格式是二进制

python
save 900 1
save 300 10
save 60 10000

RDB持久化备份文件的文件名和路径

python
dbfilename dump.rdb
dir /var/lib/redis

AOF持久化的开启配置项,默认是no关闭的。备份的文件格式:文本格式

bash
appendonly no

AOF持久化的备份文件,存储路径与RDB备份文件路径是一致的。

bash
appendfilename "appendonly.aof"

AOF持久化备份的频率[时间]

bash
# appendfsync always   # 每次修改键对应数据时都会触发一次aof
appendfsync everysec    # 每秒备份,工作中最常用。
# appendfsync no

一主二从三哨兵

4.1.3 Redis的使用

redis是一款基于CS架构的数据库,所以redis有客户端redis-cli,也有服务端redis-server。

其中,客户端可以使用python等编程语言,也可以终端下使用命令行工具管理redis数据库,甚至可以安装一些别人开发的界面工具,例如:RDM。

1553246999266

redis-cli客户端连接服务器:

bash
# redis-cli -h `redis服务器ip` -p `redis服务器port`
redis-cli -h 10.16.244.3 -p 6379

4.1.4 redis数据类型

redis就是一个全局的大字典,key就是数据的唯一标识符。根据key对应的值不同,可以划分成5个基本数据类型。
1. string类型:
	字符串类型,是 Redis 中最为基础的数据存储类型,它在 Redis 中是二进制安全的,也就是byte类型。
	单个数据的最大容量是512M。
		key: b"值"
	
2. hash类型:
	哈希类型,用于存储对象/字典,对象/字典的结构为键值对。key、域、值的类型都为string。域在同一个hash中是唯一的。
		key:{
            域(属性): 值,
            域:值,            
            域:值,
            域:值,
            ...
		}
3. list类型:
	列表类型,它的子成员类型为string。
		key: [ 值1,值2, 值3..... ]
4. set类型:
	无序集合,它的子成员类型为string类型,元素唯一不重复,没有修改操作。
		key: {值1, 值4, 值3, ...., 值5}

5. zset类型(sortedSet):
	有序集合,它的子成员值的类型为string类型,元素唯一不重复,没有修改操作。权重值1从小到大排列。
		key: {
			值1 权重值1(数字);
			值2 权重值2;
			值3 权重值3;
			值4 权重值4;
		}

redis中的所有数据操作,如果设置的键不存在则为添加,如果设置的键已经存在则修改

String

设置键值

set 设置的数据没有额外操作时,是不会过期的。

bash
set key value

设置键为name值为xiaoming的数据

bash
set name xiaoming

1553478355927

设置一个键,当键不存在时才能设置成功,用于一个变量只能被设置一次的情况。

bash
setnx  key  value

一般用于给数据加锁

bash
127.0.0.1:6379> setnx goods_1 101
(integer) 1
127.0.0.1:6379> setnx goods_1 102
(integer) 0  # 表示设置不成功

127.0.0.1:6379> del goods_1
(integer) 1
127.0.0.1:6379> setnx goods_1 102
(integer) 1

设置键值的过期时间

redis中可以对一切的数据进行设置有效期。

以秒为单位

bash
setex key seconds value

设置键为name值为xiaoming过期时间为20秒的数据

bash
setex name 20 xiaoming

实用set设置的数据会永久存储在redis中,如果实用setex对同名的key进行设置,可以把永久有效的数据设置为有时间的临时数据。

设置多个键值

bash
mset key1 value1 key2 value2 ...

例3:设置键为a1值为python、键为a2值为java、键为a3值为c

bash
mset a1 python a2 java a3 c

字符串拼接值

bash
append key value

向键为a1中拼接值haha

bash
append title "我的"
append title "redis"
append title "学习之路"

根据键获取值

根据键获取值,如果不存在此键则返回nil,相当于python的None

bash
get key

获取键name的值

bash
get name

根据多个键获取多个值

bash
mget key1 key2 ...

获取键a1、a2、a3的值

bash
mget a1 a2 a3

自增自减

bash
set id 1
incr id   # 相当于id+1
get id    # 2
incr id   # 相当于id+1
get id    # 3


set goods_id_1 10
decr goods_id_1  # 相当于 id-1
get goods_id_1    # 8
decr goods_id_1   # 相当于id-1
get goods_id_1    # 8

获取字符串的长度

bash
set name xiaoming
strlen name  # 8

比特流操作

签到记录

8位就是1byte ==> 0010 0100

bash
BITCOUNT   # 统计字符串被设置为1的bit数.
BITPOS     # 返回字符串里面第一个被设置为1或者0的bit位。
SETBIT     # 设置一个bit数据的值 
GETBIT     # 获取一个bit数据的值
bash
SETBIT mykey 7 1   
# 00000001
getbit mykey 7
# 00000001
SETBIT mykey 4 1
# 00001001
SETBIT mykey 15 1
# 0000100100000001
BITCOUNT mykey
# 3
BITPOS mykey 1
# 4

key操作

redis中所有的数据都是通过key(键)来进行操作,这里我们学习一下关于任何数据类型都通用的命令。

查找键

参数支持简单的正则表达式

bash
keys pattern

查看所有键

bash
keys *

例子:

bash
# 查看名称中包含`a`的键
keys *a*
# 查看以a开头的键
keys a*
# 查看以a结尾的键
keys *a
# 数字结尾
keys *[1-9]

判断键是否存在

如果存在返回1,不存在返回0

bash
exists key1

判断键title是否存在

bash
exists title

查看键的数据类型

bash
type key

# string    字符串
# hash      哈希类型
# list      列表类型
# set       无序集合
# zset      有序集合

查看键的数据类型

bash
type name
# string
sadd member_list xiaoming xiaohong xiaobai
# (integer) 3
type member_list
# set
hset user_1 name xiaobai age 17 sex 1
# (integer) 3
type user_1
# hash
lpush brothers zhangfei guangyu liubei xiaohei
# (integer) 4
type brothers
# list

zadd achievements 61 xiaoming 62 xiaohong 83 xiaobai  78 xiaohei 87 xiaohui 99 xiaolong
# (integer) 6
type achievements
# zset

删除键以及键对应的值

bash
del key1 key2 ...

查看键的有效期

bash
ttl key

# 结果结果是秒作为单位的整数
# -1 表示永不过期
# -2 表示当前数据已经过期,查看一个不存在的数据的有效期就是-2

设置key的有效期

给已有的数据重新设置有效期,redis中所有的数据都可以通过expire来设置它的有效期。有效期到了,数据就被删除。

bash
expire key seconds

清空所有key

慎用,一旦执行,则redis所有数据库的全部key都会被清除!!!!!

bash
flushall

key重命名

bash
rename  oldkey newkey

把name重命名为username

bash
set name xioaming
rename name username
get username

select切换数据库

bash
redis的配置文件中,默认有0~15之间的16个数据库,默认操作的就是0号数据库
select <数据库ID>

操作效果:

bash
# 默认处于0号库
127.0.0.1:6379> select 1
OK
# 这是在1号库
127.0.0.1:6379[1]> set name xiaoming
OK
127.0.0.1:6379[1]> select 2
OK
# 这是在2号库
127.0.0.1:6379[2]> set name xiaohei
OK

auth认证

bash
在redis中,如果配置了requirepass登录密码,则进入redis-cli的操作数据之前,必须要进行登录认证。
注意:在redis6.0以后,redis新增了用户名和密码登录,可以选择使用,也可以选择不适用,默认关闭的。
      在redis6.0以前,redis只可以在配置文件中,可以选择开启密码认证,也可以关闭密码认证,默认关闭的。
      
redis-cli
127.0.0.1:6379> auth <>
OK  # 认证通过

hash

类似python的字典,但是成员只能是string,专门用于结构化的数据信息。只能套一层域值对。

结构:

键key:{
   	域field:值value
}

设置指定键的属性/域

设置指定键的单个属性,如果key不存在,则表示创建一个key对应的哈希数据,如果key存在,而field不存在,则表示当前哈希数据新增一个成员,如果field存在,则表示修改哈希对应的对应成员的值。

bash
hset key field value
# redis5.0版本以后,hset可以一次性设置多个哈希的成员数据
hset key field1 value1 field2 value2 field3 value3 ...

设置键 user_1的属性namexiaoming

bash
127.0.0.1:6379> hset user_1 name xiaoming   # user_1没有会自动创建
(integer) 1
127.0.0.1:6379> hset user_1 name xiaohei    # user_1中重复的属性会被修改
(integer) 0
127.0.0.1:6379> hset user_1 age 16          # user_1中重复的属性会被新增
(integer) 1
127.0.0.1:6379> hset user:1 name xiaohui    # user:1会在redis界面操作中以:作为目录分隔符
(integer) 1
127.0.0.1:6379> hset user:1 age 15
(integer) 1
127.0.0.1:6379> hset user:2 name xiaohong age 16  # 一次性添加或修改多个属性

设置指定键的多个属性[hmset已经慢慢淘汰了,hset就可以实现多个属性]

bash
hmset key field1 value1 field2 value2 ...

设置键user_1的属性namexiaohong、属性age17,属性sex为1

bash
hmset user:3 name xiaohong age 17 sex 1

获取指定键的域/属性的值

获取指定键所有的域/属性

bash
hkeys key

获取键user的所有域/属性

bash
127.0.0.1:6379> hkeys user:2
1) "name"
2) "age"
127.0.0.1:6379> hkeys user:3
1) "name"
2) "age"
3) "sex"

获取指定键的单个域/属性的值

hget key field

获取键user:3属性name的值

bash
127.0.0.1:6379> hget user:3 name
"xiaohong"

获取指定键的多个域/属性的值

bash
hmget key field1 field2 ...

获取键user:2属性nameage的值

bash
127.0.0.1:6379> hmget user:2 name age
1) "xiaohong"
2) "16"

获取指定键的所有值

bash
hvals key

获取指定键的所有域值对

bash
127.0.0.1:6379> hvals user:3
1) "xiaohong"
2) "17"
3) "1"

删除指定键的域/属性

bash
hdel key field1 field2 ...

删除键user:3的属性sex/age/name,当键中的hash数据没有任何属性,则当前键会被redis删除

bash
hdel user:3 sex age name

判断指定属性/域是否存在于当前键对应的hash中

bash
hexists   key  field

判断user:2中是否存在age属性

bash
127.0.0.1:6379> hexists user:3 age
(integer) 0
127.0.0.1:6379> hexists user:2 age
(integer) 1
127.0.0.1:6379>

属性值自增自减

bash
hincrby key field number

给user:2的age属性在原值基础上+/-10,然后在age现有值的基础上-2

bash
# 按指定数值自增
127.0.0.1:6379> hincrby user:2 age 10
(integer) 77
127.0.0.1:6379> hincrby user:2 age 10
(integer) 87

# 按指定数值自减
127.0.0.1:6379> hincrby user:2 age -10
(integer) 77
127.0.0.1:6379> hincrby user:2 age -10

List

类似python的lis列表数据类型,但是redis中的list的子成员类型为string。

添加子成员

bash
# 在左侧(前,上)添加一条或多条成员数据
lpush key value1 value2 ...

# 在右侧(后,下)添加一条或多条成员数据
rpush key value1 value2 ...

# 在指定元素的左边(前)/右边(后)插入一个或多个数据
linsert key before 指定成员 value1 value2 ....
linsert key after 指定成员 value1 value2 ....

从键为brother的列表左侧添加一个或多个数据liubei、guanyu、zhangfei

bash
lpush brother liubei
# [liubei]
lpush brother guanyu zhangfei xiaoming
# [xiaoming,zhangfei,guanyu,liubei]

从键为brother的列表右侧添加一个或多个数据,xiaohong,xiaobai,xiaohui

bash
rpush brother xiaohong
# [xiaoming,zhangfei,guanyu,liubei,xiaohong]
rpush brother xiaobai xiaohui
# [xiaoming,zhangfei,guanyu,liubei,xiaohong,xiaobai,xiaohui]

从key=brother,key=xiaohong的列表位置左侧添加一个数据,xiaoA,xiaoB

bash
linsert brother before xiaohong xiaoA
# [xiaoming,zhangfei,guanyu,liubei,xiaoA,xiaohong,xiaobai,xiaohui]
linsert brother before xiaohong xiaoB
# [xiaoming,zhangfei,guanyu,liubei,xiaoA,xiaoB,xiaohong,xiaobai,xiaohui]

从key=brother,key=xiaohong的列表位置右侧添加一个数据,xiaoC,xiaoD

bash
linsert brother after xiaohong xiaoC
# [xiaoming,zhangfei,guanyu,liubei,xiaoA,xiaohong,xiaoC,xiaobai,xiaohui]
linsert brother after xiaohong xiaoD
# [xiaoming,zhangfei,guanyu,liubei,xiaoA,xiaohong,xiaoD,xiaoC,xiaobai,xiaohui]

注意:当列表如果存在多个成员值一致的情况下,默认只识别第一个。

bash
127.0.0.1:6379> linsert brother before xiaoA xiaohong
# [xiaoming,zhangfei,guanyu,liubei,xiaohong,xiaoA,xiaohong,xiaoD,xiaoC,xiaobai,xiaohui]
127.0.0.1:6379> linsert brother before xiaohong xiaoE
# [xiaoming,zhangfei,guanyu,liubei,xiaoE,xiaohong,xiaoA,xiaohong,xiaoD,xiaoC,xiaobai,xiaohui]
127.0.0.1:6379> linsert brother after xiaohong xiaoF
# [xiaoming,zhangfei,guanyu,liubei,xiaoE,xiaohong,xiaoF,xiaoA,xiaohong,xiaoD,xiaoC,xiaobai,xiaohui]

设置指定索引位置成员的值

bash
lset key index value
# 注意:
# redis的列表也有索引,从左往右,从0开始,逐一递增,第1个元素下标为0
# 索引可以是负数,表示尾部开始计数,如`-1`表示最后1个元素

修改键为brother的列表中下标为4的元素值为xiaohongmao

bash
lset brother 4 xiaohonghong

删除指定成员

bash
lrem key count value

# 注意:
# count表示删除的数量,value表示要删除的成员。该命令默认表示将列表从左侧前count个value的元素移除
# count==0,表示删除列表所有值为value的成员
# count >0,表示删除列表左侧开始的前count个value成员
# count <0,表示删除列表右侧开始的前count个value成员

image-20210713111037153

获取列表成员

根据指定的索引获取成员的值

bash
lindex key index

获取brother下标为2以及-2的成员

bash
lindex brother 2
lindex brother -2

移除并获取列表的第一个成员或最后一个成员

bash
lpop key  # 第一个成员出列
rpop key  # 最后一个成员出列

获取并移除brother中的第一个成员

bash
lpop brother
# 开发中往往使用rpush和lpop实现队列的数据结构->实现入列和出列

获取列表的切片

闭区间[包括stop]

bash
lrange key start stop

操作:

bash
# 获取btother的全部成员
lrange brother 0 -1
# 获取brother的前2个成员
lrange brother 0 1

获取列表的长度

bash
llen key

获取brother列表的成员个数

bash
llen brother

获取哈希的所有成员域值对

python
hgetall key

Set

类似python里面的set无序集合, 成员是字符串string,重点就是去重和无序。

添加元素

key不存在,则表示新建集合,如果存在则表示给对应集合新增成员。

bash
sadd key member1 member2 ...

向键authors的集合中添加元素zhangsanlisiwangwu

bash
sadd authors zhangsan sili wangwu

获取集合的所有的成员

bash
smembers key

获取键authors的集合中所有元素

bash
smembers authors

获取集合的长度

bash
scard keys

获取s2集合的长度

bash
sadd s2 a c d e

127.0.0.1:6379> scard s2
(integer) 4

随机获取一个或多个元素

bash
spop key [count=1]

# 注意:
# count为可选参数,不填则默认一个。被提取成员会从集合中被删除掉

随机获取s2集合的成员

bash
sadd s2 a c d e

127.0.0.1:6379> spop s2 
"d"
127.0.0.1:6379> spop s2 
"c"

删除指定元素

bash
srem key value

删除键authors的集合中元素wangwu

bash
srem authors wangwu

交集、差集和并集

bash
sinter  key1 key2 key3 ....    # 交集,比较多个集合中共同存在的成员
sdiff   key1 key2 key3 ....    # 差集,比较多个集合中不同的成员
sunion  key1 key2 key3 ....    # 并集,合并所有集合的成员,并去重
bash
sadd user:1 1 2 3 4     # user:1 = {1,2,3,4}
sadd user:2 1 3 4 5     # user:2 = {1,3,4,5}
sadd user:3 1 3 5 6     # user:3 = {1,3,5,6}
sadd user:4 2 3 4       # user:4 = {2,3,4}

# 交集
127.0.0.1:6379> sinter user:1 user:2
1) "1"
2) "3"
3) "4"
127.0.0.1:6379> sinter user:1 user:3
1) "1"
2) "3"
127.0.0.1:6379> sinter user:1 user:4
1) "2"
2) "3"
3) "4"

127.0.0.1:6379> sinter user:2 user:4
1) "3"
2) "4"

# 并集
127.0.0.1:6379> sunion user:1 user:2 user:4
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"

# 差集
127.0.0.1:6379> sdiff user:2 user:3
1) "4"  # 此时可以给user:3推荐4

127.0.0.1:6379> sdiff user:3 user:2
1) "6"  # 此时可以给user:2推荐6

127.0.0.1:6379> sdiff user:1 user:3
1) "2"
2) "4"

Zset

有序集合,去重并且根据score权重值来进行排序的。score从小到大排列。

添加成员

key如果不存在,则表示新建有序集合。

bash
zadd key score1 member1 score2 member2 score3 member3 ....

设置榜单achievements,设置成绩和用户名作为achievements的成员

bash
127.0.0.1:6379> zadd achievements 61 xiaoming 62 xiaohong 83 xiaobai  78 xiaohei 87 xiaohui 99 xiaolan
(integer) 6
127.0.0.1:6379> zadd achievements 85 xiaohuang 
(integer) 1
127.0.0.1:6379> zadd achievements 54 xiaoqing

给指定成员增加权重值

bash
zincrby key score member

给achievements中xiaobai增加10分

bash
127.0.0.1:6379> ZINCRBY achievements 10 xiaobai
"93

获取集合长度

bash
zcard key

获取users的长度

bash
zcard achievements

获取指定成员的权重值

bash
zscore key member

获取users中xiaoming的成绩

bash
127.0.0.1:6379> zscore achievements xiaobai
"93"
127.0.0.1:6379> zscore achievements xiaohong
"62"
127.0.0.1:6379> zscore achievements xiaoming
"61"

获取指定成员在集合中的排名

排名从0开始计算

bash
srank key member      # score从小到大的排名
zrevrank key member   # score从大到小的排名

获取achievements中xiaohei的分数排名,从大到小

bash
127.0.0.1:6379> zrevrank achievements xiaohei
(integer) 4

获取score在指定区间的所有成员数量

bash
zcount key min max

获取achievements从0~60分之间的人数[闭区间]

bash
127.0.0.1:6379> zadd achievements 60 xiaolv
(integer) 1
127.0.0.1:6379> zcount achievements 0 60
(integer) 2
127.0.0.1:6379> zcount achievements 54 60
(integer) 2

获取score在指定区间的所有成员

bash
zrangebyscore key min max     # 按score进行从低往高排序获取指定score区间
zrevrangebyscore key min max  # 按score进行从高往低排序获取指定score区间
zrange key start stop         # 按scoer进行从低往高排序获取指定索引区间
zrevrange key start stop      # 按scoer进行从高往低排序获取指定索引区间

获取users中60-70之间的数据

bash
127.0.0.1:6379> zrangebyscore achievements 60 90
1) "xiaolv"
2) "xiaoming"
3) "xiaohong"
4) "xiaohei"
5) "xiaohuang"
6) "xiaohui"
127.0.0.1:6379> zrangebyscore achievements 60 80
1) "xiaolv"
2) "xiaoming"
3) "xiaohong"
4) "xiaohei"
bash
# 获取achievements中分数最低的3个数据
127.0.0.1:6379> zrange achievements 0 2
1) "xiaoqing"
2) "xiaolv"
3) "xiaoming"

# 获取achievements中分数最高的3个数据
127.0.0.1:6379> zrevrange achievements 0 2
1) "xiaolan"
2) "xiaobai"
3) "xiaohui"

删除成员

bash
zrem key member1 member2 member3 ....

从achievements中删除xiaoming的数据

bash
zrem achievements xiaoming

删除指定数量的成员

bash
# 删除指定数量的成员,从最低score开始删除
zpopmin key [count]
# 删除指定数量的成员,从最高score开始删除
zpopmax key [count]

例子:

bash
# 从achievements中提取并删除成绩最低的2个数据
127.0.0.1:6379> zpopmin achievements 2
1) "xiaoqing"
2) "54"
3) "xiaolv"
4) "60"


# 从achievements中提取并删除成绩最高的2个数据
127.0.0.1:6379> zpopmax achievements 2
1) "xiaolan"
2) "99"
3) "xiaobai"
4) "93"

4.1.5 各种数据类型在开发中的常用业务场景

针对各种数据类型它们的特性,使用场景如下:
字符串string: 用于保存一些项目中的普通数据,只要键值对的都可以保存,例如,保存 session/jwt,定时记录状态,倒计时、验证码、防灌水答案
哈希hash:用于保存项目中的一些对象结构/字典数据,但是不能保存多维的字典,例如,商城的购物车,文章信息,json结构数据
列表list:用于保存项目中的列表数据,但是也不能保存多维的列表,例如,消息队列,秒杀系统,排队,浏览历史
无序集合set: 用于保存项目中的一些不能重复的数据,可以用于过滤,例如,候选人名单, 作者名单,
有序集合zset:用于保存项目中一些不能重复,但是需要进行排序的数据, 例如:分数排行榜, 海选人排行榜,热搜排行,

开发中,redis常用的业务场景:

bash
数据缓存、
分布式数据共享、
计数器、
限流、
位统计(用户打卡、签到)、
购物车、
消息队列、
抽奖奖品池、
排行榜单(搜索排名)、
用户关系记录[收藏、点赞、关注、好友、拉黑]、

开发中,针对redis的使用,python中一般常用的redis模块有:pyredis(同步),aioredis(异步)。

bash
pip install py-redis
pip install aioredis

4.1.6 python操作redis

这2个模块提供给开发者的使用方式都是一致的。都是以redis命令作为函数名,命令后面的参数作为函数的参数。只有一个特殊:del,del在python属于关键字,所以改成delete即可。

基本使用

python
from redis import Redis, StrictRedis

if __name__ == '__main__':
    # 连接redis的写法有2种:
    # url="redis://:密码@IP:端口/数据库编号"
    redis = Redis.from_url(url="redis://:root123@127.0.0.1:6379/0")
    # redis = Redis(host="127.0.0.1", port=6379, password="", db=0)

    # 字符串
    # set name xiaoming
    redis.set("name", "xiaoming")

    # # setex sms_13312345678 30 500021
    mobile = 13312345678
    redis.setex(f"sms_{mobile}", 30, "500021")
    
    #  get name
     ret = redis.get("name")
    #  redis中最基本的数据类型是字符串,但是这种字符串是bytes,所以对于python而言,读取出来的字符串数据还要decode才能使用
     print(ret, ret.decode())

    #  提取数据,键如果不存在,则返回结果为None
     code_bytes = redis.get(f"sms_{mobile}")
     print(code_bytes)
     if code_bytes: # 判断只有获取到数据才需要decode解码
        print(code_bytes.decode())

    # 设置字典,单个成员
    # hset user name xiaoming
     redis.hset("user", "name", "xiaoming")


    #  设置字典,多个成员
    #  hset user name xiaohong age 12 sex 1
    data = {
         "name": "xiaohong",
         "age": 12,
         "sex": 1
     }
     redis.hset("user", mapping=data)

    #  获取字典所有成员,字典的所有成员都是键值对,而键值对也是bytes类型,所以需要推导式进行转换
     ret = redis.hgetall("user")
     print(ret)             # {b'name': b'xiaohong', b'age': b'12', b'sex': b'1'}
     data = {key.decode(): value.decode() for (key, value) in ret.items()}
     print(data)

    # # 获取当前仓库的所有的key
    ret = redis.keys("*")
    print(ret)

    # 删除key
    if len(ret) > 0:
        redis.delete(ret[0])

4.2 Celery

Celery是一个python第三方模块,是一个功能完备即插即用的分布式异步任务队列框架。它适用于异步处理问题,当大批量发送邮件、或者大文件上传, 批图图像处理等等一些比较耗时的操作,我们可将其异步执行,这样的话原来的项目程序在执行过程中就不会因为耗时任务而形成阻塞,导致出现请求堆积过多的问题。celery通常用于实现异步任务或定时任务。

目前最新版本为: 5.2

项目:https://github.com/celery/celery/

文档:(3.1) http://docs.jinkan.org/docs/celery/getting-started/index.html

​ (最新) https://docs.celeryproject.org/en/latest/

Celery的特点是:

  • 简单,易于使用和维护,有丰富的文档。
  • 高效,支持多线程、多进程、协程模式运行,单个celery进程每分钟可以处理数百万个任务。
  • 灵活,celery中几乎每个部分都可以自定义扩展。

celery的作用是:应用解耦,异步处理,流量削锋,消息通讯。

python
celery通过消息(任务)进行通信,
celery通常使用一个叫Broker(中间人/消息中间件/消息队列/任务队列)来协助clients(任务的发出者/客户端)和worker(任务的处理者/工作进程)进行通信的.
clients发出消息到任务队列中,broker将任务队列中的信息派发给worker来处理。

client ---> 消息 --> Broker(消息队列) -----> 消息 ---> worker(celery运行起来的工作进程)

消息队列(Message Queue),也叫消息队列中间件,简称消息中间件,它是一个独立运行的程序,表示在消息的传输过程中临时保存消息的容器。
所谓的消息,是指代在两台计算机或2个应用程序之间传送的数据。消息可以非常简单,例如文本字符串或者数字,也可以是更复杂的json数据或hash数据等。
所谓的队列,是一种先进先出、后进呼后出的数据结构,python中的list数据类型就可以很方便地用来实现队列结构。
目前开发中,使用较多的消息队列有RabbitMQ,Kafka,RocketMQ,MetaMQ,ZeroMQ,ActiveMQ等,当然,像redis、mysql、MongoDB,也可以充当消息中间件,但是相对而言,没有上面那么专业和性能稳定。

并发任务10k以下的,直接使用redis
并发任务10k以上,1000k以下的,直接使用RabbitMQ
并发任务1000k以上的,直接使用RocketMQ

Celery的运行架构

Celery的运行架构由三部分组成,消息队列(message broker),任务执行单元(worker)和任务执行结果存储(task result store)组成。

image-20240222205328994

4.2.1 安装

python
pip install -U celery -i  https://pypi.tuna.tsinghua.edu.cn/simple

注意:

Celery不建议在windows系统下使用,Celery在4.0版本以后不再支持windows系统,所以如果要在windows下使用只能安装4.0以前的版本,而且即便是4.0之前的版本,在windows系统下也是不能单独使用的,需要安装gevent、geventlet或eventlet协程模块

4.2.2 基本使用

使用celery第一件要做的最为重要的事情是需要先创建一个Celery实例对象,我们一般叫做celery应用对象,或者更简单直接叫做一个app。app应用对象是我们使用celery所有功能的入口,比如启动celery、创建任务,管理任务,执行任务等.

celery框架有2种使用方式,一种是单独一个项目目录,另一种就是Celery集成到web项目框架中。

celery作为一个单独项目运行

例如,mycelery代码目录直接放在项目根目录下即可,路径如下:

python
服务端项目根目录/
└── mycelery/
    ├── settings.py   # 配置文件
    ├── __init__.py   
    ├── main.py       # 入口程序
    └── sms/          # 异步任务目录,这里拿发送短信来举例,一个类型的任务就一个目录
         ├── __init__.py
      	 └── tasks.py  # 任务的文件名必须是tasks.py!!!每一个任务就是一个被装饰的函数,写在任务文件中

main.py,代码:

python
from celery import Celery

# 实例化celery应用,参数一般为项目应用名
app = Celery("luffycity")

# 通过app实例对象加载配置文件
app.config_from_object("mycelery.settings")

# 注册任务, 自动搜索并加载任务
# 参数必须必须是一个列表,里面的每一个任务都是任务的路径名称
# app.autodiscover_tasks(["任务1","任务2",....])
app.autodiscover_tasks(["mycelery.sms","mycelery.email"])

# 启动Celery的终端命令
# 强烈建议切换目录到项目的根目录下启动celery!!
# celery -A mycelery.main worker --loglevel=info

配置文件settings.py,代码:

python
# 任务队列的链接地址
broker_url = 'redis://127.0.0.1:6379/14'

# 结果队列的链接地址
result_backend = 'redis://127.0.0.1:6379/15'

# 设置代理连接的重试行为
broker_connection_retry_on_startup = True

关于配置信息的官方文档:https://docs.celeryproject.org/en/master/userguide/configuration.html

创建任务文件sms/tasks.py,任务文件名必须固定为"tasks.py",并创建任务,代码:

python
from ..main import app

@app.task
def send_sms1():
    """没有任何参数的异步任务"""
    print('任务:send_sms1执行了...')

@app.task(name="send_sms2")
def send_sms2(mobile, code):
    """有参数的异步任务"""
    print(f'任务:send_sms2执行了...mobile={mobile}, code={code}')

@app.task
def send_sms3():
    """有结果的异步任务"""
    print('任务:send_sms3执行了...')
    return 100

@app.task(name="send_sms4")
def send_sms4(x,y):
    """有结果的异步任务"""
    print('任务:send_sms4执行了...')
    return x+y

接下来,我们运行celery。

bash
cd ~/Desktop/luffycity/luffycityapi

# 普通的运行方式[默认多进程,卡终端,按CPU核数+1创建进程数]
# ps aux|grep celery
celery -A mycelery.main worker --loglevel=info

# 启动多工作进程,以守护进程的模式运行[一个工作进程就是4个子进程]
# 注意:pidfile和logfile必须以绝对路径来声明
celery multi start worker -A mycelery.main -E --pidfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/worker1.pid" --logfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.log" -l info -n worker1
celery multi start worker -A mycelery.main -E --pidfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/worker2.pid" --logfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.log" -l info -n worker2

# 关闭运行的工作进程
celery multi stop worker -A mycelery.main --pidfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/worker1.pid" --logfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.log"
celery multi stop worker -A mycelery.main --pidfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/worker2.pid" --logfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.log"

效果如下:

image-20210721104438889

调用上面的异步任务,拿django的shell进行举例:

python
# 因为celery模块安装在了虚拟环境中,所以要确保进入虚拟环境
conda activate luffycity
cd ~/Desktop/luffycity/luffycityapi

python manage.py shell

# 调用celery执行异步任务
from mycelery.sms.tasks import send_sms1,send_sms2,send_sms3,send_sms4
mobile = "13312345656"
code = "666666"

# delay 表示马上按顺序来执行异步任务,在celrey的worker工作进程有空闲的就立刻执行
# 可以通过delay异步调用任务,可以没有参数
ret1 = send_sms1.delay()
# 可以通过delay传递异步任务的参数,可以按位置传递参数,也可以使用命名参数
# ret2 = send_sms.delay(mobile=mobile,code=code)
ret2 = send_sms2.delay(mobile,code)

# apply_async 让任务在后面指定时间后执行,时间单位:秒/s
# 任务名.apply_async(args=(参数1,参数2), countdown=定时时间)
ret4 = send_sms4.apply_async(kwargs={"x":10,"y":20},countdown=30)

# 根据返回结果,不管delay,还是apply_async的返回结果都一样的。
ret4.id      # 返回一个UUID格式的任务唯一标志符,78fb827e-66f0-40fb-a81e-5faa4dbb3505
ret4.status  # 查看当前任务的状态 SUCCESS表示成功! PENDING任务等待
ret4.get()   # 获取任务执行的结果[如果任务函数中没有return,则没有结果,如果结果没有出现则会导致阻塞]

if ret4.status == "SUCCESS":
    print(ret4.get())

接下来,我们让celery可以调度第三方框架的代码,这里拿django当成一个第三模块调用进行举例。

在main.py主程序中对django进行导包引入,并设置django的配置文件进行django的初始化。

python
import os,django
from celery import Celery
# 初始化django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'luffycityapi.settings.dev')
django.setup()

# 初始化celery对象
app = Celery("luffycity")

# 加载配置
app.config_from_object("mycelery.config")
# 自动注册任务
app.autodiscover_tasks(["mycelery.sms","mycelery.email"])
# 运行celery
# 终端下: celery -A mycelery.main worker -l info

在需要使用django配置的任务中,直接加载配置,所以我们把注册的短信发送功能,整合成一个任务函数,mycelery.sms.tasks,代码:

python
from ..main import app
from ronglianyunapi import send_sms as send_sms_to_user

@app.task(name="send_sms1")
def send_sms1():
    """没有任何参数,没有返回结果的异步任务"""
    print('任务:send_sms1执行了...')

@app.task(name="send_sms2")
def send_sms2(mobile, code):
    """有参数,没有返回结果的异步任务"""
    print(f'任务:send_sms2执行了...mobile={mobile}, code={code}')


@app.task(name="send_sms3")
def send_sms3():
    """没有任何参数,有返回结果的异步任务"""
    print('任务:send_sms3执行了...')
    return 100

@app.task(name="send_sms4")
def send_sms4(x,y):
    """有结果的异步任务"""
    print('任务:send_sms4执行了...')
    return x+y

@app.task(name="send_sms")
def send_sms(tid, mobile, datas):
    """发送短信"""
    print("发送短信")
    return send_sms_to_user(tid, mobile, datas)

最终在django的视图里面,我们调用Celery来异步执行任务。

只需要完成2个步骤,分别是导入异步任务调用异步任务。users/views.py,代码:

python
import random
from django_redis import get_redis_connection
from django.conf import settings
# from ronglianyunapi import send_sms
from mycelery.sms.tasks import send_sms
"""
/users/sms/(?P<mobile>1[3-9]\d{9})
"""
class SMSAPIView(APIView):
    """
    SMS短信接口视图
    """
    def get(self, request, mobile):
        """发送短信验证码"""
        redis = get_redis_connection("sms_code")
        # 判断手机短信是否处于发送冷却中[60秒只能发送一条]
        interval = redis.ttl(f"interval_{mobile}")  # 通过ttl方法可以获取保存在redis中的变量的剩余有效期
        if interval != -2:
            return Response({"errmsg": f"短信发送过于频繁,请{interval}秒后再次点击获取!", "interval": interval},status=status.HTTP_400_BAD_REQUEST)

        # 基于随机数生成短信验证码
        # code = "%06d" % random.randint(0, 999999)
        code = f"{random.randint(0, 999999):06d}"
        # 获取短信有效期的时间
        time = settings.RONGLIANYUN.get("sms_expire")
        # 短信发送间隔时间
        sms_interval = settings.RONGLIANYUN["sms_interval"]
        # 调用第三方sdk发送短信
        # send_sms(settings.RONGLIANYUN.get("reg_tid"), mobile, datas=(code, time // 60))
        # 异步发送短信
        send_sms.delay(settings.RONGLIANYUN.get("reg_tid"), mobile, datas=(code, time // 60))

        # 记录code到redis中,并以time作为有效期
        # 使用redis提供的管道对象pipeline来优化redis的写入操作[添加/修改/删除]
        pipe = redis.pipeline()
        pipe.multi()  # 开启事务
        pipe.setex(f"sms_{mobile}", time, code)
        pipe.setex(f"interval_{mobile}", sms_interval, "_")
        pipe.execute()  # 提交事务,同时把暂存在pipeline的数据一次性提交给redis

        return Response({"errmsg": "OK"}, status=status.HTTP_200_OK)

上面就是使用celery并执行异步任务的第一种方式,适合在一些无法直接集成celery到项目中的场景。

bash
cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: celery作为一个单独项目运行,执行异步任务"
git push

Celery作为第三方模块集成到项目中

这里还是拿django来举例,目录结构调整如下:

bash
luffycityapi/           # 服务端项目根目录
└── luffycityapi/       # 主应用目录
    ├── apps/           # 子应用存储目录  
   └── users/            # django的子应用
       └── tasks.py      # [新增]分散在各个子应用下的异步任务模块
    ├── settings/     # [修改]django的配置文件存储目录[celery的配置信息填写在django配置中即可]
    ├── __init__.py   # [修改]设置当前包目录下允许外界调用celery应用实例对象
    └── celery.py     # [新增]celery入口程序,相当于上一种用法的main.py

luffycityapi/celery.py,主应用目录下创建cerley入口程序,创建celery对象并加载配置和异步任务,代码:

python
import os
from celery import Celery

# 必须在实例化celery应用对象之前执行
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'luffycityapi.settings.dev')

# 实例化celery应用对象
app = Celery('luffycityapi')
# 指定任务的队列名称
app.conf.task_default_queue = 'Celery'
# 也可以把配置写在django的项目配置中
app.config_from_object('django.conf:settings', namespace='CELERY') # 设置django中配置信息以 "CELERY_"开头为celery的配置信息
# 自动根据配置查找django的所有子应用下的tasks任务文件
app.autodiscover_tasks()

settings/dev.py,django配置中新增celery相关配置信息,代码:

python
# Celery异步任务队列框架的配置项[注意:django的配置项必须大写,所以这里的所有配置项必须全部大写]
# 任务队列
CELERY_BROKER_URL = 'redis://:123456@127.0.0.1:6379/14'
# 结果队列
CELERY_RESULT_BACKEND = 'redis://:123456@127.0.0.1:6379/15'
# 时区,与django的时区同步
CELERY_TIMEZONE = TIME_ZONE
# 防止死锁
CELERY_FORCE_EXECV = True
# 设置并发的worker数量
CELERYD_CONCURRENCY = 200
# 设置失败允许重试[这个慎用,如果失败任务无法再次执行成功,会产生指数级别的失败记录]
CELERY_ACKS_LATE = True
# 每个worker工作进程最多执行500个任务被销毁,可以防止内存泄漏,500是举例,根据自己的服务器的性能可以调整数值
CELERYD_MAX_TASKS_PER_CHILD = 500
# 单个任务的最大运行时间,超时会被杀死[慎用,有大文件操作、长时间上传、下载任务时,需要关闭这个选项,或者设置更长时间]
CELERYD_TIME_LIMIT = 10 * 60
# 任务发出后,经过一段时间还未收到acknowledge, 就将任务重新交给其他worker执行
CELERY_DISABLE_RATE_LIMITS = True
# celery的任务结果内容格式
CELERY_ACCEPT_CONTENT = ['json', 'pickle']

# 之前定时任务(定时一次调用),使用了apply_async({}, countdown=30);
# 设置定时任务(定时多次调用)的调用列表,需要单独运行SCHEDULE命令才能让celery执行定时任务:celery -A mycelery.main beat,当然worker还是要启动的
# https://docs.celeryproject.org/en/stable/userguide/periodic-tasks.html
from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = {
    "user-add": {  # 定时任务的注册标记符[必须唯一的]
        "task": "add",   # 定时任务的任务名称
        "schedule": 10,  # 定时任务的调用时间,10表示每隔10秒调用一次add任务
        # "schedule": crontab(hour=7, minute=30, day_of_week=1),,  # 定时任务的调用时间,每周一早上7点30分调用一次add任务
    }
}

luffycityapi/__init__.py,主应用下初始化,代码:

python
import pymysql
from .celery import app as celery_app

pymysql.install_as_MySQLdb()

__all__ = ['celery_app']

users/tasks.py,代码:

python
from celery import shared_task
from ronglianyunapi import send_sms as sms
# 记录日志:
import logging
logger = logging.getLogger("django")

@shared_task(name="send_sms")
def send_sms(tid, mobile, datas):
    """异步发送短信"""
    try:
        return sms(tid, mobile, datas)
    except Exception as e:
        logger.error(f"手机号:{mobile},发送短信失败错误: {e}")


@shared_task(name="send_sms1")
def send_sms1():
    print("send_sms1执行了!!!")

django中的用户发送短信,就可以改成异步发送短信了。

users/views,视图中调用异步发送短信的任务,代码:

python
from .tasks import send_sms
send_sms.delay(settings.RONGLIANYUN.get("reg_tid"),mobile, datas=(code, time // 60))

users/views.py,异步发送信息的完整视图,代码:

python
import random
from django_redis import get_redis_connection
from django.conf import settings
# from ronglianyunapi import send_sms
# from mycelery.sms.tasks import send_sms
from .tasks import send_sms

"""
/users/sms/(?P<mobile>1[3-9]\d{9})
"""
class SMSAPIView(APIView):
    """
    SMS短信接口视图
    """
    def get(self, request, mobile):
        """发送短信验证码"""
        redis = get_redis_connection("sms_code")
        # 判断手机短信是否处于发送冷却中[60秒只能发送一条]
        interval = redis.ttl(f"interval_{mobile}")  # 通过ttl方法可以获取保存在redis中的变量的剩余有效期
        if interval != -2:
            return Response({"errmsg": f"短信发送过于频繁,请{interval}秒后再次点击获取!", "interval": interval},status=status.HTTP_400_BAD_REQUEST)

        # 基于随机数生成短信验证码
        # code = "%06d" % random.randint(0, 999999)
        code = f"{random.randint(0, 999999):06d}"
        # 获取短信有效期的时间
        time = settings.RONGLIANYUN.get("sms_expire")
        # 短信发送间隔时间
        sms_interval = settings.RONGLIANYUN["sms_interval"]
        # 调用第三方sdk发送短信
        # send_sms(settings.RONGLIANYUN.get("reg_tid"), mobile, datas=(code, time // 60))
        # 异步发送短信
        send_sms.delay(settings.RONGLIANYUN.get("reg_tid"), mobile, datas=(code, time // 60))

        # 记录code到redis中,并以time作为有效期
        # 使用redis提供的管道对象pipeline来优化redis的写入操作[添加/修改/删除]
        pipe = redis.pipeline()
        pipe.multi()  # 开启事务
        pipe.setex(f"sms_{mobile}", time, code)
        pipe.setex(f"interval_{mobile}", sms_interval, "_")
        pipe.execute()  # 提交事务,同时把暂存在pipeline的数据一次性提交给redis

        return Response({"errmsg": "OK"}, status=status.HTTP_200_OK)

终端下先启动celery,在django项目根目录下启动。

bash
cd ~/Desktop/luffycity/luffycityapi
# 1. 普通运行模式,关闭终端以后,celery就会停止运行
celery -A luffycityapi worker  -l INFO

# 2. 启动多worker进程模式,以守护进程的方式运行,不需要在意终端。但是这种运行模型,一旦停止,需要手动启动。
celery multi start worker -A luffycityapi -E --pidfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/worker1.pid" --logfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/celery.log" -l info -n worker1

# 3. 启动多worker进程模式
celery multi stop worker -A luffycityapi --pidfile="/home/moluo/Desktop/luffycity/luffycityapi/logs/worker1.pid"

还是可以在django终端下调用celery的

bash
$ python manage.py shell
>>> from users.tasks import send_sms1
>>> res = send_sms1.delay()
>>> res = send_sms1.apply_async(countdown=15)
>>> res.id
'893c31ab-e32f-44ee-a321-8b07e9483063'
>>> res.state
'SUCCESS'
>>> res.result

关于celery中异步任务发布的2个方法的参数如下:

python
异步任务名.delay(*arg, **kwargs)
异步任务名.apply_async((arg,), {'kwarg': value}, countdown=60, expires=120)

定时任务的调用器启动,可以在运行了worker以后,使用以下命令:

bash
cd ~/Desktop/luffycity/luffycityapi
celery -A luffycityapi beat

beat调度器关闭了,则定时任务无法执行,如果worker工作进程关闭了,则celery关闭,保存在消息队列中的任务就会囤积在那里。

image-20240222205622978

celery还有一些高阶用法, 我们后面用到再提。

celery后面还可以使用supervisor进行后台托管运行。还可以针对任务执行的情况和结果,使用flower来进行监控。celery失败任务的重新尝试执行。

supervisor会在celery以外关闭了以后,自动重启celery。

chapter 5 课程管理模块实现

5.1 课程列表页

前端显示课程列表页面

views/Course.vue,代码:

vue
<template>
  <div class="course">
    <Header></Header>
    <div class="top-wrap">
        <div class="actual-header">
            <div class="actual-header-wrap">
                <div class="banner">
                    <router-link class="title" to="/course"><img class="h100" src="../assets/coding-title.png" alt=""></router-link>
                    <div>真实项目实战演练</div>
                </div>
                <div class="actual-header-search">
                    <div class="search-inner">
                        <input class="actual-search-input" placeholder="搜索感兴趣的实战课程内容" type="text" autocomplete="off">
                        <img class="actual-search-button" src="../assets/search.svg" />
                    </div>
                    <div class="actual-searchtags">
                    </div>
                    <div class="search-hot">
                        <span>热搜:</span>
                        <a href="">Java工程师</a>
                        <a href="">Vue</a>
                    </div>
                </div>
            </div>
        </div>
        <div class="type">
            <div class="type-wrap">
                <div class="one warp">
                    <span class="name">方向:</span>
                    <ul class="items">
                        <li class="cur"><a href="">全部</a></li>
                        <li><a href="">前端开发</a></li>
                        <li><a href="">后端开发</a></li>
                        <li><a href="">移动开发</a></li>
                        <li><a href="">计算机基础</a></li>
                        <li><a href="">前沿技术</a></li>
                        <li><a href="">云计算&amp;大数据</a></li>
                        <li><a href="">运维&amp;测试</a></li>
                        <li><a href="">数据库</a></li>
                        <li><a href="">UI设计&amp;多媒体</a></li>
                        <li><a href="">游戏</a></li>
                        <li><a href="">求职面试</a></li>
                    </ul>
                </div>
                <div class="two warp">
                    <span class="name">分类:</span>
                    <ul class="items">
                        <li class="cur"><a href="">不限</a></li>
                        <li><a href="">Vue.js</a></li>
                        <li><a href="">Typescript</a></li>
                        <li><a href="">React.JS</a></li>
                        <li><a href="">HTML/CSS</a></li>
                        <li><a href="">JavaScript</a></li>
                        <li><a href="">Angular</a></li>
                        <li><a href="">Node.js</a></li>
                        <li><a href="">WebApp</a></li>
                        <li><a href="">小程序</a></li>
                        <li><a href="">前端工具</a></li>
                        <li><a href="">CSS</a></li>
                        <li><a href="">Html5</a></li>
                        <li><a href="">CSS3</a></li>
                    </ul>
                </div>
            </div>
        </div>
    </div>
    <div class="main">
        <div class="main-wrap">
            <div class="filter clearfix">
                <div class="sort l">
                  <a href="" class="on">最新</a>
                  <a href="">销量</a>
                  <a href="">升级</a>
                </div>
                <div class="other r clearfix"><a class="course-line l" href="" target="_blank">学习路线</a></div>
            </div>
            <ul class="course-list clearfix">
              <li class="course-card">
                <a target="_blank" href="">
                    <div class="img"><img src="../assets/course-1.png" alt=""></div>
                    <p class="title ellipsis2">全面的Docker 系统性入门+进阶实践(2021最新版)</p>
                    <p class="one">
                        <span>进阶 · 611人报名</span>
                        <span class="discount r"><i class="name">优惠价</i></span>
                    </p>
                    <p class="two clearfix">
                        <span class="price l red bold">¥428.00</span>
                        <span class="origin-price l delete-line">¥488.00</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </a>
              </li>
              <li class="course-card">
                <a target="_blank" href="">
                    <div class="img"><img src="../assets/course-2.png" alt=""></div>
                    <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>
                    <p class="one">
                        <span>进阶 · 246人报名</span>
                        <span class="discount r"><i class="name">限时优惠</i><i class="countdown">6<span class="day">天</span>01:39:21</i></span>
                    </p>
                    <p class="two clearfix">
                        <span class="price l red bold">¥328.00</span>
                        <span class="origin-price l delete-line">¥368.00</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </a>
              </li>
              <li class="course-card">
                <a target="_blank" href="">
                    <div class="img"><img src="../assets/course-3.png" alt=""></div>
                    <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>
                    <p class="one">
                        <span>进阶 · 246人报名</span>
                        <span class="discount r"><i class="name">限时优惠</i><i class="countdown">16<span class="day">天</span>01:39:21</i></span>
                    </p>
                    <p class="two clearfix">
                        <span class="price l red bold">¥328.00</span>
                        <span class="origin-price l delete-line">¥368.00</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </a>
              </li>
              <li class="course-card">
                <a target="_blank" href="">
                    <div class="img"><img src="../assets/course-4.png" alt=""></div>
                    <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>
                    <p class="one"><span>进阶 · 246人报名</span></p>
                    <p class="two clearfix">
                        <span class="price l red bold">¥399.00</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </a>
              </li>
              <li class="course-card">
                <a target="_blank" href="">
                    <div class="img"><img src="../assets/course-5.png" alt=""></div>
                    <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>
                    <p class="one"><span>进阶 · 246人报名</span></p>
                    <p class="two clearfix">
                        <span class="price l red bold">¥399.00</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </a>
              </li>
              
            </ul>
            <div class="page">
                <span class="disabled_page">首页</span>
                <span class="disabled_page">上一页</span>
                <a href="" class="active">1</a>
                <a href="">2</a>
                <a href="">3</a>
                <a href="">4</a>
                <a href="">下一页</a>
                <a href="">尾页</a>
            </div>
        </div>
    </div>
    <Footer></Footer>
  </div>
</template>

<script setup>
import {reactive,ref} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"

</script>

<style scoped>
.top-wrap {
	background-color: #f5f7fa;
	background-repeat: no-repeat;
	background-position: top center;
	background-size: cover
}
.actual-header{
  max-width: 1500px;
  margin: 0 auto;
}
.actual-header .actual-header-wrap {
	height: 100%;
	display: -webkit-box;
	display: -ms-flexbox;
	display: -webkit-flex;
	display: flex;
	-webkit-box-align: center;
	-ms-flex-align: center;
	-webkit-align-items: center;
	align-items: center;
	-webkit-box-pack: justify;
	-ms-flex-pack: justify;
	-webkit-justify-content: space-between;
	justify-content: space-between;
	padding-top: 8px
}

.actual-header .actual-header-wrap .banner {
	display: -webkit-box;
	display: -ms-flexbox;
	display: -webkit-flex;
	display: flex;
	-webkit-box-align: center;
	-ms-flex-align: center;
	-webkit-align-items: center;
	align-items: center
}

.actual-header .actual-header-wrap .banner .title {
	height: 46px;
	margin-right: 8px
}

.actual-header .actual-header-wrap .actual-header-search {
	position: relative;
	width: 320px
}

.actual-header .actual-header-wrap .actual-header-search .search-inner {
	width: 100%;
	border-radius: 4px;
	overflow: hidden;
	margin: 17px 0 7px;
	border: 1px solid rgba(84,92,99,.2)
}

.actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-input {
	width: 275px;
	font-size: 12px;
	color: #93999f;
	line-height: 24px;
	padding: 5px 12px;
	border: none;
	border-radius: 0;
	box-sizing: border-box;
	background: 0 0
}

.actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-input::-webkit-input-placeholder {
	color: #9199a1
}

.actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-input::-moz-placeholder {
	color: #9199a1
}

.actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-input:-moz-placeholder {
	color: #9199a1
}

.actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-input:-ms-input-placeholder {
	color: #9199a1
}

.actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-button {
  width: 26px;
  padding-top: 4px;
  padding-bottom: 4px;
  padding-right: 4px;
  padding-left: 6px;
  height: 26px;
  font-size: 18px;
  text-align: center;
  line-height: 26px;
	color: #fff;
  background-color: rgba(84,92,99,.2);
	cursor: pointer;
	border-top-right-radius: 4px;
	border-bottom-right-radius: 4px;
	float: right
}

.actual-header .actual-header-wrap .actual-header-search .actual-searchtags {
	position: absolute;
	right: 128px;
	top: 0;
	height: 48px;
	line-height: 48px;
	text-align: right
}

.actual-header .actual-header-wrap .actual-header-search .actual-searchtags a {
	margin-left: 24px;
	font-size: 12px;
	color: #4d555d;
	line-height: 48px
}

.actual-header .actual-header-wrap .actual-header-search .actual-searchtags a:hover {
	color: #f01414
}

.actual-header .actual-header-wrap .actual-header-search .actual-history-item a {
	float: left;
	font-size: 12px;
	color: rgba(7,17,27,.6);
	line-height: 16px;
	padding: 4px 12px;
	margin-right: 8px;
	background: rgba(7,17,27,.05);
	border-radius: 12px;
	transition: .3s background,color linear;
	margin-top: 8px
}

.actual-header .actual-header-wrap .actual-header-search .actual-history-item a:hover {
	background: rgba(7,17,27,.1);
	color: #07111b
}

.actual-header .actual-header-wrap .actual-header-search li {
	display: block;
	width: 100%;
	height: 48px;
	transition: .3s background linear;
	padding: 12px 16px;
	box-sizing: border-box;
	font-size: 14px;
	color: #4d555d;
	line-height: 24px;
	cursor: pointer;
	z-index: 1
}

.actual-header .actual-header-wrap .actual-header-search li:hover {
	background: #f3f5f7;
	color: #07111b
}

.actual-header .actual-header-wrap .actual-header-search .search-hot {
	height: 21px;
	overflow: hidden;
	padding-left: 14px
}

.actual-header .actual-header-wrap .actual-header-search .search-hot a,
.actual-header .actual-header-wrap .actual-header-search .search-hot span {
	color: rgba(84,92,99,.7);
	font-size: 12px;
	line-height: 16px
}

.actual-header .actual-header-wrap .actual-header-search .search-hot a {
	margin-right: 14px
}

.actual-header .actual-header-wrap .actual-header-search .search-hot a:last-child {
	margin-right: 0
}

.type {
  max-width: 1500px;
  margin: 0 auto;
	padding-bottom: 27px
}

.type .type-wrap {
	position: relative;
	height: 109px
}

.type .type-wrap .warp {
	display: -webkit-box;
	display: -ms-flexbox;
	display: -webkit-flex;
	display: flex;
	position: absolute;
	width: 1430px;
	height: 54px;
	overflow: hidden;
	padding: 10px;
	box-sizing: border-box;
	box-shadow: 0 12px 20px 0 rgba(95,101,105,0);
	border-radius: 8px;
	transition: all .2s
}

.type .type-wrap .warp.one {
	margin-bottom: 25px;
	z-index: 3
}

.type .type-wrap .warp.two {
	top: 59px;
	z-index: 2
}

.type .type-wrap .warp .name {
	width: 3em;
	color: #07111b;
	line-height: 32px;
	font-weight: 700;
	margin-right: 6px
}

.type .type-wrap .warp .items {
	width: 0;
	-webkit-box-flex: 1;
	-ms-flex: 1;
	-webkit-flex: 1;
	flex: 1
}

.type .type-wrap .warp .items li {
	float: left;
	line-height: 16px;
	padding: 8px;
	border-radius: 6px;
	margin: 0 12px 12px 0
}

.type .type-wrap .warp .items li a {
	color: #1c1f21
}

.type .type-wrap .warp .items li.cur {
	background-color: rgba(233,142,70,.1)
}

.type .type-wrap .warp .items li.cur a {
	color: #e98e46
}
.delete-line{
  text-decoration: line-through;
}
/******** 课程列表 ********/
.l{
  float: left;
}
.r{
  float: right;
}
.red{
  color: red;
}
.bold{
  font-weight: 700;
}
.main {
	margin-bottom: 60px
}
.main .main-wrap{
  max-width: 1500px;
  margin: 0 auto;
}
.clearfix:after {
	content: '';
	display: block;
	height: 0;
	clear: both;
	visibility: hidden
}

.main .filter {
	margin: 20px 0
}

.main .filter .sort {
	overflow: hidden
}

.main .filter .sort a {
	display: inline-block;
	float: left;
	font-size: 12px;
	color: #545c63;
	line-height: 16px;
	padding: 4px 12px;
	border-radius: 100px;
	margin-right: 12px
}

.main .filter .sort a:last-child {
	margin-right: 0
}

.main .filter .sort a.on {
	color: #fff;
	background-color: #545c63
}

.main .filter .other {
	font-size: 12px
}

.main .filter .other .course-line {
	color: #e98e46;
	line-height: 16px;
	padding: 4px 16px;
	border-radius: 100px;
	background-color: rgba(233,142,70,.1);
	margin-left: 24px
}

.main .course-list .course-card {
	position: relative;
	width: 270px;
	height: 270px;
	float: left;
	margin: 0 37px 20px 0;
	box-shadow: 0 4px 8px 0 rgba(95,101,105,.05);
	border-radius: 8px;
	background-color: #fff;
	transition: transform .2s,box-shadow .2s
}

.main .course-list .course-card:nth-child(5n) {
	margin-right: 0
}

.main .course-list .course-card:hover {
	transform: translateY(-2px);
	box-shadow: 0 12px 20px 0 rgba(95,101,105,.1)
}

.main .course-list .course-card a {
	display: inline-block;
	width: 100%
}

.main .course-list .course-card .img {
	height: 152px;
	background: no-repeat center/cover;
	margin-bottom: 8px;
	border-radius: 8px 8px 0 0;
	overflow: hidden
}

.main .course-list .course-card .title {
	color: #545c63;
	line-height: 20px;
	height: 40px;
	margin-bottom: 8px;
	padding: 0 8px
}

.main .course-list .course-card .title.ellipsis2 {
	overflow: hidden;
	text-overflow: ellipsis;
	display: -webkit-box;
	-webkit-line-clamp: 2;
	-webkit-box-orient: vertical
}

.main .course-list .course-card .one,
.main .course-list .course-card .two {
	font-size: 12px;
	color: #9199a1;
	line-height: 18px;
	padding: 0 8px;
	margin-bottom: 8px
}

.main .course-list .course-card .one .add-shop-cart .icon,
.main .course-list .course-card .one .star .icon,
.main .course-list .course-card .two .add-shop-cart .icon,
.main .course-list .course-card .two .star .icon {
	display: inline-block;
	margin-right: 2px;
	font-size: 14px
}
.imv2-shopping-cart{
  width: 14px;
}
.main .course-list .course-card .one .add-shop-cart.add-shop-cart,
.main .course-list .course-card .one .add-shop-cart.stared,
.main .course-list .course-card .one .star.add-shop-cart,
.main .course-list .course-card .one .star.stared,
.main .course-list .course-card .two .add-shop-cart.add-shop-cart,
.main .course-list .course-card .two .add-shop-cart.stared,
.main .course-list .course-card .two .star.add-shop-cart,
.main .course-list .course-card .two .star.stared {
	color: #ff655d
}


.main .course-list .course-card .one .discount i,
.main .course-list .course-card .two .discount i {
	font-style: normal;
	padding: 3px 4px
}

.main .course-list .course-card .one .discount i.name,
.main .course-list .course-card .two .discount i.name {
	color: #fff;
	background-color: rgba(242,13,13,.6)
}

.main .course-list .course-card .one .price,
.main .course-list .course-card .two .price {
	line-height: 20px;
	margin-right: 2px
}

.main .course-list .course-card .one .discount,
.main .course-list .course-card .two .discount {
	border: 1px solid rgba(242,13,13,.2);
	border-radius: 2px;
	font-size: 12px;
	line-height: 1;
	margin-right: 4px;
	overflow: hidden;
	display: -webkit-box;
	display: -ms-flexbox;
	display: -webkit-flex;
	display: flex;
	-webkit-box-align: center;
	-ms-flex-align: center;
	-webkit-align-items: center;
	align-items: center
}

.main .course-list .course-card .one .discount i,
.main .course-list .course-card .two .discount i {
	font-style: normal;
	padding: 3px 4px
}

.main .course-list .course-card .one .discount i.name,
.main .course-list .course-card .two .discount i.name {
	color: #fff;
	background-color: rgba(242,13,13,.6)
}

.main .course-list .course-card .one .discount i.countdown,
.main .course-list .course-card .two .discount i.countdown {
	display: flex;
	font-family: DINCondensed,'微软雅黑';
	color: #f76e6e;
	padding-top: 4px;
	padding-bottom: 2px
}

.main .course-list .course-card .one .discount i.countdown .day,
.main .course-list .course-card .two .discount i.countdown .day {
	display: inline-block;
	width: 12px;
	height: 12px;
  transform:scale(0.8);
}


/**** 页码 *****/
.page {
	margin: 25px 0 auto;
	overflow: hidden;
	clear: both;
	text-align: center
}

.page a {
	display: inline-block;
	margin: 0 12px;
	width: 36px;
	height: 36px;
	line-height: 36px;
	font-size: 14px;
	color: #4d555d;
	text-align: center;
	border-radius: 50%;
	-webkit-transition: border-color .2s;
	-moz-transition: border-color .2s;
	transition: border-color .2s
}

.page a:hover {
	text-decoration: none;
	background-color: #d9dde1
}

.page a.active {
	background: #4d555d;
	color: #fff
}

.page a:first-child,
.page a:last-child,
.page a:nth-child(2),
.page a:nth-last-child(2) {
	width: auto
}

.page a:first-child:hover,
.page a:last-child:hover,
.page a:nth-child(2):hover,
.page a:nth-last-child(2):hover {
	background-color: transparent
}

.page span {
	display: inline-block;
	padding: 0 12px;
	min-width: 20px;
	height: 39px;
	line-height: 39px;
	font-size: 14px;
	color: #93999f;
	text-align: center
}
</style>

注册路由,src/router/index.js,代码:

js
import {createRouter, createWebHistory} from 'vue-router'
import store from '../store/index'

// 路由列表
const routes = [
    {
        meta: {
            title: "luffy2.0-首页",
            keepAlive: true
        },
        path: '/home/',         // url访问地址
        name: "Home",
        component: () => import("../views/Home.vue")  // 记得()=>!!!
    },
    {
        meta: {
            title: "luffy2.0-登陆首页",
            keepAlive: true
        },
        path: '/login/',         // uri访问地址
        name: "Login",
        component: () => import("../views/Login.vue")  // 记得()=>!!!
    },

    {
        path: '/test/',         // uri访问地址
        name: "test",
        component: () => import("../views/test.vue")  // 记得()=>!!!
    },
    {
        meta: {
            title: "luffy2.0-个人中心",
            keepAlive: true,
            authorization: true
        },
        path: '/user/',         // uri访问地址
        name: "User",
        component: () => import("../views/User.vue")  // 记得()=>!!!
    },
    {
        meta: {
            title: "luffy2.0-注册",
            keepAlive: true,

        },
        path: '/register/',         // uri访问地址
        name: "Register",
        component: () => import("../views/Register.vue")  // 记得()=>!!!
    },
    {
        meta: {
            title: "课程页面",
            keepAlive: true,

        },
        path: '/course/',         // uri访问地址
        name: "Course",
        component: () => import("../views/Course.vue")  // 记得()=>!!!
    },


]

// 路由对象实例化
const router = createRouter({
    history: createWebHistory(),
    routes: routes,
});
router.beforeEach((to, from, next) => {
    if (to.meta.authorization && !store.getters.getUserInfo) {
        next({name: 'Login'})
    } else {
        next()
    }
})

// 暴露路由对象
export default router

5.1.1课程功能管理的设计

分析课程列表页面中的出现的数据之间的关系

学习方向:
课程分类:
课程信息:
课程章节:
课时信息:
老师信息:
价格策略:(限时免费\限时折扣\限时满减\原价)
优惠券/积分:

E-R图

E-R图描述的是数据库设计过程中,实体与实体之间的关系的,实体与属性之间的关联的。

矩形表示实体,所谓的实体就是可以相互区分的,独立的事物。实体在数据库中会被转换成数据表。

椭圆形表示属性,用于描述实体的特征。实体的属性在数据库中会被转换成数据表中的字段。

菱形则表示实体之间的关系,根据范式理论第三条,实体之间的关系存在如下:

1:N 1对多

1:1 1对1

N:M 多对多

image-20240223182925055

image-20240223182942887

UML图

用navicat画的,点上边栏的模型,选择物理模型。

物理模型(根据具体数据库来设计的,powerdesigner、navicat)

image-20240223183642108

合并分支打标签

bash
# 确认前面功能已经开发完整,review代码结束,想公司申请合并分支,开发合并分支
cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 展示课程列表页"
git push
git checkout master
git merge feature/user
git branch -d feature/user
# 查看线上本地所有的分支列表,可以看到本地的feature/user分支已经删除,但是线上的依然存在。
git branch --all
# 本地删除了分支以后,线上分支也要同步一下。
git push origin --delete feature/user
# 因为属于一个较大功能的开发合并,往往项目中都会打一个标签
git tag v0.0.3
# 提交标签版本
git push --tag
# git push origin v0.0.3

5.1.2 课程子应用创建

bash
cd luffycity
django-admin startapp course apps/course

5.1.3 注册子应用

settings.dev,代码:

python
INSTALLED_APPS = [
		...
    'courses',
]

注册子路由,apps/course/urls.py,代码:

python
from django.urls import path

urlpatterns = [
    # path('admin/', admin.site.urls),
]

注册总路由,luffycity/urls.py

python
from django.contrib import admin
from django.urls import path, include,re_path
from django.conf import settings
from django.views.static import serve  # 静态文件代理访问模块

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('api.urls')),
    path('users/', include('users.urls')),
    path('course/', include('course.urls')),
    re_path(r'uploads/(?P<path>.*)', serve, {"document_root": settings.MEDIA_ROOT}),
]

5.1.4 数据模型创建

apps/courses/models.py,代码:

python
from luffycityapi.utils.models import models,BaseModel

# Create your models here.
class CourseDirection(BaseModel):
    name = models.CharField(max_length=255, unique=True, verbose_name="方向名称")
    remark = models.TextField(default="", blank=True, null=True, verbose_name="方向描述")
    recomment_home_hot = models.BooleanField(default=False, verbose_name="是否推荐到首页新课栏目")
    recomment_home_top = models.BooleanField(default=False, verbose_name="是否推荐到首页必学栏目")
    class Meta:
        db_table = "fg_course_direction"
        verbose_name = "学习方向"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name

class CourseCategory(BaseModel):
    name = models.CharField(max_length=255, unique=True, verbose_name="分类名称")
    remark = models.TextField(default="", blank=True, null=True, verbose_name="分类描述")
    direction = models.ForeignKey("CourseDirection", related_name="category_list", on_delete=models.DO_NOTHING, db_constraint=False, verbose_name="学习方向")

    class Meta:
        db_table = "fg_course_category"
        verbose_name = "课程分类"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name

class Course(BaseModel):
    course_type = (
        (0, '付费购买'),
        (1, '会员专享'),
        (2, '学位课程'),
    )
    level_choices = (
        (0, '初级'),
        (1, '中级'),
        (2, '高级'),
    )
    status_choices = (
        (0, '上线'),
        (1, '下线'),
        (2, '预上线'),
    )
    course_cover = models.ImageField(upload_to="course/cover", max_length=255, verbose_name="封面图片", blank=True, null=True)
    course_video = models.FileField(upload_to="course/video", max_length=255, verbose_name="封面视频", blank=True, null=True)
    course_type = models.SmallIntegerField(choices=course_type,default=0, verbose_name="付费类型")
    level = models.SmallIntegerField(choices=level_choices, default=1, verbose_name="难度等级")
    description = models.TextField(null=True, blank=True, verbose_name="详情介绍")
    pub_date = models.DateField(auto_now_add=True, verbose_name="发布日期")
    period = models.IntegerField(default=7, verbose_name="建议学习周期(day)")
    attachment_path = models.FileField(max_length=1000, blank=True, null=True, verbose_name="课件路径")
    attachment_link = models.CharField(max_length=1000, blank=True, null=True, verbose_name="课件链接")
    status = models.SmallIntegerField(choices=status_choices, default=0, verbose_name="课程状态")
    students = models.IntegerField(default=0, verbose_name="学习人数")
    lessons = models.IntegerField(default=0, verbose_name="总课时数量")
    pub_lessons = models.IntegerField(default=0, verbose_name="已更新课时数量")
    price = models.DecimalField(max_digits=10,decimal_places=2, verbose_name="课程原价",default=0)
    recomment_home_hot = models.BooleanField(default=False, verbose_name="是否推荐到首页新课栏目")
    recomment_home_top = models.BooleanField(default=False, verbose_name="是否推荐到首页必学栏目")
    direction = models.ForeignKey("CourseDirection", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="学习方向")
    category = models.ForeignKey("CourseCategory", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="课程分类")
    teacher = models.ForeignKey("Teacher", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="授课老师")

    class Meta:
        db_table = "fg_course_info"
        verbose_name = "课程信息"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s" % self.name

class Teacher(BaseModel):
    role_choices = (
        (0, '讲师'),
        (1, '导师'),
        (2, '班主任'),
    )

    role = models.SmallIntegerField(choices=role_choices, default=0, verbose_name="讲师身份")
    title = models.CharField(max_length=64, verbose_name="职位、职称")
    signature = models.CharField(max_length=255, blank=True, null=True, verbose_name="导师签名")
    avatar = models.ImageField(upload_to="teacher", null=True, verbose_name="讲师头像")
    brief = models.TextField(max_length=1024, verbose_name="讲师描述")

    class Meta:
        db_table = "fg_teacher"
        verbose_name = "讲师信息"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s" % self.name

class CourseChapter(BaseModel):
    """课程章节"""
    orders = models.SmallIntegerField(default=1, verbose_name="第几章")
    summary = models.TextField(blank=True, null=True, verbose_name="章节介绍")
    pub_date = models.DateField(auto_now_add=True, verbose_name="发布日期")
    course = models.ForeignKey("Course", related_name='chapter_list', on_delete=models.CASCADE, db_constraint=False, verbose_name="课程名称")

    class Meta:
        db_table = "fg_course_chapter"
        verbose_name = "课程章节"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s-第%s章-%s" % (self.course.name, self.orders, self.name)


class CourseLesson(BaseModel):
    """课程课时"""
    lesson_type_choices = (
        (0, '文档'),
        (1, '练习'),
        (2, '视频'),
    )

    orders = models.SmallIntegerField(default=1, verbose_name="第几节")
    lesson_type = models.SmallIntegerField(default=2, choices=lesson_type_choices, verbose_name="课时种类")
    lesson_link = models.CharField(max_length=255, blank=True, null=True, help_text="若是video,填视频地址或者视频id,若是文档,填文档地址", verbose_name="课时链接")
    duration = models.CharField(blank=True, null=True, max_length=32, verbose_name="课时时长")  # 仅在前端展示使用
    pub_date = models.DateTimeField(auto_now_add=True, verbose_name="发布时间")
    free_trail = models.BooleanField(default=False, verbose_name="是否可试看")
    recomment = models.BooleanField(default=False, verbose_name="是否推荐到课程列表")
    chapter = models.ForeignKey("CourseChapter", related_name='lesson_list', on_delete=models.CASCADE, db_constraint=False, verbose_name="章节")
    course = models.ForeignKey("Course", related_name="lesson_list", on_delete=models.DO_NOTHING, db_constraint=False, verbose_name="课程")

    class Meta:
        db_table = "fg_course_lesson"
        verbose_name = "课程课时"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s-%s" % (self.chapter, self.name)

执行数据迁移

bash
cd <项目目>
python manage.py makemigrations
python manage.py migrate

因为我们一次性创建6个数据表,所以我们需要提供一个后台运营站点给将来工作人员添加对应的数据。当然我们现在也要添加数据,所以这里,我们采用simpleui来美化django内置的admin站点。

提交版本

python
cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 课程子应用与课程相关模型的创建"

5.1.5 simpleui美化admin站点

官网:https://simpleui.72wo.com/simpleui/

simpleui 免费版本

simplePro 收费版本

安装simpleui

bash
pip install django-simpleui

注册simpleui,admin界面美化,必须写在admin上面!!!!!

settings/dev.py,代码:

python
INSTALLED_APPS = [
    'simpleui',           # admin界面美化,必须写在admin上面!!!!!!!!
    'django.contrib.admin', 

    # ...
]

把当前新增的课程的相关模型注册到admin里面.simpleUI仅仅是修改了admin站点的外观效果以及新增了部分配置功能,原有的admin站点的所有功能,simpleUI都没有进行改动或者删减。

courses/apps.py,代码:

python
from django.apps import AppConfig

class CoursesConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'courses'
    verbose_name="课程管理"           #不写verbose_name,就默认按照app的名字命名.
    verbose_name_plural = verbose_name

users/apps.py,代码:

python
from django.apps import AppConfig


class UsersConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'users'
    verbose_name="用户管理"
    verbose_name_plural = verbose_name

courses/admin.py,代码:

python
from django.contrib import admin
from .models import CourseDirection, CourseCategory, Course, Teacher, CourseChapter, CourseLesson


# Register your models here.
class CourseDirectionModelAdmin(admin.ModelAdmin):
    """学习方向的模型管理器"""
    pass


admin.site.register(CourseDirection, CourseDirectionModelAdmin)


class CourseCategoryModelAdmin(admin.ModelAdmin):
    """课程分类的模型管理器"""
    pass


admin.site.register(CourseCategory, CourseCategoryModelAdmin)


class CourseModelAdmin(admin.ModelAdmin):
    """课程信息的模型管理器"""
    pass


admin.site.register(Course, CourseModelAdmin)


class TeacherModelAdmin(admin.ModelAdmin):
    """讲师信息的模型管理器"""
    pass


admin.site.register(Teacher, TeacherModelAdmin)


class CourseChapterModelAdmin(admin.ModelAdmin):
    """课程章节的模型管理器"""
    pass


admin.site.register(CourseChapter, CourseChapterModelAdmin)


class CourseLessonModelAdmin(admin.ModelAdmin):
    """课程课时的模型管理器"""
    pass


admin.site.register(CourseLesson, CourseLessonModelAdmin)

users/admin.py,代码:

python
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User
# Register your models here.


class UserModelAdmin(UserAdmin):
    pass


admin.site.register(User, UserModelAdmin)

5.1.6 Admin站点配置

公共配置,settings/dev.py,代码:

python
# admin站点公共配置
from django.contrib import admin
admin.AdminSite.site_header = '浮光在线'
admin.AdminSite.site_title = '浮光在线教育站点管理'

# 登录界面logo
SIMPLEUI_LOGO = '/uploads/logo.png'
# 快速操作
SIMPLEUI_HOME_QUICK = True
# 服务器信息
SIMPLEUI_HOME_INFO = True

# 关闭simpleui内置的使用分析
SIMPLEUI_ANALYSIS = False
# 离线模式
SIMPLEUI_STATIC_OFFLINE = True
# 首页图标地址
SIMPLEUI_INDEX = 'http://www.luffycity.cn:3000/'

5.1.7 给admin后台站点添加富文本编辑器

所谓的富文本编辑器,实际上就是前端javasctript实现的页面插件,这个插件允许我们替换多行文本框,让使用者可以在不懂html、css的情况下,也能像使用word编写文章那样,对页面的部分内容进行图文排版。

常见的富文本编辑器插件:

ckeditor:https://ckeditor.com/ckeditor-5/demo/

kindeditor:http://kindeditor.net/demo.php

我们在django中一般使用就是ckeditor编辑器,可以通过pip安装。

django-ckeditor:https://github.com/django-ckeditor/django-ckeditor

bash
pip install django-ckeditor

注册到项目中,settings/dev.py,代码:

python
# ckeditor富文本编辑器配置
INSTALLED_APPS = [
    'simpleui', # admin界面美化,必须写在admin上面
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    "rest_framework",  # 注意:记得加入 rest_framework
    'corsheaders',  # cors跨域子应用
    'ckeditor',   # 富文本编辑器

    "home",
    'users',
    'courses',
]

# 上传文件的存储路径
CKEDITOR_UPLOAD_PATH = "ckeditor/"

# 工具条配置
CKEDITOR_CONFIGS = {
    'default': {
        # 'toolbar': 'full', # full 显示全部工具
        # 'toolbar': 'Basic', # Basic 显示基本工具
        'toolbar': 'Custom',  # 自定义工具条的显示数量
        'toolbar_Custom': [
            ['Bold', 'Italic', 'Underline', 'Image', 'Styles', 'Format', 'Font', 'Fontsize'],
            ['NumberedList', 'BulletedList', '-', 'Outdent', 'Indent', '-', 'JustifyLeft', 'JustifyCenter',
             'JustifyRight', 'JustifyBlock'],
            ['Link', 'Unlink', 'Table'],
            ['RemoveFormat', 'Source']
        ],
        # 设置编辑器的高度
        'height': 120,
    },
}

总路由,luffycity/urls,代码:

python
urlpatterns = [
    # ...
    path('ckeditor/', include('ckeditor_uploader.urls')),
    # ...
]

经过上面的配置以后,我们就可以让admin站点在显示模型管理器中的字段时,把models.TextField字段换成富文本字段。

ckeditor安装成功可以允许开发者在模型中设置2个富文本字段。

python
# 不支持上传文件
from ckeditor.fields import RichTextField
# 支持上传文件
from ckeditor_uploader.fields import RichTextUploadingField

# 原来的models.TextField字段中所有的设置信息全部不需要改动,因为上面这2个字段都是models.TextField的子类。

所以我们现在可以把课程相关模型所有的models.TextField字段替换成富文本字段了。

apps/courses/models.py,代码:

python
from utils.Mymodels import models, BaseModel
from ckeditor_uploader.fields import RichTextUploadingField  # 包含上传文件


# Create your models here.
class CourseDirection(BaseModel):
    name = models.CharField(max_length=255, unique=True, verbose_name="方向名称")
    remark = RichTextUploadingField(verbose_name="内容", config_name='default')
    recommend_home_hot = models.BooleanField(default=False, verbose_name="是否推荐到首页新课栏目")
    recommend_home_top = models.BooleanField(default=False, verbose_name="是否推荐到首页必学栏目")

    class Meta:
        db_table = "fg_course_direction"
        verbose_name = "学习方向"
        verbose_name_plural = verbose_name


class CourseCategory(BaseModel):
    name = models.CharField(max_length=255, unique=True, verbose_name="分类名称")
    remark = RichTextUploadingField(default="", blank=True, null=True, verbose_name="分类描述",config_name='default')
    direction = models.ForeignKey("CourseDirection", related_name="category_list", db_constraint=False,
                                  on_delete=models.DO_NOTHING, verbose_name="学习方向")

    class Meta:
        db_table = "fg_course_category"
        verbose_name = "课程分类"
        verbose_name_plural = verbose_name


class Course(BaseModel):
    course_type = (
        (0, '付费购买'),
        (1, '会员专享'),
        (2, '学位课程'),
    )
    level_choices = (
        (0, '初级'),
        (1, '中级'),
        (2, '高级'),
    )
    status_choices = (
        (0, '上线'),
        (1, '下线'),
        (2, '预上线'),
    )
    course_cover = models.ImageField(upload_to="course/cover", max_length=255, verbose_name="封面图片", blank=True,
                                     null=True)
    course_video = models.FileField(upload_to="course/video", max_length=255, verbose_name="封面视频", blank=True,
                                    null=True)
    course_type = models.SmallIntegerField(choices=course_type, default=0, verbose_name="付费类型")
    level = models.SmallIntegerField(choices=level_choices, default=1, verbose_name="难度等级")
    description = RichTextUploadingField(null=True, blank=True, verbose_name="详情介绍",config_name='default')
    pub_date = models.DateField(auto_now_add=True, verbose_name="发布日期")
    period = models.IntegerField(default=7, verbose_name="建议学习周期(day)")
    attachment_path = models.FileField(max_length=1000, blank=True, null=True, verbose_name="课件路径")
    attachment_link = models.CharField(max_length=1000, blank=True, null=True, verbose_name="课件链接")
    status = models.SmallIntegerField(choices=status_choices, default=0, verbose_name="课程状态")
    students = models.IntegerField(default=0, verbose_name="学习人数")
    lessons = models.IntegerField(default=0, verbose_name="总课时数量")
    pub_lessons = models.IntegerField(default=0, verbose_name="已更新课时数量")
    price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="课程原价", default=0)
    recommend_home_hot = models.BooleanField(default=False, verbose_name="是否推荐到首页新课栏目")
    recommend_home_top = models.BooleanField(default=False, verbose_name="是否推荐到首页必学栏目")
    direction = models.ForeignKey("CourseDirection", related_name="course_list", on_delete=models.DO_NOTHING, null=True,
                                  blank=True, db_constraint=False, verbose_name="学习方向")
    category = models.ForeignKey("CourseCategory", related_name="course_list", on_delete=models.DO_NOTHING, null=True,
                                 blank=True, db_constraint=False, verbose_name="课程分类")
    teacher = models.ForeignKey("Teacher", related_name="course_list", on_delete=models.DO_NOTHING, null=True,
                                blank=True, db_constraint=False, verbose_name="授课老师")

    class Meta:
        db_table = "fg_course_info"
        verbose_name = "课程信息"
        verbose_name_plural = verbose_name


class Teacher(BaseModel):
    role_choices = (
        (0, '讲师'),
        (1, '导师'),
        (2, '班主任'),
    )

    role = models.SmallIntegerField(choices=role_choices, default=0, verbose_name="讲师身份")
    title = models.CharField(max_length=64, verbose_name="职位、职称")
    signature = models.CharField(max_length=255, blank=True, null=True, verbose_name="导师签名")
    avatar = models.ImageField(upload_to="teacher", null=True, verbose_name="讲师头像")
    brief = RichTextUploadingField(max_length=1024, verbose_name="讲师描述",config_name='default')

    class Meta:
        db_table = "fg_teacher"
        verbose_name = "讲师信息"
        verbose_name_plural = verbose_name


class CourseChapter(BaseModel):
    """课程章节"""
    orders = models.SmallIntegerField(default=1, verbose_name="第几章")
    summary = RichTextUploadingField(blank=True, null=True, verbose_name="章节介绍",config_name='default')
    pub_date = models.DateField(auto_now_add=True, verbose_name="发布日期")
    course = models.ForeignKey("Course", related_name='chapter_list', on_delete=models.CASCADE, db_constraint=False,
                               verbose_name="课程名称")

    class Meta:
        db_table = "fg_course_chapter"
        verbose_name = "课程章节"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s-第%s章-%s" % (self.course.name, self.orders, self.name)


class CourseLesson(BaseModel):
    """课程课时"""
    lesson_type_choices = (
        (0, '文档'),
        (1, '练习'),
        (2, '视频'),
    )

    orders = models.SmallIntegerField(default=1, verbose_name="第几课时")
    lesson_type = models.SmallIntegerField(default=2, choices=lesson_type_choices, verbose_name="课时种类")
    lesson_link = models.CharField(max_length=255, blank=True, null=True, help_text="若是video,填视频地址或者视频id,若是文档,填文档地址",
                                   verbose_name="课时链接")
    duration = models.CharField(blank=True, null=True, max_length=32, verbose_name="课时时长")  # 仅在前端展示使用
    pub_date = models.DateTimeField(auto_now_add=True, verbose_name="发布时间")
    free_trail = models.BooleanField(default=False, verbose_name="是否可试看")
    recommend = models.BooleanField(default=False, verbose_name="是否推荐到课程列表")
    chapter = models.ForeignKey("CourseChapter", related_name='lesson_list', on_delete=models.CASCADE,
                                db_constraint=False, verbose_name="章节")
    course = models.ForeignKey("Course", related_name="lesson_list", on_delete=models.DO_NOTHING, db_constraint=False,
                               verbose_name="课程")

    class Meta:
        db_table = "fg_course_lesson"
        verbose_name = "课程课时"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s-第%s章-%s-第%s课时-%s" % (
            self.course.name, self.orders, self.chapter.name, self.orders, self.name)

因为这个字段属于ckeditor修改前端外观的而已,所以对于ORM而言,本质上来说还是models.TextField字段,所以不用执行数据迁移。

课程相关模型的admin站点配置在列表页中展示模型字段,

admin.ModelAdmin写法:

https://www.cnblogs.com/wupeiqi/articles/7444717.html ,佩奇笔记。

**https://www.cnblogs.com/Neeo/articles/9205439.html,开哥笔记**。

apps/courses/admin.py,代码:

python
from django.contrib import admin
from .models import CourseDirection, CourseCategory, Course, Teacher, CourseChapter, CourseLesson


class CourseCategoryInLine(admin.StackedInline):
    """课程分类的内嵌类"""
    model = CourseCategory
    fields = ["id", "name", "orders"]


class CourseDirectionModelAdmin(admin.ModelAdmin):
    """学习方向的模型管理器"""
    list_display = ["id", "name", "recommend_home_hot", "recommend_home_top"]
    # 默认排序字段
    ordering = ["id"]
    # 字段过滤
    list_filter = ["recommend_home_hot", "recommend_home_top"]
    # 搜索字段
    search_fields = ["name"]
    # 内嵌外键数据
    inlines = [CourseCategoryInLine, ]  #在添加一个学习方向时可以同时添加几个课程分类!!!
    # 分页配置,一夜数据量
    list_per_page = 10


admin.site.register(CourseDirection, CourseDirectionModelAdmin)


class CourseCategoryModelAdmin(admin.ModelAdmin):
    """课程分类的模型管理器"""
    # pass
    #
    list_display = ["id", "name", "direction"]
    ordering = ["id"]
    list_filter = ["direction"]
    search_fields = ["name"]
    # 分页配置,一页数据量
    list_per_page = 10
    # 更新数据时的表单配置项,数据点进去之后修改时显示的字段及其分组!!!
    fieldsets = (
        ("必填", {'fields': ('name', 'direction', 'remark')}),
        ("选填", {
            'classes': ('collapse',),
            'fields': ('is_show', 'orders'),
        }),
    )
    # 添加数据时的表单配置项,点击add键后,添加数据时显示的字段
    add_fieldsets = (
        (None, {
          	#classes 键用于指定样式类,这里使用了 ('wide',),表示该字段集合应该使用宽度布局。
            'classes': ('wide',),  
            'fields': ('name', 'direction', 'remark'),
        }),
    )

    # 当前方法会在显示表单的时候,自动执行,返回值就是表单配置项
    def get_fieldsets(self, request, obj=None):
        """
        获取表单配置项
        :param request: 客户端的http请求对象
        :param obj:     本次修改的模型对象,如果是添加数据操作,则obj为None
        :return:
        """
        if not obj:
            return self.add_fieldsets
        return super().get_fieldsets(request, obj)


admin.site.register(CourseCategory, CourseCategoryModelAdmin)


class CourseModelAdmin(admin.ModelAdmin):
    """课程信息的模型管理器"""
    list_display = ["id", "name", 'course_cover', "course_type", "level", "pub_date", "students", "lessons", "price"]
    # 分页配置,一夜数据量
    list_per_page = 10


admin.site.register(Course, CourseModelAdmin)


class TeacherModelAdmin(admin.ModelAdmin):
    """讲师信息的模型管理器"""
    list_display = ["id", "name", "avatar", "title", "role", "signature"]
    # 分页配置,一夜数据量
    list_per_page = 10
    # 搜索字段
    search_fields = ["name", "title", "role", "signature"]


admin.site.register(Teacher, TeacherModelAdmin)


class CourseChapterModelAdmin(admin.ModelAdmin):
    """课程章节的模型管理器"""
    list_display = ["id", "pub_date", ]  # text删除了
    # 分页配置,一夜数据量
    list_per_page = 10


admin.site.register(CourseChapter, CourseChapterModelAdmin)


class CourseLessonModelAdmin(admin.ModelAdmin):
    """课程课时的模型管理器"""
    list_display = ["id",   "lesson_type", "duration", "pub_date", "free_trail"]
    # 分页配置,一夜数据量
    list_per_page = 10

    # 下面是旧版本写法,django2.0版本  ->  django3.0以后,建议在模型中声明自定义字段  ---> text2属于新版本写法
    def text(self, obj):
        return obj.__str__()

    text.admin_order_field = "orders"
    text.short_description = "课时名称"
    # pass


admin.site.register(CourseLesson, CourseLessonModelAdmin)

提交版本

python
cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 安装配置simpleUI美化admin站点并使用富文本编辑器增强多行文本框"
# git push
git push --set-upstream origin feature/course

作业:在admin站点展示用户模型相关数据。

5.1.8 Admin站点关联外键数据

一个学习方向下面有多个课程分类,如果我们希望在查看或编辑某个学习方向的信息时,希望Admin站点在显示学习方向的信息的同时也一同显示并编辑同属该方向下的所有课程分类信息。我们可以使用django.admin提供的 TabularInline 和 StackedInline 内嵌类来实现。这2个类的使用一样,不同的是排版效果:
	TabularInline让外键对应的数据横向排列(表格的一行),
	StackedInline让外键对应的数据竖着排(表单格式)。

courses.admin,代码:

python
class CourseCategoryInLine(admin.StackedInline):
    """课程分类的内嵌类"""
    model = CourseCategory
    fields = ["id","name","orders"]

class CourseDirectionModelAdmin(admin.ModelAdmin):
    """学习方向的模型管理器"""
    list_display = ["id","name","recommend_home_hot","recommend_home_top"]
    ordering = ["id"]
    list_filter = ["recommend_home_hot","recommend_home_top"]
    search_fields = ["name"]
    # 内嵌外键数据
    inlines = [CourseCategoryInLine, ]

5.1.9 给图片字段生成缩略图

在项目开发中,经常会遇到需要以图片的方式来展示商品/课程/物品/人物,但是如果每次展示的图片都是高清图片,则客户端访问页面时,下载这个图片就占据我们服务端的一定的网络资源,因为高清图片往往比较大,也可以影响用户访问页面的速度。让项目在列表中展示缩略图即可,真正的高清图直接在详情页中展示。

bash
pip install django-stdimage

注册子应用,settings.dev,代码:

python
INSTALLED_APPS = [
     
    'stdimage',    # 生成缩略图
    
]

apps/courses/models.py,代码:

python
from utils.Mymodels import BaseModel, models
from ckeditor_uploader.fields import RichTextUploadingField
from stdimage import StdImageField
from django.utils.safestring import mark_safe


class CourseDirection(BaseModel):
    name = models.CharField(max_length=255, unique=True, verbose_name="方向名称")
    remark = RichTextUploadingField(default="", blank=True, null=True, verbose_name="方向描述")
    recommend_home_hot = models.BooleanField(default=False, verbose_name="是否推荐到首页新课栏目")
    recommend_home_top = models.BooleanField(default=False, verbose_name="是否推荐到首页必学栏目")

    class Meta:
        db_table = "fg_course_direction"
        verbose_name = "学习方向"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name


class CourseCategory(BaseModel):
    name = models.CharField(max_length=255, unique=True, verbose_name="分类名称")
    remark = RichTextUploadingField(default="", blank=True, null=True, verbose_name="分类描述")
    # related_name 反向引用属性名称
    # 数据库外键设置为虚拟外键:db_constraint=False
    direction = models.ForeignKey("CourseDirection", related_name="category_list", on_delete=models.DO_NOTHING,
                                  db_constraint=False, verbose_name="学习方向")

    class Meta:
        db_table = "fg_course_category"
        verbose_name = "课程分类"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name


class Course(BaseModel):
    course_type = (
        (0, '付费购买'),
        (1, '会员专享'),
        (2, '学位课程'),
    )
    level_choices = (
        (0, '初级'),
        (1, '中级'),
        (2, '高级'),
    )
    status_choices = (
        (0, '上线'),
        (1, '下线'),
        (2, '预上线'),
    )
    # course_cover = models.ImageField(upload_to="course/cover", max_length=255, verbose_name="封面图片", blank=True, null=True)
    course_cover = StdImageField(variations={
        'thumb_1080x608': (1080, 608),  # 高清图
        'thumb_540x304': (540, 304),  # 中等比例,
        'thumb_108x61': (108, 61, True),  # 小图(第三个参数表示保持图片质量),
    }, max_length=255, delete_orphans=True, upload_to="course/cover", null=True, verbose_name="封面图片", blank=True)

    course_video = models.FileField(upload_to="course/video", max_length=255, verbose_name="封面视频", blank=True,
                                    null=True)
    course_type = models.SmallIntegerField(choices=course_type, default=0, verbose_name="付费类型")
    level = models.SmallIntegerField(choices=level_choices, default=1, verbose_name="难度等级")
    description = RichTextUploadingField(null=True, blank=True, verbose_name="详情介绍")
    pub_date = models.DateField(auto_now_add=True, verbose_name="发布日期")
    period = models.IntegerField(default=7, verbose_name="建议学习周期(day)")
    attachment_path = models.FileField(max_length=1000, blank=True, null=True, verbose_name="课件路径")
    attachment_link = models.CharField(max_length=1000, blank=True, null=True, verbose_name="课件链接")
    status = models.SmallIntegerField(choices=status_choices, default=0, verbose_name="课程状态")
    students = models.IntegerField(default=0, verbose_name="学习人数")
    lessons = models.IntegerField(default=0, verbose_name="总课时数量")
    pub_lessons = models.IntegerField(default=0, verbose_name="已更新课时数量")
    price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="课程原价", default=0)
    recommend_home_hot = models.BooleanField(default=False, verbose_name="是否推荐到首页新课栏目")
    recommend_home_top = models.BooleanField(default=False, verbose_name="是否推荐到首页必学栏目")
    direction = models.ForeignKey("CourseDirection", related_name="course_list", on_delete=models.DO_NOTHING, null=True,
                                  blank=True, db_constraint=False, verbose_name="学习方向")
    category = models.ForeignKey("CourseCategory", related_name="course_list", on_delete=models.DO_NOTHING, null=True,
                                 blank=True, db_constraint=False, verbose_name="课程分类")
    teacher = models.ForeignKey("Teacher", related_name="course_list", on_delete=models.DO_NOTHING, null=True,
                                blank=True, db_constraint=False, verbose_name="授课老师")

    class Meta:
        db_table = "fg_course_info"
        verbose_name = "课程信息"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s" % self.name

    def course_cover_small(self):
        if self.course_cover:
            return mark_safe(f'<img style="border-radius: 0%;" src="{self.course_cover.thumb_108x61.url}">')
        return ""

    course_cover_small.short_description = "封面图片(108x61)"
    course_cover_small.allow_tags = True
    course_cover_small.admin_order_field = "course_cover"

    def course_cover_medium(self):
        if self.course_cover:
            return mark_safe(f'<img style="border-radius: 0%;" src="{self.course_cover.thumb_540x304.url}">')
        return ""

    course_cover_medium.short_description = "封面图片(540x304)"
    course_cover_medium.allow_tags = True
    course_cover_medium.admin_order_field = "course_cover"

    def course_cover_large(self):
        if self.course_cover:
            return mark_safe(f'<img style="border-radius: 0%;" src="{self.course_cover.thumb_1080x608.url}">')
        return ""

    course_cover_large.short_description = "封面图片(1080x608)"
    course_cover_large.allow_tags = True
    course_cover_large.admin_order_field = "course_cover"


class Teacher(BaseModel):
    role_choices = (
        (0, '讲师'),
        (1, '导师'),
        (2, '班主任'),
    )

    role = models.SmallIntegerField(choices=role_choices, default=0, verbose_name="讲师身份")
    title = models.CharField(max_length=64, verbose_name="职位、职称")
    signature = models.CharField(max_length=255, blank=True, null=True, verbose_name="导师签名")
    # avatar = models.ImageField(upload_to="teacher", null=True, verbose_name="讲师头像")
    # avatar = models.ImageField(upload_to="teacher", null=True, verbose_name="讲师头像")
    # 使用缩略图提供的StdImageFiled字段以后,每次客户端提交图片时,stdImage模块会自动根据字段里面的配置项生成对应尺寸的缩略图
    avatar = StdImageField(variations={
        'thumb_800x800': (800, 800),  # 'large': (800, 800),
        'thumb_400x400': (400, 400),  # 'medium': (400, 400),
        'thumb_50x50': (50, 50, True),  # 'small': (50, 50, True),
    }, delete_orphans=True, upload_to="teacher", null=True, verbose_name="讲师头像")

    brief = RichTextUploadingField(max_length=1024, verbose_name="讲师描述")

    class Meta:
        db_table = "fg_teacher"
        verbose_name = "讲师信息"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s" % self.name

    def avatar_small(self):
        if self.avatar:
            return mark_safe(f'<img style="border-radius: 100%;" src="{self.avatar.thumb_50x50.url}">')
        return ""

    avatar_small.short_description = "头像信息(50x50)"
    avatar_small.allow_tags = True
    avatar_small.admin_order_field = "avatar"

    def avatar_medium(self):
        if self.avatar:
            return mark_safe(f'<img style="border-radius: 100%;" src="{self.avatar.thumb_400x400.url}">')
        return ""

    avatar_medium.short_description = "头像信息(400x400)"
    avatar_medium.allow_tags = True
    avatar_medium.admin_order_field = "avatar"

    def avatar_large(self):
        if self.avatar:
            return mark_safe(f'<img style="border-radius: 100%;" src="{self.avatar.thumb_800x800.url}">')
        return ""

    avatar_large.short_description = "头像信息(800x800)"
    avatar_large.allow_tags = True
    avatar_large.admin_order_field = "avatar"


class CourseChapter(BaseModel):
    """课程章节"""
    orders = models.SmallIntegerField(default=1, verbose_name="第几章")
    summary = RichTextUploadingField(blank=True, null=True, verbose_name="章节介绍")
    pub_date = models.DateField(auto_now_add=True, verbose_name="发布日期")
    course = models.ForeignKey("Course", related_name='chapter_list', on_delete=models.CASCADE, db_constraint=False,
                               verbose_name="课程名称")

    class Meta:
        db_table = "fg_course_chapter"
        verbose_name = "课程章节"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s-第%s章-%s" % (self.course.name, self.orders, self.name)

    # admin的自定义字段
    @admin.display(boolean=True, ordering='orders')
    def text(self):
        return self.__str__()
    # admin站点配置排序规则和显示的字段文本提示
    text.short_description = "章节名称"
    
    #text.allow_tags = True  allow_tag被淘汰了,新写法上面的装饰器
    #text.admin_order_field = "orders"


class CourseLesson(BaseModel):
    """课程课时"""
    lesson_type_choices = (
        (0, '文档'),
        (1, '练习'),
        (2, '视频'),
    )

    orders = models.SmallIntegerField(default=1, verbose_name="第几节")
    lesson_type = models.SmallIntegerField(default=2, choices=lesson_type_choices, verbose_name="课时种类")
    lesson_link = models.CharField(max_length=255, blank=True, null=True, help_text="若是video,填视频地址或者视频id,若是文档,填文档地址",
                                   verbose_name="课时链接")
    duration = models.CharField(blank=True, null=True, max_length=32, verbose_name="课时时长")  # 仅在前端展示使用
    pub_date = models.DateTimeField(auto_now_add=True, verbose_name="发布时间")
    free_trail = models.BooleanField(default=False, verbose_name="是否可试看")
    recommend = models.BooleanField(default=False, verbose_name="是否推荐到课程列表")
    chapter = models.ForeignKey("CourseChapter", related_name='lesson_list', on_delete=models.CASCADE,
                                db_constraint=False, verbose_name="章节")
    course = models.ForeignKey("Course", related_name="lesson_list", on_delete=models.DO_NOTHING, db_constraint=False,
                               verbose_name="课程")

    class Meta:
        db_table = "fg_course_lesson"
        verbose_name = "课程课时"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s-%s" % (self.chapter, self.name)
      
      
		@admin.display(boolean=True, ordering='orders')
    def text(self):                                 #admin的自定义字段
        return self.__str__()
    text.short_description = "课时名称"
    
    #text.allow_tags = True  allow_tag被淘汰了,新写法上面的装饰器
    #text.admin_order_field = "orders"

apps/courses/admin.py,代码:

python
from django.contrib import admin
from .models import CourseDirection, CourseCategory, Course, Teacher, CourseChapter, CourseLesson


class CourseCategoryInLine(admin.StackedInline):
    """课程分类的内嵌类"""
    model = CourseCategory
    fields = ["id", "name", "orders"]


class CourseDirectionModelAdmin(admin.ModelAdmin):
    """学习方向的模型管理器"""
    list_display = ["id", "name", "recommend_home_hot", "recommend_home_top"]
    # 默认排序字段
    ordering = ["id"]
    # 字段过滤
    list_filter = ["recommend_home_hot", "recommend_home_top"]
    # 搜索字段
    search_fields = ["name"]
    # 内嵌外键数据
    inlines = [CourseCategoryInLine, ]
    # 分页配置,一夜数据量
    list_per_page = 10


admin.site.register(CourseDirection, CourseDirectionModelAdmin)


class CourseCategoryModelAdmin(admin.ModelAdmin):
    """课程分类的模型管理器"""
    # pass
    #
    list_display = ["id", "name", "direction"]
    ordering = ["id"]
    list_filter = ["direction"]
    search_fields = ["name"]
    # 分页配置,一页数据量
    list_per_page = 10
    # 更新数据时的表单配置项
    fieldsets = (
        ("必填", {'fields': ('name', 'direction', 'remark')}),
        ("选填", {
            'classes': ('collapse',),
            'fields': ('is_show', 'orders'),
        }),
    )
    # 添加数据时的表单配置项
    add_fieldsets = (
        (None, {
            'classes': ('wide',),
            'fields': ('name', 'direction', 'remark'),
        }),
    )

    # 当前方法会在显示表单的时候,自动执行,返回值就是表单配置项
    def get_fieldsets(self, request, obj=None):
        """
        获取表单配置项
        :param request: 客户端的http请求对象
        :param obj:     本次修改的模型对象,如果是添加数据操作,则obj为None
        :return:
        """
        if not obj:
            return self.add_fieldsets
        return super().get_fieldsets(request, obj)


admin.site.register(CourseCategory, CourseCategoryModelAdmin)


class CourseModelAdmin(admin.ModelAdmin):
    """课程信息的模型管理器"""
    list_display = ["id", "name", 'course_cover_small', "course_type", "level", "pub_date", "students", "lessons", "price"]
    # 分页配置,一夜数据量
    list_per_page = 10


admin.site.register(Course, CourseModelAdmin)


class TeacherModelAdmin(admin.ModelAdmin):
    """讲师信息的模型管理器"""
    list_display = ["id", "name", "avatar", "title", "role", "signature"]
    # 分页配置,一夜数据量
    list_per_page = 10
    # 搜索字段
    search_fields = ["name", "title", "role", "signature"]


admin.site.register(Teacher, TeacherModelAdmin)


class CourseChapterModelAdmin(admin.ModelAdmin):
    """课程章节的模型管理器"""
    list_display = ["id", "pub_date",'text' ]  # text删除了
    # 分页配置,一夜数据量
    list_per_page = 10


admin.site.register(CourseChapter, CourseChapterModelAdmin)


class CourseLessonModelAdmin(admin.ModelAdmin):
    """课程课时的模型管理器"""
    list_display = ["id",   "lesson_type", "duration", "pub_date", "free_trail",'text']
    # 分页配置,一夜数据量
    list_per_page = 10


admin.site.register(CourseLesson, CourseLessonModelAdmin)

提交版本

python
cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: admin站点配置以及给图片字段生成缩略图"
git push

5.1.10 添加测试数据

学习方向:

sql
truncate table fg_course_direction;
INSERT INTO luffycity.fg_course_direction (id, orders, is_show, is_deleted, created_time, updated_time, name, remark,
                                           recommend_home_hot, recommend_home_top, is_http)
VALUES (1, 1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '前端开发', '', 1, 1, 0),
       (2, 1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '后端开发', '', 1, 1, 0),
       (3, 1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '移动开发', '', 1, 1, 0),
       (4, 1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '计算机基础', '', 1, 1, 0),
       (5, 1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '前沿技术', '', 1, 1, 0),
       (6, 1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '云计算', '', 1, 1, 0),
       (7, 1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '大数据', '', 1, 1, 0),
       (8, 1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '运维', '', 1, 1, 0),
       (9, 1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '测试', '', 1, 1, 0),
       (10, 1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '数据库', '', 1, 1, 0),
       (11, 1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', 'UI设计', '', 1, 1, 0),
       (12, 1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '多媒体', '', 1, 1, 0),
       (13, 1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '游戏', '', 1, 1, 0),
       (14, 1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '求职面试', '', 1, 1, 0);

课程分类:

sql
truncate table fg_course_category;
INSERT INTO luffycity.fg_course_category (orders, is_show, is_deleted, created_time, updated_time, name, remark,
                                          direction_id, is_http)
VALUES (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Vue.js', '', 1, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Typescript', '', 1, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'React.js', '', 1, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'HTML', '', 1, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'JavaScript', '', 1, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Angular', '', 1, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Node.js', '', 1, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'WebApp', '', 1, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '小程序', '', 1, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '前端工具', '', 1, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'HTML/CSS', '', 1, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Html5', '', 1, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'CSS3', '', 1, 0),

       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Java', '', 2, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'SpringBoot', '', 2, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Spring Cloud', '', 2, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'SSM', '', 2, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'PHP', '', 2, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '.net', '', 2, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Python', '', 2, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '爬虫', '', 2, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Django', '', 2, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Flask', '', 2, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Go', '', 2, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'C', '', 2, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'C++', '', 2, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'C#', '', 2, 0),

       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Flutter', '', 3, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Android', '', 3, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'iOS', '', 3, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'React native', '', 3, 0),


       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '计算机网络', '', 4, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '算法与数据结构', '', 4, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '数学', '', 4, 0),


       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '微服务', '', 5, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '机器学习', '', 5, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '深度学习', '', 5, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '计算机视觉', '', 5, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '自然语言处理', '', 5, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '数据分析&挖掘', '', 5, 0),


       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '大数据', '', 6, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Hadoop', '', 6, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Spark', '', 6, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Hbase', '', 6, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Flink', '', 6, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Storm', '', 6, 0),

       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '阿里云', '', 7, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '容器', '', 7, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Docker', '', 7, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Kubernetes', '', 7, 0),

       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '运维', '', 8, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '自动化运维', '', 8, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '中间件', '', 8, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Linux', '', 8, 0),

       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '测试', '', 9, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '功能测试', '', 9, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '性能测试', '', 9, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '自动化测试', '', 9, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '接口测试', '', 9, 0),

       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'MySQL', '', 10, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Redis', '', 10, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'MongoDB', '', 10, 0),


       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '设计基础', '', 11, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '设计工具', '', 11, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'APPUI设计', '', 11, 0),

       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Unity 3D', '', 13, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'cocos creator', '', 13, 0),

       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '求职面试', '', 14, 0),
       (1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'leetcode', '', 14, 0);

讲师信息:

sql
truncate table fg_teacher;
INSERT INTO luffycity.fg_teacher (id, name, orders, is_show, is_deleted, created_time, updated_time, role, title,
                                  signature, avatar, brief, is_http)
VALUES (1, '张老师', 1, 1, 0, '2021-07-22 04:31:27.741562', '2021-07-22 04:31:27.741708', 0, 'BAT中某某技术总监', 'xxxxxxxx',
        'teacher/avatar.jpg', '<p>2009入行,在IT行业深耕13年,删库无数,行内同行送称号:删库小王子。</p>', 0),
       (2, '李老师', 1, 1, 0, '2021-07-22 04:31:27.741562', '2021-07-22 04:31:27.741708', 0, 'BAT中某某技术顾问', 'xxxxxxxx',
        'teacher/avatar.jpg', '<p>百变小王子,各种框架信手拈来。</p>', 0),
       (3, '王老师', 1, 1, 0, '2021-07-22 04:31:27.741562', '2021-07-22 04:31:27.741708', 0, 'BAT中某某技术主管', 'xxxxxxxx',
        'teacher/avatar.jpg', '<p>草根站长,专注运维20年。</p>', 0),
       (4, '红老师', 1, 1, 0, '2021-07-22 04:31:27.741562', '2021-07-22 04:31:27.741708', 0, 'BAT中某某项目经理', 'xxxxxxxx',
        'teacher/avatar.jpg', '<p>美女讲师,说话好听。</p>', 0);

添加测试数据在工作中一共有三种方式:

  1. 可以根据django的manage.py指令进行[自定义终端命令]
  2. 可以采用第三方模块Faker来完成数据的模拟添加(Faker模块可以写在上面第一种方法里面)
  3. 可以使用python脚本或者shell脚本来完成

方法一:自定义终端命令

文档:https://docs.djangoproject.com/zh-hans/3.2/howto/custom-management-commands/

在子应用目录下添加一个 management/commands 包目录,然后这个commands里面处理__init__.py以外其他文件的文件名将作为django-admin或者python manage.py的命令选项了。注意:文件名不能以_开头,否则不会被识别为命令。

接着我们可以在commands包下根据自己的业务需要,编写对应的命令。例如,我们现在需要在courses子应用下添加课程对应的测试数据,所以我们在courses下创建 management/commands 包目录,然后创建命令文件example.py。

apps/course/management/commands/example.py,代码:

python
import constants
from django.core.management.base import BaseCommand, CommandError
from courses.models import Teacher

# 类名必须是Command而且一个文件就是一个命令类,这个命令类必须直接或间接继承BaseCommand
class Command(BaseCommand):
    help = '添加课程相关的测试数据'

    # 如果当前命令,需要接受来自终端的参数,可以使用add_arguments
    def add_arguments(self, parser):
        pass
        # 位置参数,必填项
        # parser.add_argument('name', nargs='+', type=int)

        # 命令参数,可选项
        # parser.add_argument(
        #     '--table',
        #     action='store_true',
        #     help='Delete poll instead of closing it',
        # )

    # 命令执行的核心方法,
    def handle(self, *args, **options):
        """添加测试数据"""
        print("添加测试数据")

        Teacher.objects.create(
            name="赵小明",
            avatar="teacher/avatar.jpg",
            role=1,
            title="老师",
            signature="从业3年,管理班级无数",
            brief="从业3年,管理班级无数",
        )

方法二:Faker添加模拟数据

文档:https://faker.readthedocs.io/en/stable/locales/zh_CN.html#

github:https://github.com/joke2k/faker/

faker是一个在多个编程语言里面都比较常用的第三方工具类,它的作用就是可以提供非常有效的方式帮开发者生成一些模拟仿真的测试数据。

pip install faker

结合上面的自定义终端命令来实现,

apps/course/management/commands/example.py,代码:

python
import constants,random
from django.core.management.base import BaseCommand, CommandError
from courses.models import Teacher
from faker import Faker
from django.conf import settings

# 类名必须是Command而且一个文件就是一个命令类,这个命令类必须直接或间接继承BaseCommand
class Command(BaseCommand):
    help = '添加课程相关的测试数据'

    # 如果当前命令,需要接受来自终端的参数,可以使用add_arguments
    def add_arguments(self, parser):
        # 位置参数,必填项
        # parser.add_argument('date_type', nargs='+', type=int, help="添加数据的类型")

        # 命令参数,可选项
        parser.add_argument(
            '--type',
            dest='type',
            default='teacher',
            type=str,
            help='测试数据的类型',
        )

        parser.add_argument(
            '--number',
            dest='number',
            default=10,
            type=int,
            help='添加数据的数量',
        )

    # 命令执行的核心方法,
    def handle(self, *args, **options):
        """添加课程相关的测试数据"""
        if options["type"] == "teacher":
            self.add_teacher(options)
        elif options["type"] == "direction":
            self.add_direction(options)

    def add_teacher(self, options):
        """添加授课老师的测试数据"""
        faker = Faker(['zh-CN'])

        for i in range(options['number']):
            roles = ['讲师', '导师', '班主任']
            role = random.randint(0, 2)
            years = random.randint(0, 20)
            Teacher.objects.create(
                name=faker.unique.name(),
                avatar="teacher/avatar.jpg",
                role=role,
                title=roles[role],
                signature=f"从业{years}年,管理班级无数",
                brief=f"从业{years}年,管理班级无数",
            )
        print("添加授课老师的测试数据完成....")

    def add_direction(self, options):
        """添加学习方向的测试数据"""
        print("添加学习方向的测试数据完成....")

终端下调用:

bash
cd <项目路>
python manage.py example
python manage.py example --type teacher
python manage.py example --type direction
python manage.py example --type teacher  --number 100

方法三:基于终端脚本来完成数据的添加

注意:basemodel里设置的默认值,会保存在makemigrations生成的文件中,默认值不会出现在表结构里,只有在调用Django的orm时,比如create的话,会自动去makemigrations生成的文件中提取默认值,然后拼接生成完整的sql语句,这样表结构中有没有默认值就无所谓,但现在用脚本添加数据, 所以is_http,is_show,is_deleted就得手动加默认值 因为数据库表结构里没有。

要编写一个python或者shell脚本,就要清楚一件事情,就是我们可以根据对应的语言来编写对应的终端代码,但是必须在首行的位置声明执行这些代码的解析器是谁?路径在哪里?

编写python脚本

首行指定运行当前代码的python解释器。写完整绝对路径

scripts/test1.py,代码:

python
#! /home/moluo/anaconda3/envs/luffycity/bin/python

# 首行以后的代码必须要符合python的语法
"""
针对通用代码的运行,可以直接使用系统内置的全局环境的python解释器,也可以使用虚拟环境的解析器
#! /usr/bin/python3
如果这个代码需要调用对应的第三方模块,那么就要写上安装该模块的python解释器
#! /home/moluo/anaconda3/envs/luffycity/bin/python
"""
import os, sys
from faker import Faker

faker = Faker(["zh_CN"])
user = faker.unique.name()
print(f"hello,{user}")

# python获取终端参数
try:
    dir = sys.argv[1]  # 0 ==> ./test1.py    1 ==> user
except Exception as e:
    dir = "2021"

# python直接执行shell命令
# ret = os.popen("ls -l")
ret = os.popen("ls -l")  # 等同于上一行
print(ret.read())

ret = os.popen(f"mkdir {dir} && cd {dir} && echo 'hello {user}' > index.html")  # 等同于上一行
print(ret.read())

sys.argv的意思:https://www.cnblogs.com/aland-1415/p/6613449.html

os.popen()的意思:https://www.runoob.com/python/os-popen.html,在python里执行bash

编写shell脚本

首行指定运行当前代码的shell解释器。写完整路径。

scripts/test2.sh,代码:

bash
#!/bin/zsh
执行脚本需要权限

不管编写的什么的脚本命令,编写完脚本以后,脚本本身因为操作系统默认会取消它的执行权限,所以我们通过以下命令来增加执行的权限。

python
chmod +x 对应的文件名
# chmod 755 对应的文件名

# 例如,给python脚本赋予执行的权限。文件名假设为:test1.py
chmod +x test1.py

# 例如,shell脚本赋予执行的权限,文件名假设为:test2.sh
chmod +x test2.sh

赋予了权限以后,就可以执行脚本了。

但是执行过程中, 一定使用相对路径的方式来执行这个脚本。

# 例如,上面的test1.py或者test2.sh
./test1.py
./test2.sh

# 如果不希望使用相对路径,则需要把当前文件所载的目录设置为环境变量才行。

使用shell命令来完成测试数据的添加

  1. 编写一个sql语句的文件

scripts/test_data.sql

sql
-- 如果使用数据库本身的外键,则添加/删除/修改数据时,务必关闭原来表中的主外键约束功能
set FOREIGN_KEY_CHECKS=0;

-- 清空原有的课程信息表信息
truncate table fg_course_info;

-- 添加课程信息
INSERT INTO luffycity.fg_course_info (id, name, orders, is_show, is_deleted, created_time, updated_time, course_cover, course_video, course_type, level, description, pub_date, period, attachment_path, attachment_link, status, students, lessons, pub_lessons, price, recomment_home_hot, recomment_home_top, category_id, direction_id, teacher_id)
VALUES
(1, '7天Typescript从入门到放弃', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-10.png', '', 0, 0, '<p>7天Typescript从入门到放弃</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 988, 100, 30, 998.00, 1, 1, 2, 1, 1),
(2, '3天Typescript精修', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-9.png', '', 0, 0, '<p>3天Typescript精修</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 988, 100, 30, 998.00, 1, 1, 2, 1, 1),
(3, '3天学会Vue基础', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-8.png', '', 0, 0, '<p>3天学会Vue基础</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 988, 100, 30, 998.00, 1, 1, 2, 1, 1);

-- 如果使用数据库本身的外键,则添加/删除/修改数据以后,务必开启原来表中的主外键约束功能
set FOREIGN_KEY_CHECKS=1;
  1. 编写一个shell脚本test_data.sh来执行上面的文件
bash
#!/bin/zsh

#将一个名为"test_data.sql"的文件导入到名为"luffycity"的MySQL数据库中
mysql -uroot -proot123 luffycity < ./test_data.sql
  1. 赋予create_data.sh执行的权限
bash
chmod +x test_data.sh
./test_data.sh

提交代码版本

python
cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "test: 添加测试数据的三种方式"
git push

5.1.11 在Admin站点中管理公共数据与用户数据

apps/api/app.py,代码:

python
from django.apps import AppConfig

class ApiConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'api'
    verbose_name = "Public Data"
    verbose_name_plural = verbose_name

apps/api/admin.py,代码:

python
from django.contrib import admin
from api.models import NavModel, BannerModel


# Register your models here.
class NavModelAdmin(admin.ModelAdmin):
    list_display = ["id", "name", "link", "is_http"]


admin.site.register(NavModel, NavModelAdmin)  #admin.site.register!!!!


class BannerModelAdmin(admin.ModelAdmin):
    list_display = ['id', 'name', 'link', 'is_http', 'image_html']


admin.site.register(BannerModel, BannerModelAdmin)

apps/api/models.py 代码:

python
from django.db import models
from utils.Mymodels import BaseModel
from django.utils.safestring import mark_safe

class NavModel(BaseModel):
    name = models.CharField(max_length=255)
    link = models.CharField(max_length=255)
    position = models.IntegerField(choices=((1, 'Header'), (0, 'Footer')))

    class Meta:
        db_table = 'luffy_nav'
        verbose_name = '导航菜单'
        verbose_name_plural = verbose_name

# Create your models here.

class BannerModel(BaseModel):
    image = models.ImageField(upload_to="banner/%Y/", verbose_name="图片地址")
    link = models.CharField(max_length=500, verbose_name="链接地址")
    note = models.CharField(max_length=150, verbose_name='备注信息')
    is_http = models.BooleanField(default=False, verbose_name="是否外链地址",
                                  help_text="站点链接地址:http://www.baidu.com/book<br>站点链接地址:/book/")

    class Meta:
        db_table = "luffy_banner"
        verbose_name = "轮播广告"
        verbose_name_plural = verbose_name

    def image_html(self):
        if self.image:
            return mark_safe(
                f'<img style="border-radius: 0%;max-height: 100px; max-width: 400px;" src="{self.image.url}">')
        return ''

    image_html.short_description = "广告图片"
    image_html.allow_tags = True
    image_html.admin_order_field = "image"

5.1.12 实现图片上传到阿里云OSS对象存储

实现图片上传到阿里云OSS对象存储。

image-20211118110206279

部分公司:基于fastDFS构建静态资源服务器

部分公司:申请第三方云存储:阿里云OSS,腾讯云云存储,百度云云存储,亚马逊S3

创建阿里云OSS对象存储

开发文档:https://promotion.aliyun.com/ntms/act/ossdoclist.html?spm=5176.8465980.entries.1.4e701450wyVJSM

Bucket存储库:https://oss.console.aliyun.com/bucket

image-20220510114233898

image-20220510114257336

image-20220510114352447

image-20220510114442592

bash
bucket     luffycity-dahong
endpoint   oss-cn-beijing.aliyuncs.com

查询获取接口访问key和秘钥

地址:https://ram.console.aliyun.com/manage/ak

image-20210917151821217

image-20240226200938756

bash
ACCESS_KEY_ID       LTAI5tRjNLok7dxRLFkej1gw
ACCESS_KEY_SECRET   EDqf8NvlE7R8flWH6frbtMf80DDWa2

安装阿里云的SDK集成到项目中使用

终端下安装:

python
pip install oss2
pip install django-oss-storage
python直接操作oss2

适用于一些没有oss集成模块的web框架中,ossdemo.py,代码:

python
import oss2,uuid
if __name__ == '__main__':
    OSS_ACCESS_KEY_ID = "LTAI5t991uBJjk8TunKooM7M"
    OSS_ACCESS_KEY_SECRET = "oEDvV9RaoCf6rHIZXlJCJAmk0phub2"
    OSS_ENDPOINT = "oss-cn-beijing.aliyuncs.com"  # 访问域名, 根据服务器上的实际配置修改
    OSS_BUCKET_NAME = "luffycityoline"  # oss 创建的 BUCKET 名称

    OSS_SERVER_URL = f"https://{OSS_BUCKET_NAME}.{OSS_ENDPOINT}"

    # 创建命名空间操作实例对象
    auth = oss2.Auth(OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET)
    bucket = oss2.Bucket(auth, OSS_ENDPOINT, OSS_BUCKET_NAME)

    # 上传文件
    image = f"demo/{str(uuid.uuid4())}.jpg"
    with open('/home/moluo/Desktop/luffycity/luffycityapi/luffycityapi/uploads/avatar/2021/avatar.jpg', "rb") as f:
        result = bucket.put_object(image, f.read() )
        print(result)
        print(result.status)
        print(f"{OSS_SERVER_URL}/{image}")
django配置自定义文件存储上传文件到oss

settings.dev,代码:

python
# 阿里云OSS云存储
OSS_ACCESS_KEY_ID = "LTAI5t991uBJjk8TunKooM7M"
OSS_ACCESS_KEY_SECRET = "oEDvV9RaoCf6rHIZXlJCJAmk0phub2"
OSS_ENDPOINT = "oss-cn-beijing.aliyuncs.com"    # 访问域名, 根据服务器上的实际配置修改
OSS_BUCKET_NAME = "luffycityoline"    # oss 创建的 BUCKET 名称

# 添加下面配置后 Django admin 后台上传的 ImageField, FileField 类型的字段都会被自动上传到 oss 的服务器中, 访问路径也会自动替换
# 如果注释掉的话 oss 的配置会失效, 上传文件会存储到本地, 且访问路径也会变成本地
DEFAULT_FILE_STORAGE = 'django_oss_storage.backends.OssMediaStorage'

注意:上面的配置完成以后,将来django中所有上传下载的文件都会默认从OSS对象存储中操作。所以本地原来保存的图片等静态资源再访问就无效了。所以我们需要把uploads这个目录下的所有文件信息,手动上传到当前项目配置的OSS Bucket存储库中。

提交代码版本

python
cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 实现图片上传到阿里云OSS对象存储"
git push

5.1.13 后端实现学习方向列表接口

创建序列化器

course/serializers.py

python
from course.models import CourseDirection, CourseCategory
from rest_framework import serializers


class CourseDirectionModelSerializer(serializers.ModelSerializer):
    class Meta:
        model = CourseDirection
        fields = ['id', 'name']


class CourseCategoryModelSerializer(serializers.ModelSerializer):
    class Meta:
        model = CourseCategory
        fields = ['id', 'name']

视图

course/views.py

python
from rest_framework.generics import ListAPIView
from .models import CourseDirection, CourseCategory
from .serializers import CourseDirectionModelSerializer, CourseCategoryModelSerializer


# Create your views here.
class CourseDirectionListAPIView(ListAPIView):
    """学习方向"""
    queryset = CourseDirection.objects.filter(is_show=True, is_deleted=False).order_by("orders", "-id")
    serializer_class = CourseDirectionModelSerializer
    pagination_class = None


class CourseCategoryListAPIView(ListAPIView):
    """学习分类"""
    queryset = CourseCategory.objects.filter(is_show=True, is_deleted=False).order_by("orders","-id")
    serializer_class = CourseCategoryModelSerializer
    pagination_class = None

路由

python
from django.urls import path
from course.views import CourseDirectionViews, CourseCategoryViews

urlpatterns = [
    path('direction/', CourseDirectionViews.as_view(), name='direction'),
    path('category/', CourseCategoryViews.as_view(), name='category'),
]

提交代码版本

python
cd <项目路径>
git add .
git commit -m "feature: 服务端提供课程分类列表的api接口"
git push

5.1.14 客户端发送请求获取学习方向和课程分类信息,当用户点击不同的学习方向时,显示不同方向下的课程分类信息

前端代码

src/api/course.js,代码:

js
import {ref, reactive} from 'vue'
import http from "./http";

// const courses = reactive({
//     direction_list: [],
//     category_list: [],
//     current_direction: 0,
//     current_category:0,
//     get_direction() {
//         return http.get('/courses/direction/')
//     },
//
//
// })
const course = reactive({
    direction_list: [],
    category_list: [],
    current_direction: 0,
    current_category: 0,
    get_direction() {
        return http.get('http://api.luffycity.cn:8000/course/direction/');
    },
    get_category() {
        return http.get(`http://api.luffycity.cn:8000/course/category/${this.current_direction}/`)
    },

})
export default course;

src/views/Courses.vue,代码:

vue
<template>
    <div class="course">
        <Header></Header>
        <div class="top-wrap">
            <div class="actual-header">
                <div class="actual-header-wrap">
                    <div class="banner">
                        <router-link class="title" to="/course"><img class="h100" src="../assets/coding-title.png"
                                                                     alt=""></router-link>
                        <div>真实项目实战演练</div>
                    </div>
                    <div class="actual-header-search">
                        <div class="search-inner">
                            <input class="actual-search-input" placeholder="搜索感兴趣的实战课程内容" type="text"
                                   autocomplete="off">
                            <img class="actual-search-button" src="../assets/search.svg"/>
                        </div>
                        <div class="actual-searchtags">
                        </div>
                        <div class="search-hot">
                            <span>热搜:</span>
                            <a href="">Java工程师</a>
                            <a href="">Vue</a>
                        </div>
                    </div>
                </div>
            </div>
            <div class="type">
                <div class="type-wrap">
                    <div class="one warp">
                        <span class="name">方向:</span>
                        <ul class="items">
                            <li :class="{cur:course.current_direction===0}"><a href="">全部</a></li>
                            <li v-for="direction in course.direction_list"
                                @click.prevent.stop="clickDirection(direction)"
                                :class="{cur:course.current_direction===direction.id}"><a href="">{{direction.name}}</a>
                            </li>

                        </ul>
                    </div>
                    <div class="two warp">
                        <span class="name">分类:</span>
                        <ul class="items">
                            <li :class="{cur:course.current_category===0}"><a href="">不限</a></li>
                            <li v-for="category in course.category_list"
                                @click.stop.prevent="course.current_category=category.id"
                                :class="{cur:course.current_category===category.id}"><a
                                    href="">{{category.name}}</a></li>

                        </ul>
                    </div>
                </div>
            </div>
        </div>
        <div class="main">
            <div class="main-wrap">
                <div class="filter clearfix">
                    <div class="sort l">
                        <a href="" class="on">最新</a>
                        <a href="">销量</a>
                        <a href="">升级</a>
                    </div>
                    <div class="other r clearfix"><a class="course-line l" href="" target="_blank">学习路线</a></div>
                </div>
                <ul class="course-list clearfix">
                    <li class="course-card">
                        <a target="_blank" href="">
                            <div class="img"><img src="../assets/course-1.png" alt=""></div>
                            <p class="title ellipsis2">全面的Docker 系统性入门+进阶实践(2021最新版)</p>
                            <p class="one">
                                <span>进阶 · 611人报名</span>
                                <span class="discount r"><i class="name">优惠价</i></span>
                            </p>
                            <p class="two clearfix">
                                <span class="price l red bold">¥428.00</span>
                                <span class="origin-price l delete-line">¥488.00</span>
                                <span class="add-shop-cart r"><img class="icon imv2-shopping-cart"
                                                                   src="../assets/cart2.svg">加购物车</span>
                            </p>
                        </a>
                    </li>
                    <li class="course-card">
                        <a target="_blank" href="">
                            <div class="img"><img src="../assets/course-2.png" alt=""></div>
                            <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>
                            <p class="one">
                                <span>进阶 · 246人报名</span>
                                <span class="discount r"><i class="name">限时优惠</i><i class="countdown">6<span
                                        class="day">天</span>01:39:21</i></span>
                            </p>
                            <p class="two clearfix">
                                <span class="price l red bold">¥328.00</span>
                                <span class="origin-price l delete-line">¥368.00</span>
                                <span class="add-shop-cart r"><img class="icon imv2-shopping-cart"
                                                                   src="../assets/cart2.svg">加购物车</span>
                            </p>
                        </a>
                    </li>
                    <li class="course-card">
                        <a target="_blank" href="">
                            <div class="img"><img src="../assets/course-3.png" alt=""></div>
                            <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>
                            <p class="one">
                                <span>进阶 · 246人报名</span>
                                <span class="discount r"><i class="name">限时优惠</i><i class="countdown">16<span
                                        class="day">天</span>01:39:21</i></span>
                            </p>
                            <p class="two clearfix">
                                <span class="price l red bold">¥328.00</span>
                                <span class="origin-price l delete-line">¥368.00</span>
                                <span class="add-shop-cart r"><img class="icon imv2-shopping-cart"
                                                                   src="../assets/cart2.svg">加购物车</span>
                            </p>
                        </a>
                    </li>
                    <li class="course-card">
                        <a target="_blank" href="">
                            <div class="img"><img src="../assets/course-4.png" alt=""></div>
                            <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>
                            <p class="one"><span>进阶 · 246人报名</span></p>
                            <p class="two clearfix">
                                <span class="price l red bold">¥399.00</span>
                                <span class="add-shop-cart r"><img class="icon imv2-shopping-cart"
                                                                   src="../assets/cart2.svg">加购物车</span>
                            </p>
                        </a>
                    </li>
                    <li class="course-card">
                        <a target="_blank" href="">
                            <div class="img"><img src="../assets/course-5.png" alt=""></div>
                            <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>
                            <p class="one"><span>进阶 · 246人报名</span></p>
                            <p class="two clearfix">
                                <span class="price l red bold">¥399.00</span>
                                <span class="add-shop-cart r"><img class="icon imv2-shopping-cart"
                                                                   src="../assets/cart2.svg">加购物车</span>
                            </p>
                        </a>
                    </li>

                </ul>
                <div class="page">
                    <span class="disabled_page">首页</span>
                    <span class="disabled_page">上一页</span>
                    <a href="" class="active">1</a>
                    <a href="">2</a>
                    <a href="">3</a>
                    <a href="">4</a>
                    <a href="">下一页</a>
                    <a href="">尾页</a>
                </div>
            </div>
        </div>
        <Footer></Footer>
    </div>
</template>
vue
<script setup>
    import Header from "../components/Header.vue"
    import Footer from "../components/Footer.vue"
    import course from "../utils/courses";

    course.get_direction().then(response => {
        course.direction_list = response.data
        console.log(course.direction_list);
    }).catch(error => {
        console.log(error)
    })

    course.get_category().then(response => {
        course.category_list = response.data

    }).catch(error => {
        console.log(error)
    })
    const clickDirection = (direction) => {
        course.current_direction = direction.id;
        course.get_category().then(response => {
            course.category_list=response.data;
        }).catch(error => {
            console.log(error);
        });

    }

</script>
vue
<style scoped>
    .top-wrap {
        background-color: #f5f7fa;
        background-repeat: no-repeat;
        background-position: top center;
        background-size: cover
    }

    .actual-header {
        max-width: 1500px;
        margin: 0 auto;
    }

    .actual-header .actual-header-wrap {
        height: 100%;
        display: -webkit-box;
        display: -ms-flexbox;
        display: -webkit-flex;
        display: flex;
        -webkit-box-align: center;
        -ms-flex-align: center;
        -webkit-align-items: center;
        align-items: center;
        -webkit-box-pack: justify;
        -ms-flex-pack: justify;
        -webkit-justify-content: space-between;
        justify-content: space-between;
        padding-top: 8px
    }

    .actual-header .actual-header-wrap .banner {
        display: -webkit-box;
        display: -ms-flexbox;
        display: -webkit-flex;
        display: flex;
        -webkit-box-align: center;
        -ms-flex-align: center;
        -webkit-align-items: center;
        align-items: center
    }

    .actual-header .actual-header-wrap .banner .title {
        height: 46px;
        margin-right: 8px
    }

    .actual-header .actual-header-wrap .actual-header-search {
        position: relative;
        width: 320px
    }

    .actual-header .actual-header-wrap .actual-header-search .search-inner {
        width: 100%;
        border-radius: 4px;
        overflow: hidden;
        margin: 17px 0 7px;
        border: 1px solid rgba(84, 92, 99, .2)
    }

    .actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-input {
        width: 275px;
        font-size: 12px;
        color: #93999f;
        line-height: 24px;
        padding: 5px 12px;
        border: none;
        border-radius: 0;
        box-sizing: border-box;
        background: 0 0
    }

    .actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-input::-webkit-input-placeholder {
        color: #9199a1
    }

    .actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-input::-moz-placeholder {
        color: #9199a1
    }

    .actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-input:-moz-placeholder {
        color: #9199a1
    }

    .actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-input:-ms-input-placeholder {
        color: #9199a1
    }

    .actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-button {
        width: 26px;
        padding-top: 4px;
        padding-bottom: 4px;
        padding-right: 4px;
        padding-left: 6px;
        height: 26px;
        font-size: 18px;
        text-align: center;
        line-height: 26px;
        color: #fff;
        background-color: rgba(84, 92, 99, .2);
        cursor: pointer;
        border-top-right-radius: 4px;
        border-bottom-right-radius: 4px;
        float: right
    }

    .actual-header .actual-header-wrap .actual-header-search .actual-searchtags {
        position: absolute;
        right: 128px;
        top: 0;
        height: 48px;
        line-height: 48px;
        text-align: right
    }

    .actual-header .actual-header-wrap .actual-header-search .actual-searchtags a {
        margin-left: 24px;
        font-size: 12px;
        color: #4d555d;
        line-height: 48px
    }

    .actual-header .actual-header-wrap .actual-header-search .actual-searchtags a:hover {
        color: #f01414
    }

    .actual-header .actual-header-wrap .actual-header-search .actual-history-item a {
        float: left;
        font-size: 12px;
        color: rgba(7, 17, 27, .6);
        line-height: 16px;
        padding: 4px 12px;
        margin-right: 8px;
        background: rgba(7, 17, 27, .05);
        border-radius: 12px;
        transition: .3s background, color linear;
        margin-top: 8px
    }

    .actual-header .actual-header-wrap .actual-header-search .actual-history-item a:hover {
        background: rgba(7, 17, 27, .1);
        color: #07111b
    }

    .actual-header .actual-header-wrap .actual-header-search li {
        display: block;
        width: 100%;
        height: 48px;
        transition: .3s background linear;
        padding: 12px 16px;
        box-sizing: border-box;
        font-size: 14px;
        color: #4d555d;
        line-height: 24px;
        cursor: pointer;
        z-index: 1
    }

    .actual-header .actual-header-wrap .actual-header-search li:hover {
        background: #f3f5f7;
        color: #07111b
    }

    .actual-header .actual-header-wrap .actual-header-search .search-hot {
        height: 21px;
        overflow: hidden;
        padding-left: 14px
    }

    .actual-header .actual-header-wrap .actual-header-search .search-hot a,
    .actual-header .actual-header-wrap .actual-header-search .search-hot span {
        color: rgba(84, 92, 99, .7);
        font-size: 12px;
        line-height: 16px
    }

    .actual-header .actual-header-wrap .actual-header-search .search-hot a {
        margin-right: 14px
    }

    .actual-header .actual-header-wrap .actual-header-search .search-hot a:last-child {
        margin-right: 0
    }

    .type {
        max-width: 1500px;
        margin: 0 auto;
        padding-bottom: 27px
    }

    .type .type-wrap {
        position: relative;
        height: 109px
    }

    .type .type-wrap .warp {
        display: -webkit-box;
        display: -ms-flexbox;
        display: -webkit-flex;
        display: flex;
        position: absolute;
        width: 1430px;
        height: 54px;
        overflow: hidden;
        padding: 10px;
        box-sizing: border-box;
        box-shadow: 0 12px 20px 0 rgba(95, 101, 105, 0);
        border-radius: 8px;
        transition: all .2s
    }

    .type .type-wrap .warp.one {
        margin-bottom: 25px;
        z-index: 3
    }

    .type .type-wrap .warp.two {
        top: 59px;
        z-index: 2
    }

    .type .type-wrap .warp .name {
        width: 3em;
        color: #07111b;
        line-height: 32px;
        font-weight: 700;
        margin-right: 6px
    }

    .type .type-wrap .warp .items {
        width: 0;
        -webkit-box-flex: 1;
        -ms-flex: 1;
        -webkit-flex: 1;
        flex: 1
    }

    .type .type-wrap .warp .items li {
        float: left;
        line-height: 16px;
        padding: 8px;
        border-radius: 6px;
        margin: 0 12px 12px 0
    }

    .type .type-wrap .warp .items li a {
        color: #1c1f21
    }

    .type .type-wrap .warp .items li.cur {
        background-color: rgba(233, 142, 70, .1)
    }

    .type .type-wrap .warp .items li.cur a {
        color: #e98e46
    }

    .delete-line {
        text-decoration: line-through;
    }

    /******** 课程列表 ********/
    .l {
        float: left;
    }

    .r {
        float: right;
    }

    .red {
        color: red;
    }

    .bold {
        font-weight: 700;
    }

    .main {
        margin-bottom: 60px
    }

    .main .main-wrap {
        max-width: 1500px;
        margin: 0 auto;
    }

    .clearfix:after {
        content: '';
        display: block;
        height: 0;
        clear: both;
        visibility: hidden
    }

    .main .filter {
        margin: 20px 0
    }

    .main .filter .sort {
        overflow: hidden
    }

    .main .filter .sort a {
        display: inline-block;
        float: left;
        font-size: 12px;
        color: #545c63;
        line-height: 16px;
        padding: 4px 12px;
        border-radius: 100px;
        margin-right: 12px
    }

    .main .filter .sort a:last-child {
        margin-right: 0
    }

    .main .filter .sort a.on {
        color: #fff;
        background-color: #545c63
    }

    .main .filter .other {
        font-size: 12px
    }

    .main .filter .other .course-line {
        color: #e98e46;
        line-height: 16px;
        padding: 4px 16px;
        border-radius: 100px;
        background-color: rgba(233, 142, 70, .1);
        margin-left: 24px
    }

    .main .course-list .course-card {
        position: relative;
        width: 270px;
        height: 270px;
        float: left;
        margin: 0 37px 20px 0;
        box-shadow: 0 4px 8px 0 rgba(95, 101, 105, .05);
        border-radius: 8px;
        background-color: #fff;
        transition: transform .2s, box-shadow .2s
    }

    .main .course-list .course-card:nth-child(5n) {
        margin-right: 0
    }

    .main .course-list .course-card:hover {
        transform: translateY(-2px);
        box-shadow: 0 12px 20px 0 rgba(95, 101, 105, .1)
    }

    .main .course-list .course-card a {
        display: inline-block;
        width: 100%
    }

    .main .course-list .course-card .img {
        height: 152px;
        background: no-repeat center/cover;
        margin-bottom: 8px;
        border-radius: 8px 8px 0 0;
        overflow: hidden
    }

    .main .course-list .course-card .title {
        color: #545c63;
        line-height: 20px;
        height: 40px;
        margin-bottom: 8px;
        padding: 0 8px
    }

    .main .course-list .course-card .title.ellipsis2 {
        overflow: hidden;
        text-overflow: ellipsis;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical
    }

    .main .course-list .course-card .one,
    .main .course-list .course-card .two {
        font-size: 12px;
        color: #9199a1;
        line-height: 18px;
        padding: 0 8px;
        margin-bottom: 8px
    }

    .main .course-list .course-card .one .add-shop-cart .icon,
    .main .course-list .course-card .one .star .icon,
    .main .course-list .course-card .two .add-shop-cart .icon,
    .main .course-list .course-card .two .star .icon {
        display: inline-block;
        margin-right: 2px;
        font-size: 14px
    }

    .imv2-shopping-cart {
        width: 14px;
    }

    .main .course-list .course-card .one .add-shop-cart.add-shop-cart,
    .main .course-list .course-card .one .add-shop-cart.stared,
    .main .course-list .course-card .one .star.add-shop-cart,
    .main .course-list .course-card .one .star.stared,
    .main .course-list .course-card .two .add-shop-cart.add-shop-cart,
    .main .course-list .course-card .two .add-shop-cart.stared,
    .main .course-list .course-card .two .star.add-shop-cart,
    .main .course-list .course-card .two .star.stared {
        color: #ff655d
    }


    .main .course-list .course-card .one .discount i,
    .main .course-list .course-card .two .discount i {
        font-style: normal;
        padding: 3px 4px
    }

    .main .course-list .course-card .one .discount i.name,
    .main .course-list .course-card .two .discount i.name {
        color: #fff;
        background-color: rgba(242, 13, 13, .6)
    }

    .main .course-list .course-card .one .price,
    .main .course-list .course-card .two .price {
        line-height: 20px;
        margin-right: 2px
    }

    .main .course-list .course-card .one .discount,
    .main .course-list .course-card .two .discount {
        border: 1px solid rgba(242, 13, 13, .2);
        border-radius: 2px;
        font-size: 12px;
        line-height: 1;
        margin-right: 4px;
        overflow: hidden;
        display: -webkit-box;
        display: -ms-flexbox;
        display: -webkit-flex;
        display: flex;
        -webkit-box-align: center;
        -ms-flex-align: center;
        -webkit-align-items: center;
        align-items: center
    }

    .main .course-list .course-card .one .discount i,
    .main .course-list .course-card .two .discount i {
        font-style: normal;
        padding: 3px 4px
    }

    .main .course-list .course-card .one .discount i.name,
    .main .course-list .course-card .two .discount i.name {
        color: #fff;
        background-color: rgba(242, 13, 13, .6)
    }

    .main .course-list .course-card .one .discount i.countdown,
    .main .course-list .course-card .two .discount i.countdown {
        display: flex;
        font-family: DINCondensed, '微软雅黑';
        color: #f76e6e;
        padding-top: 4px;
        padding-bottom: 2px
    }

    .main .course-list .course-card .one .discount i.countdown .day,
    .main .course-list .course-card .two .discount i.countdown .day {
        display: inline-block;
        width: 12px;
        height: 12px;
        transform: scale(0.8);
    }


    /**** 页码 *****/
    .page {
        margin: 25px 0 auto;
        overflow: hidden;
        clear: both;
        text-align: center
    }

    .page a {
        display: inline-block;
        margin: 0 12px;
        width: 36px;
        height: 36px;
        line-height: 36px;
        font-size: 14px;
        color: #4d555d;
        text-align: center;
        border-radius: 50%;
        -webkit-transition: border-color .2s;
        -moz-transition: border-color .2s;
        transition: border-color .2s
    }

    .page a:hover {
        text-decoration: none;
        background-color: #d9dde1
    }

    .page a.active {
        background: #4d555d;
        color: #fff
    }

    .page a:first-child,
    .page a:last-child,
    .page a:nth-child(2),
    .page a:nth-last-child(2) {
        width: auto
    }

    .page a:first-child:hover,
    .page a:last-child:hover,
    .page a:nth-child(2):hover,
    .page a:nth-last-child(2):hover {
        background-color: transparent
    }

    .page span {
        display: inline-block;
        padding: 0 12px;
        min-width: 20px;
        height: 39px;
        line-height: 39px;
        font-size: 14px;
        color: #93999f;
        text-align: center
    }
</style>

后端代码

apps/course/url.py

python
from django.urls import path
from course.views import CourseDirectionViews, CourseCategoryViews

urlpatterns = [
    path('direction/', CourseDirectionViews.as_view(), name='direction'),
    path('category/<int:did>/', CourseCategoryViews.as_view(), name='category'),
]

视图中获取数据时,提取路由参数作为查询的条件,

apps/course/views.py,代码:

python
from django.shortcuts import render
from rest_framework.generics import ListCreateAPIView, ListAPIView
from course.models import CourseDirection, CourseCategory
from course.serializer import CourseDirectionModelSerializer, CourseCategoryModelSerializer
from rest_framework.response import Response
from rest_framework import status

# Create your views here.

class CourseDirectionViews(ListAPIView):
    queryset = CourseDirection.objects.filter(is_deleted=False, is_show=True).order_by('-id')
    serializer_class = CourseDirectionModelSerializer


class CourseCategoryViews(ListAPIView):
    queryset = CourseCategory.objects.filter(is_deleted=False, is_show=True).order_by('-id')
    serializer_class = CourseCategoryModelSerializer

    def get(self, request, did):
        if did != 0:
            instance = self.get_queryset().filter(direction_id=did)
            serializer = self.get_serializer(instance=instance, many=True)
            return Response(serializer.data, status=status.HTTP_200_OK)
        return super().get(self, request)

Serializer代码不变。

提交代码版本

python
cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 当用户点击不同的学习方向时,显示不同方向下的课程分类信息"
git push

5.1.15 课程信息列表展示

服务端提供课程信息列表的api接口

序列化器,courses.serializers,代码:

python
from .models import Course


class CourseInfoModelSerializer(serializers.ModelSerializer):
    """课程信息的序列化器"""
    class Meta:
        model = Course
        fields = [
            "id", "name", "course_cover", "level", "get_level_display",
            "students", "status", "get_status_display",
            "lessons", "pub_lessons", "price", "discount"
        ]

模型,courses/models.py,代码:

python
class Course(BaseModel):
    course_type = (
        (0, '付费购买'),
        (1, '会员专享'),
        (2, '学位课程'),
    )
    level_choices = (
        (0, '初级'),
        (1, '中级'),
        (2, '高级'),
    )
    status_choices = (
        (0, '上线'),
        (1, '下线'),
        (2, '预上线'),
    )
    # course_cover = models.ImageField(upload_to="course/cover", max_length=255, verbose_name="封面图片", blank=True, null=True)
    course_cover = StdImageField(variations={
        'thumb_1080x608': (1080, 608),   # 高清图
        'thumb_540x304': (540, 304),    # 中等比例,
        'thumb_108x61': (108, 61, True),  # 小图(第三个参数表示保持图片质量),
    }, max_length=255, delete_orphans=True, upload_to="course/cover", null=True, verbose_name="封面图片",blank=True)

    course_video = models.FileField(upload_to="course/video", max_length=255, verbose_name="封面视频", blank=True, null=True)
    course_type = models.SmallIntegerField(choices=course_type,default=0, verbose_name="付费类型")
    level = models.SmallIntegerField(choices=level_choices, default=1, verbose_name="难度等级")
    description = RichTextUploadingField(null=True, blank=True, verbose_name="详情介绍")
    pub_date = models.DateField(auto_now_add=True, verbose_name="发布日期")
    period = models.IntegerField(default=7, verbose_name="建议学习周期(day)")
    attachment_path = models.FileField(max_length=1000, blank=True, null=True, verbose_name="课件路径")
    attachment_link = models.CharField(max_length=1000, blank=True, null=True, verbose_name="课件链接")
    status = models.SmallIntegerField(choices=status_choices, default=0, verbose_name="课程状态")
    students = models.IntegerField(default=0, verbose_name="学习人数")
    lessons = models.IntegerField(default=0, verbose_name="总课时数量")
    pub_lessons = models.IntegerField(default=0, verbose_name="已更新课时数量")
    price = models.DecimalField(max_digits=10,decimal_places=2, verbose_name="课程原价",default=0)
    recomment_home_hot = models.BooleanField(default=False, verbose_name="是否推荐到首页新课栏目")
    recomment_home_top = models.BooleanField(default=False, verbose_name="是否推荐到首页必学栏目")
    direction = models.ForeignKey("CourseDirection", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="学习方向")
    category = models.ForeignKey("CourseCategory", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="课程分类")
    teacher = models.ForeignKey("Teacher", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="授课老师")

    class Meta:
        db_table = "fg_course_info"
        verbose_name = "课程信息"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s" % self.name

    def course_cover_small(self):
        if self.course_cover:
            return mark_safe(f'<img style="border-radius: 0%;" src="{self.course_cover.thumb_108x61.url}">')
        return ""

    course_cover_small.short_description = "封面图片(108x61)"
    course_cover_small.allow_tags = True
    course_cover_small.admin_order_field = "course_cover"

    def course_cover_medium(self):
        if self.course_cover:
            return mark_safe(f'<img style="border-radius: 0%;" src="{self.course_cover.thumb_540x304.url}">')
        return ""

    course_cover_medium.short_description = "封面图片(540x304)"
    course_cover_medium.allow_tags = True
    course_cover_medium.admin_order_field = "course_cover"

    def course_cover_large(self):
        if self.course_cover:
            return mark_safe(f'<img style="border-radius: 0%;" src="{self.course_cover.thumb_1080x608.url}">')
        return ""

    course_cover_large.short_description = "封面图片(1080x608)"
    course_cover_large.allow_tags = True
    course_cover_large.admin_order_field = "course_cover"

    @property
    def discount(self):
        # todo 将来通过计算获取当前课程的折扣优惠相关的信息
        import random
        return {
            "type": ["限时优惠","限时减免"].pop(random.randint(0,1)), # 优惠类型
            "expire": random.randint(100000, 1200000),  #  优惠倒计时
            "price": self.price - random.randint(1,10) * 10,  # 优惠价格
        }

视图,courses/views.py,代码:

python
from rest_framework.generics import ListAPIView
from .models import CourseDirection, CourseCategory, Course
from .serializers import CourseDirectionModelSerializer, CourseCategoryModelSerializer, CourseInfoModelSerializer


# Create your views here.
class CourseDirectionListAPIView(ListAPIView):
    """学习方向"""
    queryset = CourseDirection.objects.filter(is_show=True, is_deleted=False).order_by("orders", "-id")
    serializer_class = CourseDirectionModelSerializer
    pagination_class = None


class CourseCategoryListAPIView(ListAPIView):
    """学习分类"""
    # queryset = CourseCategory.objects.filter(is_show=True, is_deleted=False).order_by("orders","-id")
    serializer_class = CourseCategoryModelSerializer
    pagination_class = None

    def get_queryset(self):
        # 类视图中,获取路由参数
        queryset = CourseCategory.objects.filter(is_show=True, is_deleted=False)
        # 如果direction为0,则表示查询所有的课程分类,如果大于0,则表示按学习方向来查找课程分类
        direction = int(self.kwargs.get("direction", 0))
        if direction > 0:
            queryset = queryset.filter(direction=direction)

        return queryset.order_by("orders", "id").all()


# url: /course/学习方向ID/课程分类
# url: /course/P<direction>\d+)/(?P<category>\d+)$/
# url: /course/0/0  # 展示所有的课程列表信息,不区分学习方向和课程分类
# url: /course/1/0  # 展示前端开发学习方向的课程列表信息,不区分课程分类
# url: /course/1/5  # 展示前端开发学习方向下javascript课程分类的课程列表信息
class CourseListAPIView(ListAPIView):
    """课程列表接口"""
    serializer_class = CourseInfoModelSerializer

    def get_queryset(self):
        queryset = Course.objects.filter(is_deleted=False, is_show=True).order_by("-orders", "-id")
        direction = int(self.kwargs.get("direction", 0))
        category = int(self.kwargs.get("category", 0))
        # 只有在学习方向大于0的情况下才进行学习方向的过滤
        if direction > 0:
            queryset = queryset.filter(direction=direction)

        # 只有在课程分类大于0的情况下才进行课程分类的过滤
        if category > 0:
            queryset = queryset.filter(category=category)

        return queryset.all()

courses.urls,路由:

python
from django.urls import path, re_path
from . import views

urlpatterns = [
    path("directions/", views.CourseDirectionListAPIView.as_view()),
    re_path(r"categories/(?P<direction>\d+)/", views.CourseCategoryListAPIView.as_view()),
    re_path(r"^(?P<direction>\d+)/(?P<category>\d+)/$", views.CourseListAPIView.as_view()),
]

客户端发送请求获取课程列表信息

src/api/course.js,代码:

javascript
import http from "../utils/http";
import {reactive, ref} from "vue"


const course = reactive({
    current_direction: 0,  // 当前选中的学习方向,0表示所有方向
    current_category: 0,  // 当前选中的课程分类,0表示不限分类
    direction_list: [],    // 学习方向列表
    category_list: [],    // 课程分类列表
    course_list: [],       // 课程列表数据
    get_course_direction(){
        // 获取学习方向信息
        return http.get("/courses/directions/")
    },
    get_course_category () {
        // 获取课程分类信息
        return http.get(`/courses/categories/${this.current_direction}/`)
    },
    get_course_list () {
        // 获取课程列表信息
      return http.get(`/courses/${this.current_direction}/${this.current_category}/`)
    }
})

export default course;

Course.vue,代码:

vue
<template>
  <div class="course">
    <Header></Header>
    <div class="top-wrap">
        <div class="actual-header">
            <div class="actual-header-wrap">
                <div class="banner">
                    <router-link class="title" to="/course"><img class="h100" src="../assets/coding-title.png" alt=""></router-link>
                    <div>真实项目实战演练</div>
                </div>
                <div class="actual-header-search">
                    <div class="search-inner">
                        <input class="actual-search-input" placeholder="搜索感兴趣的实战课程内容" type="text" autocomplete="off">
                        <img class="actual-search-button" src="../assets/search.svg" />
                    </div>
                    <div class="actual-searchtags">
                    </div>
                    <div class="search-hot">
                        <span>热搜:</span>
                        <a href="">Java工程师</a>
                        <a href="">Vue</a>
                    </div>
                </div>
            </div>
        </div>
        <div class="type">
            <div class="type-wrap">
                <div class="one warp">
                    <span class="name">方向:</span>
                    <ul class="items">
                        <li :class="{cur:course.current_direction===0}" @click.prevent.stop="course.current_direction=0"><a href="">全部</a></li>
                        <li :class="{cur:course.current_direction===direction.id}" @click.prevent.stop="course.current_direction=direction.id" v-for="direction in course.direction_list"><a href="">{{direction.name}}</a></li>
                    </ul>
                </div>
                <div class="two warp">
                    <span class="name">分类:</span>
                    <ul class="items">
                        <li :class="{cur:course.current_category===0}"><a href="" @click.prevent.stop="course.current_category=0">不限</a></li>
                        <li :class="{cur:course.current_category===category.id}" v-for="category in course.category_list"><a href="" @click.prevent.stop="course.current_category=category.id">{{category.name}}</a></li>
                    </ul>
                </div>
            </div>
        </div>
    </div>
    <div class="main">
        <div class="main-wrap">
            <div class="filter clearfix">
                <div class="sort l">
                  <a href="" class="on">最新</a>
                  <a href="">销量</a>
                  <a href="">升级</a>
                </div>
                <div class="other r clearfix"><a class="course-line l" href="" target="_blank">学习路线</a></div>
            </div>
            <ul class="course-list clearfix">
              <li class="course-card" v-for="course_info in course.course_list">
                <a target="_blank" href="">
                    <div class="img"><img :src="course_info.course_cover" alt=""></div>
                    <p class="title ellipsis2">{{course_info.name}}</p>
                    <p class="one">
                        <span>{{course_info.get_level_display}} · {{course_info.students}}人报名</span>
                        <span class="discount r">
                          <i class="name" v-if="course_info.discount.type">{{course_info.discount.type}}</i>
                          <i class="countdown" v-if="course_info.discount.expire">{{parseInt(course_info.discount.expire/86400)}}<span class="day">天</span>{{fill0(parseInt(course_info.discount.expire/3600%24))}}:{{fill0(parseInt(course_info.discount.expire/60%60))}}:{{fill0(parseInt(course_info.discount.expire%60))}}</i>
                        </span>
                    </p>
                    <p class="two clearfix">
                        <span class="price l red bold" v-if="course_info.discount.price">¥{{parseFloat(course_info.discount.price).toFixed(2)}}</span>
                        <span class="price l red bold" v-else>¥{{parseFloat(course_info.price).toFixed(2)}}</span>
                        <span class="origin-price l delete-line" v-if="course_info.discount.price">¥{{parseFloat(course_info.price).toFixed(2)}}</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </a>
              </li>
            </ul>
            <div class="page">
                <span class="disabled_page">首页</span>
                <span class="disabled_page">上一页</span>
                <a href="" class="active">1</a>
                <a href="">2</a>
                <a href="">3</a>
                <a href="">4</a>
                <a href="">下一页</a>
                <a href="">尾页</a>
            </div>
        </div>
    </div>
    <Footer></Footer>
  </div>
</template>
vue
<script setup>
import {reactive,ref, watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import course from "../api/course";
import {fill0} from "../utils/func";


// 获取学习方向的列表数据
course.get_course_direction().then(response=>{
  course.direction_list = response.data;
})


// 获取课程分类的列表数据
const get_category = ()=>{
  // 获取课程分类
  course.get_course_category().then(response=>{
    course.category_list = response.data;
  })
}

get_category();


const get_course_list = ()=>{
  // 获取课程列表
  course.get_course_list().then(response=>{
    course.course_list = response.data;
  })
}

get_course_list();


watch(
    // 监听当前学习方向,在改变时,更新对应方向下的课程分类与课程信息
    ()=> course.current_direction,
    ()=>{
        // 重置当前选中的课程分类
        course.current_category=0;
        get_category();
        get_course_list();
    }
)

watch(
    // 监听切换不同的课程分类,在改变时,更新对应分类下的课程信息
    ()=> course.current_category,
    ()=>{
        get_course_list();
    }
)

</script>

src/utils/func.js,代码:

javascript
// 给小于10的数字左边补0
export function fill0(num){
    return num<10?"0"+num: num;
}

提交代码版本

python
cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 根据不同的学习方向与课程分类,展示课程列表信息"
git push

5.1.16 排序展示课程信息

后端提供排序课程的接口,只需要在courses/views.py把原来views.py中的CoursesAPIView新增两句代码:

python
from rest_framework.generics import ListAPIView
from .models import CourseDirection, CourseCategory, Course
from .serializers import CourseDirectionModelSerializer, CourseCategoryModelSerializer, CourseInfoModelSerializer
from rest_framework.filters import OrderingFilter

# 中间代码省略...

# url: /course/学习方向ID/课程分类
# url: /course/P<direction>\d+)/(?P<category>\d+)$/
# url: /course/0/0  # 展示所有的课程列表信息,不区分学习方向和课程分类
# url: /course/1/0  # 展示前端开发学习方向的课程列表信息,不区分课程分类
# url: /course/1/5  # 展示前端开发学习方向下javascript课程分类的课程列表信息
class CourseListAPIView(ListAPIView):
    """课程列表接口"""
    serializer_class = CourseInfoModelSerializer
    filter_backends = [OrderingFilter, ]
    ordering_fields = ['id', 'students', 'orders']

    def get_queryset(self):
        queryset = Course.objects.filter(is_deleted=False, is_show=True).order_by("-orders", "-id")
        direction = int(self.kwargs.get("direction", 0))
        category = int(self.kwargs.get("category", 0))
        # 只有在学习方向大于0的情况下才进行学习方向的过滤
        if direction > 0:
            queryset = queryset.filter(direction=direction)

        # 只有在课程分类大于0的情况下才进行课程分类的过滤
        if category > 0:
            queryset = queryset.filter(category=category)

        return queryset.all()

客户端根据排序字段对应的课程顺序

src/api/course.js,代码:

javascript
import http from "../utils/http";
import {reactive, ref} from "vue"


const course = reactive({
    current_direction: 0,  // 当前选中的学习方向,0表示所有方向
    current_category: 0,   // 当前选中的课程分类,0表示不限分类
    direction_list: [],    // 学习方向列表
    category_list: [],     // 课程分类列表
    course_list: [],       // 课程列表数据
    ordering: "-id",       // 课程排序条件
    get_course_direction(){
        // 获取学习方向信息
        return http.get("/courses/directions/")
    },
    get_course_category () {
        // 获取课程分类信息
        return http.get(`/courses/categories/${this.current_direction}/`)
    },
    get_course_list () {
        // 获取课程列表信息
        let params = {}
        if(this.ordering){
            params.ordering = this.ordering;
        }
      return http.get(`/courses/${this.current_direction}/${this.current_category}/`, {
            params, // params: params 的简写
        })
    }
})

export default course;

views/Course.vue,代码

html
            <div class="filter clearfix">
                <div class="sort l">
                  <a href="" :class="{on:course.ordering==='-id'}" @click.prevent.stop="course.ordering=(course.ordering==='-id'?'':'-id')">最新</a>
                  <a href="" :class="{on:course.ordering==='-students'}" @click.prevent.stop="course.ordering=(course.ordering==='-students'?'':'-students')">销量</a>
                  <a href="" :class="{on:course.ordering==='-orders'}" @click.prevent.stop="course.ordering=(course.ordering==='-orders'?'':'-orders')">推荐</a>
                </div>
                <div class="other r clearfix"><a class="course-line l" href="" target="_blank">学习路线</a></div>
            </div>
vue
<script setup>
import {reactive,ref, watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import course from "../api/course";
import {fill0} from "../utils/func";


// 获取学习方向的列表数据
course.get_course_direction().then(response=>{
  course.direction_list = response.data;
})


// 获取课程分类的列表数据
const get_category = ()=>{
  // 获取课程分类
  course.get_course_category().then(response=>{
    course.category_list = response.data;
  })
}

get_category();



const get_course_list = ()=>{
  // 获取课程列表
  course.get_course_list().then(response=>{
    course.course_list = response.data;
  })
}

get_course_list();


watch(
    // 监听当前学习方向,在改变时,更新对应方向下的课程分类与课程信息
    ()=> course.current_direction,
    ()=>{
        // 重置排序条件
        course.ordering = "-id";
        // 重置当前选中的课程分类
        course.current_category=0;
        get_category();
        get_course_list();
    }
)

watch(
    // 监听切换不同的课程分类,在改变时,更新对应分类下的课程信息
    ()=> course.current_category,
    ()=>{
        // 重置排序条件
        course.ordering = "-id";
        get_course_list();
    }
)


watch(
    // 监听课程切换不同的排序条件
    ()=>course.ordering,
    ()=>{
        get_course_list();
    }
)


</script>

5.1.17 分页展示课程信息

服务端调整课程信息的api接口,实现分页查询。

courses.paginations,代码:

python
from rest_framework.pagination import PageNumberPagination


class CourseListPageNumberPagination(PageNumberPagination):
    """课程信息列表分页器"""
    page_size = 5
    max_page_size = 20
    page_size_query_param = "size"
    page_query_param = "page"

courses.views,代码:

python
from rest_framework.generics import ListAPIView
from .models import CourseDirection, CourseCategory, Course
from .serializers import CourseDirectionModelSerializer, CourseCategoryModelSerializer, CourseInfoModelSerializer
from rest_framework.filters import OrderingFilter
from .paginations import CourseListPageNumberPagination

# url: /course/学习方向ID/课程分类
# url: /course/P<direction>\d+)/(?P<category>\d+)$/
# url: /course/0/0  # 展示所有的课程列表信息,不区分学习方向和课程分类
# url: /course/1/0  # 展示前端开发学习方向的课程列表信息,不区分课程分类
# url: /course/1/5  # 展示前端开发学习方向下javascript课程分类的课程列表信息
class CourseListAPIView(ListAPIView):
    """课程列表接口"""
    serializer_class = CourseInfoModelSerializer
    filter_backends = [OrderingFilter, ]
    ordering_fields = ['id', 'students', 'orders']
    pagination_class = CourseListPageNumberPagination

    def get_queryset(self):
        queryset = Course.objects.filter(is_deleted=False, is_show=True).order_by("-orders", "-id")
        direction = int(self.kwargs.get("direction", 0))
        category = int(self.kwargs.get("category", 0))
        # 只有在学习方向大于0的情况下才进行学习方向的过滤
        if direction > 0:
            queryset = queryset.filter(direction=direction)

        # 只有在课程分类大于0的情况下才进行课程分类的过滤
        if category > 0:
            queryset = queryset.filter(category=category)

        return queryset.all()

因为服务端改成分页展示数据,所以返回的数据的结构发生了改变,而且需要根据数据量来决定是否展示分页或者展示分页页码

api/course.js,代码:

javascript
import http from "../utils/http";
import {reactive, ref} from "vue"

const course = reactive({
    current_direction: 0,  // 当前选中的学习方向,0表示所有方向
    current_category: 0,   // 当前选中的课程分类,0表示不限分类
    direction_list: [],    // 学习方向列表
    category_list: [],     // 课程分类列表
    course_list: [],       // 课程列表数据
    ordering: "",          // 课程排序条件
    page: 1,               // 当前页码,默认为1
    size: 5,               // 当前页数据量
    count: 0,         // 课程信息列表的数量
    has_perv: false,  // 是否有上一页
    has_next: false,  // 是否有下一页
    timer: null,      // 课程相关数据的定时器
    // 获取学习方向信息
    get_course_direction(){
        return http.get("/courses/direction")
    },
    // 获取课程分类信息
    get_course_category () {
      return http.get(`/courses/category/${this.current_direction}/`,)
    },
    // 获取课程列表信息
    get_course_list () {
      let params = {
          page: this.page,
          size: this.size,
      }
      if(this.ordering){
        params.ordering = this.ordering;
      }
      return http.get(`/courses/${this.current_direction}/${this.current_category}/`,{
        params,
      })
    }
})

export default course;

views/Course.vue,代码:

vue
<div class="page" v-if="course.count > course.size">
    <a href="" v-if="course.has_perv" @click.prevent.stop="course.page=1">首页</a>
    <span v-else>首页</span>
    <a href="" v-if="course.has_perv" @click.prevent.stop="course.page--">上一页</a>
    <span v-else>上一页</span>
    <a href="" v-if="course.has_perv" @click.prevent.stop="course.page--">{{course.page-1}}</a>
    <a class="active">{{course.page}}</a>
    <a href="" v-if="course.has_next" @click.prevent.stop="course.page++">{{course.page+1}}</a>
    <a href="" v-if="course.has_next" @click.prevent.stop="course.page++">下一页</a>
    <span v-else>下一页</span>
    <a href="" v-if="course.has_next" @click.prevent.stop="course.page=Math.ceil(course.count/course.size)">尾页</a>
    <span v-else>尾页</span>
</div>
vue
<script setup>
import {reactive,ref, watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import course from "../api/course";
import {fill0} from "../utils/func";


// 获取学习方向的列表数据
course.get_course_direction().then(response=>{
  course.direction_list = response.data;
})


// 获取课程分类的列表数据
const get_category = ()=>{
  // 获取课程分类
  course.get_course_category().then(response=>{
    course.category_list = response.data;
  })
}

get_category();



const get_course_list = ()=>{
  // 获取课程列表
  course.get_course_list().then(response=>{
    course.course_list = response.data.results;
    // 总数据量
    course.count = response.data.count;
    course.has_perv = !!response.data.previous; // !!2个非表示把数据转换成布尔值
    course.has_next = !!response.data.next;
  })
}

get_course_list();


watch(
    // 监听当前学习方向,在改变时,更新对应方向下的课程分类与课程信息
    ()=> course.current_direction,
    ()=>{
        // 重置排序条件
        course.ordering = "-id";
        // 重置当前选中的课程分类
        course.current_category=0;
        get_category();
        get_course_list();
    }
)

watch(
    // 监听切换不同的课程分类,在改变时,更新对应分类下的课程信息
    ()=> course.current_category,
    ()=>{
        // 重置排序条件
        course.ordering = "-id";
        get_course_list();
    }
)


watch(
    // 监听课程切换不同的排序条件
    ()=>course.ordering,
    ()=>{
        get_course_list();
    }
)

// 监听页码
watch(
    ()=>course.page,
    ()=>{
        // 重新获取课程信息
        get_course_list();
    }
)

</script>

5.1.18 使用计时器让活动时间不断减少