路飞项目
3. 环境搭建
3.1 外部依赖
- 注册支付宝的开发者账号[https://open.alipay.com],注册一下账号就可以了,剩下的以后再说
- 注册容联云短信接口平台的账号[https://www.yuntongxun.com/?ly=baidu-pz-p&qd=cpc&cp=ppc&xl=null&kw=10360228]
- 注册保利威视频服务平台的账号[暂时别注册,因为有个7天免费测试期,如果到时候过期了就没法用了,网址:http://www.polyv.net/?f=baiduPZ&utm_term=%E4%BF%9D%E5%88%A9%E5%A8%81]
- 注册gitee[码云]的账号
- 注册阿里云账号,如果可以购买一个服务器和域名, 或者第一次使用的可以申请一个免费外网服务器
- 如果有条件的,可以申请一个域名进行备案[ICP备案和公安部备案],如果没有的话, 可以注册natapp[内网穿透]
3.2 依赖包安装
pip3 install django -i https://pypi.douban.com/simple/ # 注意:在虚拟环境中安装第三方包的时候,不要使用sudo,因为sudo是以管理员身份来安装的,会将安装的东西安装到全局中去,而不是虚拟环境中,并且在linux系统下不要出现中文路径
pip3 install djangorestframework -i https://pypi.douban.com/simple/
pip3 install PymySQL -i https://pypi.douban.com/simple/
pip3 install Pillow -i https://pypi.douban.com/simple/
pip3 install django-redis -i https://pypi.douban.com/simple/
4. 搭建项目
4.1 创建项目
可以使用pycharm-django直接创建django项目
也可以在终端使用命令创建
django-admin startproject luffyapi
4.2 调整目录
打开项目以后,调整目录结构,因为公司使用的结构和平常django是不太一样的
luffy/
├── docs/ # 项目相关资料保存目录
├── luffycity/ # 前端项目目录
├── luffyapi/ # 后端项目目录
├── logs/ # 项目运行时/开发时的代码保存
├── manage.py
├── luffyapi/ # 项目主应用,开发时的代码保存
│ ├── apps/ # 开发者的代码保存目录,以模块[子应用]为目录保存(包)
│ ├── libs/ # 第三方类库的保存目录[第三方组件,模块](包)
│ ├── settings/ #(包)
│ ├── dev.py # 项目开发时的本地配置
│ ├── prod.py # 项目上线时的运行配置
│ ├── test.py # 测试人员使用的配置(咱们不需要)
│ ├── urls.py # 总路由(包)
│ ├── utils/ # 多个模块[子应用]的公共函数类库[自己开发的组件]
└── scripts/ # 保存项目运营时的脚本文件
在编辑开发项目时,必须制定项目目录才能运行,例如,开发后端项目,则必须选择的目录是luffyapi
上面的目录结构图,使用ubuntu的命令tree输出的
如果没有安装tree,可以使用 sudo apt install tree
注意: 创建文件夹的时候,是创建包(憨init.py文件的)还是创建单纯的文件夹,看目录里面放什么,如果放的是py文件相关的代码,最好创建包,如果不是,那就创建单纯的文件夹
4.3 分不同环境进行项目配置
开发者本地的环境,目录,数据库密码和线上的服务器都会不一样,所以我们的配置文件可以针对不同的系统分成多份。
- 在项目主应用下,创建一个settings的配置文件存储目录
- 根据线上线下两种情况分别创建两个配置文件dev.py和prod.py
- 把原来的项目主应用的settings.py配置内容符合两份到dev.py和prod.py里面
- 把原来的settings.py配置文件修改文件名或者删除
接下里在manage.py根据不同的情况导入对应的配置文件
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'luffyapi.settings.dev')
4.4 创建代码版本
cd进入到自己希望存储代码的目录路径,并创建本地仓库.git(pycharm直接打开终端就是项目根目录了,无需cd)
新创建的本地仓库.git是空仓库
git init
git add . 或者文件名 # .代表所有文件
git status # 查看当前项目的版本状态
git commit -m '描述信息' # 可以写版本信息
git push 远程仓库名称 dev(分支名称) # 往远程仓库提交代码
git branch dev # 创建本地分支dev
git checkout dev # 切换到本地分支代码
4.5 配置用户名和邮箱(码云账号邮箱)
可以先注册码云
git config --global user.name '账号'
git config --global user.email '邮箱'
4.6 在gitee平台上创建仓库
公司一般都有自己的代码仓库,一般都是自己搭建,也有使用第三方提供的代码管理平台
常用的代码管理平台: github,gitee(码云),codepen
若果公司自己搭建的代码管理平台:gitlab框架
4.6.1创建一个公有仓库
创建仓库后的界面
仓库地址要选择HTTPS
接下来,我们就可以把本地新建好的项目提交到gitee码云上了
# .表示当前目录下所有的文件或目录提交到上传队列[上传队列也叫"暂存区"]
git add .
# 把本地上传队列的代码提交到本地仓库
git commit -m "项目描述"
# 给本地的git版本控制软件设置项目的远程仓库地址
git remote add origin https://gitee.com/cloud_chaoy/qunyyasha.git
# 提交代码给远程仓库
git push -u origin master
扩展:
git status 可以查看当前项目的代码版本状态
git reflog 可以查看代码版本日志[简单格式]
git log 可以查看代码版本日志[详细格式]
git branch -D 分支名称
删除分支时,必须切换到别的分支上才能进行删除
上面虽然成功移交了代码版本,但是一些不需要的文件也被提交上去了,所以我们针对一些不要的文件,可以选择从代码版本中删除,并且使用.gitignore把这些垃圾文件过滤掉
git rm 文件 # 删除单个文件
git rm -rf 目录 # 递归删除目录
# 以下操作建议通过终端来完成,不要使用pycharm提供,否则删除.idea还会继续生成。
git rm -rf .idea
git rm db.sqlite3
# 注意,上面的操作只是从项目的源代码中删除,但是git是不知情的,所以我们需要同步。
git add .
git commit -m "删除不必要的文件或目录"
git push -u origin master
使用.gitignore
把一些垃圾文件过滤掉
vim .gitignore
index.html
.gitignore
./lyapi/idea
./lyapi/idea/*
./git
./lyapi/db.sqlite3
4.6.2 克隆项目到本地
注意:
克隆只用在当我们进入一家新公司的时候,参与人家已经在做的项目,人家已经有仓库了,但我们新加入到项目中,这时我们就可以执行 git clone 直接复制别人的仓库代码
如果当前目录下出现git仓库同名目录时,会克隆失败
4.7 日志配置
django官方文档
在settings/dev.py文件中追加如下配置:
# 日志配置
LOGGING = {
'version': 1, #使用的python内置的logging模块,那么python可能会对它进行升级,所以需要写一个版本号,目前就是1版本
'disable_existing_loggers': False, #是否去掉目前项目中其他地方中以及使用的日志功能,但是将来我们可能会引入第三方的模块,里面可能内置了日志功能,所以尽量不要关闭。
'formatters': { #日志记录格式
'verbose': { #levelname等级,asctime记录时间,module表示日志发生的文件名称,lineno行号,message错误信息
'format': '%(levelname)s %(asctime)s %(module)s %(lineno)d %(message)s'
},
'simple': {
'format': '%(levelname)s %(module)s %(lineno)d %(message)s'
},
},
'filters': { #过滤器:可以对日志进行输出时的过滤用的
'require_debug_true': { #在debug=True下产生的一些日志信息,要不要记录日志,需要的话就在handlers中加上这个过滤器,不需要就不加
'()': 'django.utils.log.RequireDebugTrue',
},
'require_debug_false': { #和上面相反
'()': 'django.utils.log.RequireDebugFalse',
},
},
'handlers': { #日志处理方式,日志实例
'console': { #在控制台输出时的实例
'level': 'DEBUG', #日志等级;debug是最低等级,那么只要比它高等级的信息都会被记录
'filters': ['require_debug_true'], #在debug=True下才会打印在控制台
'class': 'logging.StreamHandler', #使用的python的logging模块中的StreamHandler来进行输出
'formatter': 'simple'
},
'file': {
'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler',
# 日志位置,日志文件名,日志保存目录必须手动创建
'filename': os.path.join(os.path.dirname(BASE_DIR), "logs/luffy.log"), #注意,你的文件应该有读写权限。
# 日志文件的最大值,这里我们设置300M
'maxBytes': 300 * 1024 * 1024,
# 日志文件的数量,设置最大日志数量为10
'backupCount': 10,
# 日志格式:详细格式
'formatter': 'verbose',
'encoding': 'utf-8', # 设置默认编码,否则打印出来汉字乱码
},
},
# 日志对象
'loggers': {
'django': { #和django结合起来使用,将django中之前的日志输出内容的时候,按照我们的日志配置进行输出,
'handlers': ['console', 'file'], #将来项目上线,把console去掉
'propagate': True, #冒泡:是否将日志信息记录冒泡给其他的日志处理系统,工作中都是True,不然django这个日志系统捕获到日志信息之后,其他模块中可能也有日志记录功能的模块,就获取不到这个日志信息了
},
}
}
4.8 异常处理
新建utils/execptions.py
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.py配置文件中添加
REST_FRAMEWORK = {
# 异常处理
'EXCEPTION_HANDLER': 'luffyapi.utils.exceptions.custom_exception_handler',
}
4.9 创建数据库
create database luffy default charset=utf8mb4; -- utf8也会导致有些极少的中文出现乱码的问题,mysql5.5之后官方才进行处理,出来了utf8mb4,这个是真正的utf8,能够容纳所有的中文,其实一般情况下utf8就够用了。
为当前目录创建数据库用户(这个用户只能看到这个数据库)
create user chao identified by '123';
grant all privileges on luffy.* to 'chao'@'%';
flush privileges;
mysql -u chao -p123
select user(); #chao
4.9.1 配置数据库连接
在settings/dev.py文件中配置
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"HOST": "127.0.0.1",
"PORT": 3306,
"USER": "chao",
"PASSWORD": "123",
"NAME": "luffy",
}
}
在项目主模块的__init__.py
中导入pymysql
import pymysql
pymysql.install_as_MySQLdb()
5. 前端项目初始化
cd到路飞项目下创建一个luffcity前端项目
vue init webpack luffycity
在src目录下创建settings.js站点开发配置文件:
export default {
Host:"http://www.luffyapi.com:8000", // 后台接口
}
sudo vim /etc/hosts/
127.0.0.1 localhost
127.0.1.1 ubuntu
# The following lines are desirable for IPv6 capable hosts
::1 ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
127.0.0.1 www.luffycc.com
127.0.0.1 www.luffyapi.com
然后到后端luffyapi中,设置manage.py
runserver www.luffyapi.com:8000
前端luffycity中,main.js
// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue'
import App from './App'
import router from './router'
import settings from './settings'
Vue.config.productionTip = false
Vue.prototype.$settings = settings
/* eslint-disable no-new */
new Vue({
el: '#app',
router,
components: { App },
template: '<App/>'
})
Edit Configurations 添加 npm 设置 dev
引入elementUI
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import axios from 'axios'
Vue.use(ElementUI);
复制组件和图片资源
src里的router配置一下,把没用的hellword删掉,加mode:'history'去掉路径里的#
config/index.js里面的 host改一下 : www.luffycc.com
在static/css/style.css 里写全局css样式
在main.js中引入一下
import '../static/css/style.css'
6. cors跨域
在settings/dev.py里面
# 设置哪些客户端可以通过地址访问到后端
ALLOWED_HOSTS = ['www.luffyapi.com','www.luffycc.com']
# 自己的客户端网址也要设置,将来要访问到服务端
现在,前端与后端分出不同的域名,我们需要为后端添加跨域访问的支持否则前端无法使用axios请求后端提供的api数据,可以使用CORS来解决后端对跨域访问的支持
使用django-cors-headers扩展
Response(headers={"Access-Control-Allow-Origin":'客户端地址'})
文档:https://github.com/ottoyiu/django-cors-headers/
安装
pip3 install django-cors-headers
添加应用
INSTALLED_APPS = (
...
'corsheaders',
...
)
中间件设置(必须写在第一个位置)
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', #放在中间件的最上面,就是给响应头加上了一个响应头跨域
...
]
需要添加白名单,确定一下哪些客户端可以跨区
# CORS组的配置信息
CORS_ORIGIN_WHITELIST = (
#'www.luffycity.cn:8080', #如果这样写不行的话,就加上协议(http://www.luffycity.cn:8080,因为不同的corsheaders版本可能有不同的要求)
'http://www.luffycc.com:8080', # 一定要加逗号啊
)
CORS_ALLOW_CREDENTIALS = False # 是否允许ajax跨域请求时携带cookie,False表示不用,我们后面也用不到cookie,所以关掉它就可以了,以防有人通过cookie来搞我们的网站
前端引入axios插件
npm i axios -S --registry https://registry.npm.taobao.org
在main.js中引用axios
import axios from 'axios'; // 从node_modules目录中导入包
// 客户端配置是否允许ajax发送请求时附带cookie,false表示不允许
axios.defaults.withCredentials = false;
Vue.prototype.$axios = axios; // 把对象挂载vue中
如果你拷贝前端vue-cli项目到指定目录下,运行有问题,报一些不知名的错误,那么就删除node_modules
文件夹,然后在项目根目录下执行npm install
,重新按照package.json文件夹中的包进行node_modules里面包的下载。
都设置好后 项目启动没有问题
cd 到项目目录
git add .
git commit -m 'v1 初始化项目'
git log
git push origin master # 推到远程仓库上
7. 轮播图功能实现
7.1 安装依赖模块和配置
后端
图片处理模块
pip3 install pillow
上传文件相关配置
settings.py,由于我们需要在后台上传轮播图图片,所以需要在django中配置一下上传文件的相关配置,有了它以后,就不需要我们自己写上传文件和保存文件的操作了
# 访问静态文件的url地址前缀
STATIC_URL = '/static/'
# 设置django的静态文件目录
STATICFILES_DIRS = [
os.path.join(BASE_DIR,"static")
]
# 项目中存储上传文件的根目录[暂时配置],注意,uploads目录需要手动创建否则上传文件时报错
MEDIA_ROOT=os.path.join(BASE_DIR,"uploads")
# 访问上传文件的url地址前缀
MEDIA_URL ="/media/"
总路由urls.py
from django.urls import re_path
from django.conf import settings
from django.views.static import serve
urlpatterns = [
...
re_path(r'media/(?P<path>.*)', serve, {"document_root": settings.MEDIA_ROOT}),
]
7.2 注册home子应用
因为当前功能是drf的第一个功能,所以我们先创建一个子应用home,创建在luffyapi/apps目录下
python3 ../../manage.py startapp home
注册home子应用,因为子应用的位置发生了改变(调整目录结构的时候),所以要新增一个导包路径
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 新增一个系统导包路径
import sys
#sys.path使我们可以直接import导入时使用到的路径,所以我们直接将我们的apps路径加到默认搜索路径里面去,那么django就能直接找到apps下面的应用了
sys.path.insert(0,os.path.join(BASE_DIR,"apps"))
INSTALLED_APPS = [
# 注意,加上drf框架的注册
'rest_framework',
# 子应用
'home',
]
注意,pycharm会路径错误的提示。可以鼠标右键设置apps为 mark dir.... as source root,不推荐,因为这是pycharm提供的。
7.3 新建开发分支进行独立开发
接下来,我们完成的功能[轮播图]这些,建议采用开发分支来完成,所以我们可以通过以下命令,复刻一份代码[也就是新建一个分支]出来进行独立开发.这样的话,就不会影响到线上的主干代码!!!
# 新建一个分支
git branch 分支名称
# 查看所有分支
git branch
# 切换分支[-b表示新建分支的同时并切换到新分支]
git checkout -b 分支名称
# 删除分支
git branch -d 分支名称
接下来,我们可以创建一个dev开发分支并在开发分支下干活!
git branch dev
git checkout dev
7.4 创建轮播图的模型
home/models.py
from django.db import models
# Create your models here.
class Banner(models.Model):
"""轮播广告图模型"""
# 模型字段
title = models.CharField(max_length=500, verbose_name="广告标题")
link = models.CharField(max_length=500, verbose_name="广告链接")
# upload_to 设置上传文件的保存子目录,将来上传来的文件会存到我们的media下面的banner文件夹下,这里存的是图片地址。
image_url = models.ImageField(upload_to="banner", null=True, blank=True, max_length=255, verbose_name="广告图片")
remark = models.TextField(verbose_name="备注信息")
is_show = models.BooleanField(default=False, verbose_name="是否显示") #将来轮播图肯定会更新,到底显示哪些
orders = models.IntegerField(default=1, verbose_name="排序")
is_deleted = models.BooleanField(default=False, verbose_name="是否删除")
# 表信息声明
class Meta:
db_table = "ly_banner"
verbose_name = "轮播广告"
verbose_name_plural = verbose_name
# 自定义方法[自定义字段或者自定义工具方法]
def __str__(self):
return self.title
数据迁移指令
python manage.py makemigrations
python manage.py migrate
7.4.1 序列化器
home/serializers.py(自己创建一个)
from rest_framework import serializers
from . import models
class BannerModelSerializer(serializers.ModelSerializer):
""" 轮播广告的序列化器 """
class Meta:
model = models.Banner
fields = ['id','image_url','link']
7.4.2 视图代码
views.py
from django.shortcuts import render
from rest_framework.generics import ListAPIView
# Create your views here.
from . import models
from luffyapi.settings import contains
from .serializers import BannerModelSerializer,NavModelSerializer
class BannerView(ListAPIView):
queryset = models.Banner.objects.filter(is_deleted=False,is_show=True)[0:contains.BANNER_LENGTH] #没有必要获取所有图片数据,因为有些可能是删除了的或者不显示的
# 切片获取数据的时候,我们可以将切片长度设置成常熟默认配置项,用来控制前端的页面展示效果
serializer_class = BannerModelSerializer
在settings下新建一个contains.py 的文件存放我们所有的一些常量信息配置
# 首页展示的轮播图广告数量
BANNER_LENGTH = 3
# 顶部导航的数量
HEADER_NAV_LENGTH = 5
# 脚部导航的数量
FOOTER_NAV_LENGTH = 7
7.4.3 路由代码
home/urls.py
from django.urls import path
from . import views
urlpatterns = [
path(r'banner/',views.BannerView.as_view()),
]
把home的路由urls.py注册到总路由中
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'media/(?P<path>.*)', serve, {"document_root": settings.MEDIA_ROOT}),
path('home/', include("home.urls") ),
]
8. Xadmin
我们还需要有一个后台提供数据管理操作,安装xadmin,他的功能要比django默认的admin的功能更强大一点
pip3 install https://codeload.github.com/sshwsfc/xadmin/zip/django2 -i https://pypi.douban.com/simple/
在配置文件中注册如下应用
INSTALLED_APPS = [
...
'xadmin',
'crispy_forms',
'reversion',
...
]
# 修改使用中文界面
LANGUAGE_CODE = 'zh-Hans'
# 修改时区
TIME_ZONE = 'Asia/Shanghai'
xadmin有建立自己的数据库模型类,需要进行数据库迁移
python manage.py makemigrations
python manage.py migrate
8.1在总路由中添加xadmin的路由信息
import xadmin
xadmin.autodiscover()
# version模块自动注册需要版本控制的 Model
from xadmin.plugins import xversion
xversion.register_models()
urlpatterns = [
path(r'xadmin/', xadmin.site.urls),
]
如果之前没有创建超级用户,需要创建,如果有了,则可以直接使用之前的。
python manage.py createsuperuser
8.2 给xadmin设置基本站点配置信息
import xadmin
from xadmin import views
class BaseSetting(object):
"""xadmin的基本配置"""
enable_themes = True # 开启主题切换功能
use_bootswatch = True
xadmin.site.register(views.BaseAdminView, BaseSetting)
class GlobalSettings(object):
"""xadmin的全局配置"""
site_title = "路飞学城" # 设置站点标题
site_footer = "路飞学城有限公司" # 设置站点的页脚
menu_style = "accordion" # 设置菜单折叠
xadmin.site.register(views.CommAdminView, GlobalSettings)
8.4 注册轮播图模型到xadmin中
在当前子应用中创建adminx.py
import xadmin
from xadmin import views
from . import models
class BannerXAdmin(object):
list_display = ['id','title','link','image_url']
search_fields = ['id','title']
ordering = ['-id']
xadmin.site.register(models.Banner, BannerXAdmin)
8.5 修改后端xadmin中子应用名称
home/apps.py
class HomeConfig(AppConfig):
name = 'home'
verbose_name = '我的首页'
在home这app下面的__init__.py
中设置
default_app_config = "home.apps.HomeConfig"
手动在xadmin中把,轮播图图片信息添加进去
8.6 客户端获取后端数据
Banner.vue代码
<template>
<el-carousel indicator-position="outside" height="400px">
<el-carousel-item v-for="(value,index) in banner_list" :key="value.id">
<!-- <router-link :to="value.link">-->
<a :href="value.link">
<img :src="value.image_url" alt="" style="width: 100%;height: 400px;">
<!-- <img src="@/assets/banner1.png" alt="">-->
<!-- </router-link>-->
</a>
</el-carousel-item>
</el-carousel>
</template>
<script>
export default {
name: "Banner",
data(){
return {
banner_list:[
]
}
},
methods:{
get_banner_data(){
this.$axios.get(`${this.$settings.Host}/home/banner`)
.then((res)=>{
console.log(res);
this.banner_list = res.data
})
.catch((error)=>{
})
}
},
created(){
this.get_banner_data();
},
}
</script>
<style scoped>
</style>
9. 导航功能实现
9.1 创建模型
引入一个公共模型(抽象模型,不会在数据迁移的时候为它创建表)
from django.db import models
# Create your models here.
from django.db import models
class BaseModel(models.Model):
"""公共模型"""
is_show = models.BooleanField(default=False, verbose_name="是否显示")
orders = models.IntegerField(default=1, verbose_name="排序")
is_deleted = models.BooleanField(default=False, verbose_name="是否删除")
created_time = models.DateTimeField(auto_now_add=True, verbose_name="添加时间")
updated_time = models.DateTimeField(auto_now=True, verbose_name="修改时间")
#更新:update方法不能自动更新auto_now的时间,save()方法保存能够自动修改更新时间
class Meta:
# 设置当前模型为抽象模型,在数据迁移的时候django就不会为它单独创建一张表
abstract = True
# Create your models here.
class Banner(models.Model):
"""轮播广告图模型"""
# 模型字段
title = models.CharField(max_length=500, verbose_name="广告标题")
link = models.CharField(max_length=500, verbose_name="广告链接")
# upload_to 设置上传文件的保存子目录,将来上传来的文件会存到我们的media下面的banner文件夹下,这里存的是图片地址。
image_url = models.ImageField(upload_to="banner", null=True, blank=True, max_length=255, verbose_name="广告图片")
remark = models.TextField(verbose_name="备注信息")
is_show = models.BooleanField(default=False, verbose_name="是否显示") #将来轮播图肯定会更新,到底显示哪些
orders = models.IntegerField(default=1, verbose_name="排序")
is_deleted = models.BooleanField(default=False, verbose_name="是否删除")
# 表信息声明
class Meta:
db_table = "ly_banner"
verbose_name = "轮播广告"
verbose_name_plural = verbose_name
# 自定义方法[自定义字段或者自定义工具方法]
def __str__(self):
return self.title
class Nav(BaseModel):
"""导航菜单模型"""
POSITION_OPTION = (
(1, "顶部导航"),
(2, "脚部导航"),
)
title = models.CharField(max_length=500, verbose_name="导航标题")
link = models.CharField(max_length=500, verbose_name="导航链接")
position = models.IntegerField(choices=POSITION_OPTION, default=1, verbose_name="导航位置")
is_site = models.BooleanField(default=False, verbose_name="是否是站外地址")
class Meta:
db_table = 'luffy_nav'
verbose_name = '导航菜单'
verbose_name_plural = verbose_name
# 自定义方法[自定义字段或者自定义工具方法]
def __str__(self):
return self.title
数据迁移指令
python manage.py makemigrations
python manage.py migrate
9.2 序列化器
home/serializers.py
class NavModelSerializer(serializers.ModelSerializer):
""" 导航栏序列化器 """
class Meta:
model = models.Nav
fields = ['id','title','link','position','is_site']
9.3 视图
home/views.py
from django.shortcuts import render
from rest_framework.generics import ListAPIView
# Create your views here.
from . import models
from luffyapi.settings import contains
from .serializers import BannerModelSerializer,NavModelSerializer
class BannerView(ListAPIView):
queryset = models.Banner.objects.filter(is_deleted=False,is_show=True)[0:contains.BANNER_LENGTH]
serializer_class = BannerModelSerializer
# 获取顶部导航栏需要的数据
class NavView(ListAPIView):
queryset = models.Nav.objects.filter(is_deleted=False,is_show=True,position=1)[0:contains.HEADER_NAV_LENGTH]
serializer_class = NavModelSerializer
# 获取底部导航栏需要的数据
class BottomNavView(ListAPIView):
queryset = models.Nav.objects.filter(is_deleted=False,is_show=True,position=2)[0:contains.FOOTER_NAV_LENGTH]
serializer_class = NavModelSerializer
常量配置
settings/contains.py
# 首页展示的轮播广告数量
BANNER_LENGTH = 3
# 顶部导航的数量
HEADER_NAV_LENGTH = 5
# 脚部导航的数量
FOOTER_NAV_LENGTH = 7
9.4 路由
home/urls.py
from django.urls import path
from . import views
urlpatterns = [
path(r'banner/',views.BannerView.as_view()),
path(r'nav/',views.NavView.as_view()),
path(r'nav/bottom/', views.BottomNavView.as_view())
]
总路由
from django.contrib import admin
from django.urls import path,include,re_path
from django.conf import settings
from django.views.static import serve
import xadmin
xadmin.autodiscover()
# version模块自动注册需要版本控制的 Model
from xadmin.plugins import xversion
xversion.register_models()
urlpatterns = [
path(r'xadmin/', xadmin.site.urls),
re_path(r'media/(?P<path>.*)', serve, {"document_root": settings.MEDIA_ROOT}),
path(r'home/', include('home.urls')),
path(r'users/', include('users.urls')),
]
9.5 xadmin中注册导航栏模型
home/adminx.py
class NavXAdmin(object):
list_display = ['id','title','link','position','is_site']
xadmin.site.register(models.Nav,NavXAdmin)
9.6 前端获取后端数据
Header.vue
<template>
<div class="total-header">
<div class="header">
<el-container>
<el-header height="80px" class="header-cont">
<el-row>
<el-col class="logo" :span="3">
<a href="/">
<img src="@/assets/head-logo.svg" alt="">
</a>
</el-col>
<el-col class="nav" :span="10">
<el-row>
<!-- <el-col :span="3"> <router-link to="/course/" class="active">免费课</router-link> </el-col>-->
<!-- <el-col :span="3"> <router-link to="/">轻课</router-link> </el-col>-->
<!-- <el-col :span="3"> <router-link to="/">学位课</router-link> </el-col>-->
<!-- <el-col :span="3"> <router-link to="/">题库</router-link> </el-col>-->
<!-- <el-col :span="3"> <router-link to="/">教育</router-link> </el-col>-->
<el-col :span="3" v-for="(value,index) in nav_list" :key="value.id">
<router-link v-if="!value.is_site" :to="value.link" :class="{active:count===index}" @click="count=index">{{value.title}}</router-link>
<a v-else="" :href="value.link" :class="{active:count===index}">{{value.title}}</a>
</el-col>
</el-row>
</el-col>
<el-col :span="11" class="header-right-box">
<div class="search">
<input type="text" id="Input" placeholder="请输入想搜索的课程" style="" @blur="inputShowHandler" ref="Input" v-show="!s_status">
<ul @click="ulShowHandler" v-show="s_status" class="search-ul">
<span>Python</span>
<span>Linux</span>
</ul>
<p>
<img class="icon" src="@/assets/sousuo1.png" alt="" v-show="s_status">
<img class="icon" src="@/assets/sousuo2.png" alt="" v-show="!s_status">
<img class="new" src="@/assets/new.png" alt="">
</p>
</div>
<div class="register" v-show="!token">
<router-link to="/user/login"><button class="signin">登录</button></router-link>
|
<!-- <a target="_blank" href="">-->
<router-link to="/"><button class="signup">注册</button></router-link>
<!-- </a>-->
</div>
<div class="shop-car" v-show="token">
<router-link to="/">
<b>6</b>
<img src="@/assets/shopcart.png" alt="">
<span>购物车 </span>
</router-link>
</div>
<div class="nav-right-box" v-show="token">
<div class="nav-right">
<router-link to="/">
<div class="nav-study">我的教室</div>
</router-link>
<div class="nav-img" @mouseover="personInfoList" @mouseout="personInfoOut">
<img src="@/assets/touxiang.png" alt="" style="border: 1px solid rgb(243, 243, 243);">
<!-- hover -- mouseenter+mouseout-->
<ul class="home-my-account" v-show="list_status">
<li>
我的账户
<img src="https://hcdn1.luffycity.com/static/frontend/activity/back_1568185800.821227.svg" alt="">
</li>
<li>
我的订单
<img src="https://hcdn1.luffycity.com/static/frontend/activity/back_1568185800.821227.svg" alt="">
</li>
<li>
贝里小卖铺
<img src="https://hcdn1.luffycity.com/static/frontend/activity/back_1568185800.821227.svg" alt="">
</li>
<li>
我的优惠券
<img src="https://hcdn1.luffycity.com/static/frontend/activity/back_1568185800.821227.svg" alt="">
</li>
<li>
<span>
我的消息
<b>(26)</b>
</span>
<img src="https://hcdn1.luffycity.com/static/frontend/activity/back_1568185800.821227.svg" alt="">
</li>
<li @click="logout">
退出
<img src="https://hcdn1.luffycity.com/static/frontend/activity/back_1568185800.821227.svg" alt="">
</li>
</ul>
</div>
</div>
</div>
</el-col>
</el-row>
</el-header>
</el-container>
</div>
</div>
</template>
<script>
export default {
name: "Header",
data(){
return {
// 设置一个登录状态的标记,因为登录注册部分在登录之后会发生变化,false未登录转台
token:false,
s_status:true,
list_status:false, //用来控制个人中心下拉菜单的动态显示,false不显示
nav_list:[],
count:0,
}
},
methods:{
get_nav_data(){
this.$axios.get(`${this.$settings.Host}/home/nav`)
.then((res)=>{
console.log(res)
this.nav_list = res.data
})
.catch((error)=>{
})
},
ulShowHandler(){
this.s_status = false;
console.log(this.$refs.Input);
// this.$refs.Input.focus();
this.$nextTick(()=>{ //延迟回调方法,Vue中DOM更新是异步的,也就是说让Vue去显示我们的input标签的操作是异步的,如果我们直接执行this.$refs.Input.focus();是不行的,因为异步的去显示input标签的操作可能还没有完成,所有我们需要等它完成之后在进行DOM的操作,需要借助延迟回调对DOM进行操作,这是等这次操作对应的所有Vue中DOM的更新完成之后,在进行nextTick的操作。
this.$refs.Input.focus();
})
},
inputShowHandler(){
console.log('xxxxx')
this.s_status = true;
},
personInfoList(){
this.list_status = true;
},
personInfoOut(){
this.list_status = false;
},
check_login(){
this.token = localStorage.token || sessionStorage.token;
},
// 退出登录
logout(){
sessionStorage.removeItem('token');
sessionStorage.removeItem('username');
sessionStorage.removeItem('id');
localStorage.removeItem('token');
localStorage.removeItem('username');
localStorage.removeItem('id');
this.check_login();
},
},
created(){
this.get_nav_data();
this.check_login();
},
}
</script>
<style scoped>
.header-cont .nav .active{
color: #f5a623;
font-weight: 500;
border-bottom: 2px solid #f5a623;
}
.total-header{
min-width: 1200px;
z-index: 100;
box-shadow: 0 4px 8px 0 hsla(0,0%,59%,.1);
}
.header{
width: 1200px;
margin: 0 auto;
}
.header .el-header{
padding: 0;
}
.logo{
height: 80px;
/*line-height: 80px;*/
/*text-align: center;*/
display: flex; /* css3里面的弹性布局,高度设定好之后,设置这个属性就能让里面的内容居中 */
align-items: center;
}
.nav .el-row .el-col{
height: 80px;
line-height: 80px;
text-align: center;
}
.nav a{
font-size: 15px;
font-weight: 400;
cursor: pointer;
color: #4a4a4a;
text-decoration: none;
}
.nav .el-row .el-col a:hover{
border-bottom: 2px solid #f5a623
}
.header-cont{
position: relative;
}
.search input{
width: 185px;
height: 26px;
font-size: 14px;
color: #4a4a4a;
border: none;
border-bottom: 1px solid #ffc210;
outline: none;
}
.search ul{
width: 185px;
height: 26px;
display: flex;
align-items: center;
padding: 0;
padding-bottom: 3px;
border-bottom: 1px solid hsla(0,0%,59%,.25);
cursor: text;
margin: 0;
font-family: Helvetica Neue,Helvetica,Microsoft YaHei,Arial,sans-serif;
}
.search .search-ul,.search #Input{
padding-top:10px;
}
.search ul span {
color: #545c63;
font-size: 12px;
padding: 3px 12px;
background: #eeeeef;
cursor: pointer;
margin-right: 3px;
border-radius: 11px;
}
.hide{
display: none;
}
.search{
height: auto;
display: flex;
}
.search p{
position: relative;
margin-right: 20px;
margin-left: 4px;
}
.search p .icon{
width: 16px;
height: 16px;
cursor: pointer;
}
.search p .new{
width: 18px;
height: 10px;
position: absolute;
left: 15px;
top: 0;
}
.register{
height: 36px;
display: flex;
align-items: center;
line-height: 36px;
}
.register .signin,.register .signup{
font-size: 14px;
color: #5e5e5e;
white-space: nowrap;
}
.register button{
outline: none;
cursor: pointer;
border: none;
background: transparent;
}
.register a{
color: #000;
outline: none;
}
.header-right-box{
height: 100%;
display: flex;
align-items: center;
font-size: 15px;
color: #4a4a4a;
position: absolute;
right: 0;
top: 0;
}
.shop-car{
width: 99px;
height: 28px;
border-radius: 15px;
margin-right: 20px;
background: #f7f7f7;
display: flex;
align-items: center;
justify-content: center;
position: relative;
cursor: pointer;
}
.shop-car b{
position: absolute;
left: 28px;
top: -1px;
width: 18px;
height: 16px;
color: #fff;
font-size: 12px;
font-weight: 350;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
background: #ff0826;
overflow: hidden;
transform: scale(.8);
}
.shop-car img{
width: 20px;
height: 20px;
margin-right: 7px;
}
.nav-right-box{
position: relative;
}
.nav-right-box .nav-right{
float: right;
display: flex;
height: 100%;
line-height: 60px;
position: relative;
}
.nav-right .nav-study{
font-size: 15px;
font-weight: 300;
color: #5e5e5e;
margin-right: 20px;
cursor: pointer;
}
.nav-right .nav-study:hover{
color:#000;
}
.nav-img img{
width: 26px;
height: 26px;
border-radius: 50%;
display: inline-block;
cursor: pointer;
}
.home-my-account{
position: absolute;
right: 0;
top: 60px;
z-index: 101;
width: 190px;
height: auto;
background: #fff;
border-radius: 4px;
box-shadow: 0 4px 8px 0 #d0d0d0;
}
li{
list-style: none;
}
.home-my-account li{
height: 40px;
font-size: 14px;
font-weight: 300;
color: #5e5e5e;
padding-left: 20px;
padding-right: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
}
.home-my-account li img{
cursor: pointer;
width: 5px;
height: 10px;
}
.home-my-account li span{
height: 40px;
display: flex;
align-items: center;
}
.home-my-account li span b{
font-weight: 300;
margin-top: -2px;
}
</style>
10. 用户的登录认证
10.1 前端显示登陆页面
Login.vue
<template>
<div class="login box">
<img src="../../static/img/Loginbg.3377d0c.jpg" alt="">
<div class="login">
<div class="login-title">
<img src="../../static/img/Logotitle.1ba5466.png" alt="">
<p>帮助有志向的年轻人通过努力学习获得体面的工作和生活!</p>
</div>
<div class="login_box">
<div class="title">
<span @click="login_type=0">密码登录</span>
<span @click="login_type=1">短信登录</span>
</div>
<div class="inp" v-if="login_type==0">
<input v-model = "username" type="text" placeholder="用户名 / 手机号码" class="user">
<input v-model = "password" type="password" name="" class="pwd" placeholder="密码">
<div id="geetest1"></div>
<div class="rember">
<p>
<input type="checkbox" class="no" name="a" v-model="remember"/>
<span>记住密码</span>
</p>
<p>忘记密码</p>
</div>
<button class="login_btn" @click="loginHandle">登录</button>
<p class="go_login" >没有账号 <span>立即注册</span></p>
</div>
<div class="inp" v-show="login_type==1">
<input v-model = "username" type="text" placeholder="手机号码" class="user">
<input v-model = "password" type="text" class="pwd" placeholder="短信验证码">
<button id="get_code">获取验证码</button>
<button class="login_btn">登录</button>
<p class="go_login" >没有账号 <span>立即注册</span></p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Login',
data(){
return {
login_type: 0,
username:"",
password:"",
remember:'',
}
},
methods:{
},
};
</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;
top: 0;
left: 0;
margin: auto;
right: 0;
bottom: 0;
top: -338px;
}
.login .login-title{
width: 100%;
text-align: center;
}
.login-title img{
width: 190px;
height: auto;
}
.login-title p{
font-family: PingFangSC-Regular;
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;
}
.login_box .title{
font-size: 20px;
color: #9b9b9b;
letter-spacing: .32px;
border-bottom: 1px solid #e6e6e6;
display: flex;
justify-content: space-around;
padding: 50px 60px 0 60px;
margin-bottom: 20px;
cursor: pointer;
}
.login_box .title span:nth-of-type(1){
color: #4a4a4a;
border-bottom: 2px solid #84cc39;
}
.inp{
width: 350px;
margin: 0 auto;
}
.inp input{
border: 0;
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;
}
.inp .rember p span{
display: inline-block;
font-size: 12px;
width: 100px;
/*position: absolute;*/
/*left: 20px;*/
}
#geetest{
margin-top: 20px;
}
.login_btn{
width: 100%;
height: 45px;
background: #84cc39;
border-radius: 5px;
font-size: 16px;
color: #fff;
letter-spacing: .26px;
margin-top: 30px;
}
.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>
10.2 绑定登陆页面路由
main.js
import Vue from 'vue'
import Router from 'vue-router'
import Home from '@/components/Home'
import Login from '@/components/Login'
Vue.use(Router)
export default new Router({
mode : 'history' ,
routes: [
...
{
path: '/user/login',
name: '',
component: Login,
},
],
})
调整Home.vue中头部子组件Vheader.vue的登陆按钮的链接地址
Header.vue
<router-link to="/user/login">登录</router-link>
10.3 后端实现登陆认证
Django默认已经提供了认证系统Auth模块,我们认证的时候,会使用auth模块里面给我们提供的表,认证系统包含:
- 用户管理
- 权限
- 用户组
- 密码哈希系统
- 用户登录或内容显示的表单和视图
- 一个可插拔的后台系统admin
Django默认用户的认证机制依赖Session机制,我们在项目中将引入JWT认证机制,将用户的身份凭据存放在Token中,然后对接Django的认证系统实现:
- 用户的数据模型
- 用户密码的加密与验证
- 用户的权限系统
10.4 Django用户模型
Django认证系统中提供了用户模型类User保存用户的数据,默认的User包含以下常见的基本字段:
字段名 | 字段描述 |
---|---|
username |
必选。150个字符以内。 用户名可能包含字母数字,_ ,@ ,+ . 和- 个字符。 |
first_name |
可选(blank=True )。 少于等于30个字符。 |
last_name |
可选(blank=True )。 少于等于30个字符。 |
email |
可选(blank=True )。 邮箱地址。 |
password |
必选。 密码的哈希加密串。 (Django 不保存原始密码)。 原始密码可以无限长而且可以包含任意字符。 |
groups |
与Group 之间的多对多关系。 |
user_permissions |
与Permission 之间的多对多关系。 |
is_staff |
布尔值。 设置用户是否可以访问Admin 站点。 |
is_active |
布尔值。 指示用户的账号是否激活。 它不是用来控制用户是否能够登录,而是描述一种帐号的使用状态。 |
is_superuser |
是否是超级用户。超级用户具有所有权限。 |
last_login |
用户最后一次登录的时间。 |
date_joined |
账户创建的时间。 当账号创建时,默认设置为当前的date/time。 |
上面缺少一些字段,所以后面我们会对它进行改造,比如说它里面没有手机号字段,后面我们需要加上。
常用方法:
-
set_password
(raw_password)设置用户的密码为给定的原始字符串,不会保存User对象,当
None
为raw_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_staff
和is_superuser
为True
创建用户模块的子应用
python manage.py startapp users
在settings.py文件中注册子应用
INSTALLED_APPS = [
...
'users',
]
10.5 创建自定义的用户模型类
Django认证系统中提供的用户模型类及方法很方便,我们可以使用这个模型,但是字段有些无法满足项目需求,如本项目中需要保存用户的手机号,则需要给模型类添加额外的字段
Django提供了django.contrib.auth.models.AbstractUser
用户抽象模型类允许我们继承并扩展字段来使用Django认证系统的用户模型类
我们可以在apps中创建Django应用users,并在配置文件中注册users应用
在创建好的应用的models.py中定义用户的用户模型类
from django.db import models
from django.contrib.auth.models import AbstractUser
# Create your models here.
class User(AbstractUser):
phone = models.CharField(max_length=16,null=True,blank=True)
wechat = models.CharField(max_length=16,null=True,blank=True)
class Meta:
db_table = 'luffy_user'
verbose_name = '用户表'
verbose_name_plural = verbose_name
我们自定义的用户模型类还不能直接被Django的认证系统所识别,需要在配置文件中告知Django认证系统使用我们自定义的模型类
配置文件中设置settings/dev.py
#注册自定义用户模型,格式:“应用名.模型类名”
AUTH_USER_MODEL = 'users.User'
AUTH_USER_MODEL
参数的设置以.
来分隔,表示应用名.模型类名
注意: Django建议我们对于AUTH_USER_MODEL参数的设置一定要在第一次数据库迁移之前就设置好,否则后续使用可能出现位置错误
执行数据库迁移
python manage.py makemigrations
python manage.py migrate
如果在第一次数据迁移之后,才设置AUTH_USER_MODEL自定义用户模型,则会报错,解决方案如下
1. 先把现有的数据库导出备份 Dump with 'mysqldump' , 然后清掉数据库中所有的数据表
2. 把开发者穿件的所有子应用下面的migrations目录下除了__init__.py以外的所有迁移文件,只要涉及到用户的,一律删除,并将django-migrations表中的数据全部删除
3. 把django.contrib.admin.migrations目录下除了__init__.py以外的所有迁移文件,全部删除。
4. 把django.contrib.auth.migrations目录下除了__init__.py以外的所有迁移文件,全部删除。
5. 把reversion.migrations目录下除了__init__.py以外的所有迁移文件,全部删除。这个不在django目录里面,在site-packages里面,是xadmin安装的时候带的,它会记录用户信息,也需要删除
6. 把xadmin.migrations目录下除了__init__.py以外的所有迁移文件,全部删除。
7. 删除我们当前数据库中的所有表
8. 接下来,执行数据迁移(makemigrations和migrate),回顾第0步中的数据,将数据导入就可以了,以后如果要修改用户相关数据,不需要重复本次操作,直接数据迁移即可。
11. Django REST framework JWT
在用户注册或登录后,我们想记录用户的登录状态,或者为用户创建身份认证的凭证,我们不再使用session认证机制,而使用Json Web Token认证机制
很多公司开发的一些移动端可能不支持cookie,并且我们通过cookie和session做接口认证的话,效率其实并不是很高,我们的接口可能提供给给多个客户端,session数据保存在服务端,那么就需要每次调用session数据进行校验,比较耗时,所以引入了token认证
Json Web token(JWT),是为了在网络应用环境间传递声明执行的一种基于JSON的开放标准(RFC 7519),该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景,JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其他业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密
11.1 JWT的构成
JWT就是一段字符串,由三段信息构成,将这三段信息文本用.
连接在一起就构成了jwt字符串
如:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
header
jwt的头部承载两部分信息:
- 声明类型: jwt
- 声明加密的算法: 通常直接使用
HMAC SHA256
完整的头部就像这样的Json数据
{
'typ': 'JWT',
'alg': 'HS256'
}
然后将头部进行base64.b64encode()
加密(该加密是可以对称解密的),构成了第一部分
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
python3中base64加密解密
import base64
str1 = 'admin'
str2 = str1.encode()
b1 = base64.b64encode(str2) #数据越多,加密后的字符串越长
b2 = base64.b64decode(b1) #admin
各个语言中都有base64加密解密的功能,所以我们jwt为了安全,需要配合第三段加密
payload
载荷就是存放有效信息的地方
- 标准中注册的声明
- 公共的声明
- 私有的声明
标准中注册的声明 (建议但不强制使用) :
-
iss: jwt签发者
-
sub: jwt所面向的用户
-
aud: 接收jwt的一方
-
exp: jwt的过期时间,这个过期时间必须要大于签发时间
-
nbf: 定义在什么时间之前,该jwt都是不可用的.
-
iat: jwt的签发时间
-
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
以上是JWT 规定的7个官方字段,供选用
公共声明:公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息,但不建议添加敏感信息,因为该部分在客户端可解密
私有的声明:私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息
定义一个payload,json格式的数据
{
"sub": "1234567890",
"exp": "3422335555", #时间戳形式
"name": "John Doe",
"admin": true
}
然后将其进行base64.b64encode()
加密,得到JWT的第二部分
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
signature
JWT的第三部分是一个签证信息,由三部分组成
-
header(base64后的)
-
payload(base64后的)
-
secret 密钥
这个部分需要base64加密后的header和base64加密后的payload使用.
连接组成的字符串,然后通过header中声明的加密方式进行加盐secret
组合加密,然后就构成了jwt的第三部分
// javascript
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);
var signature = HMACSHA256(encodedString, 'secret'); //xxxx // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
将这三部分用.
连接成一个完整的字符串,构成了最终的JWT
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证的,所以,他就是你服务端的私钥,在任何场景都不应该流露出去,一旦客户端得知这个secret,就意味着客户端可以自我签发jwt了
jwt的优点:
1. 实现分布式的单点登录非常方便
2. 数据实际保存在客户端,可以分担服务器的存储压力
3. JWT不仅可用于认证,还可用于信息交换,善用JWT有助于减少服务器请求数据库的次数,jwt的构成非常简单,字节占用很小,所以它非常便于传输
jwt的缺点:
1. 数据保存在客户端,服务端只认jwt,不识别客户端
2. jwt可以设置过期时间,但是因为数据保存在了客户端,所以对于过期时间不好调整。 secret_key轻易不要改,一改所有客户端都要重新登录
11.2 安装配置JWT
安装
pip install djangorestframework-jwt -i https://mirrors.aliyun.com/pypi/simple/
配置(github网址:https://github.com/jpadilla/django-rest-framework-jwt)
在settings/dev.py中
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.BasicAuthentication',
),
}
import datetime
JWT_AUTH = {
'JWT_EXPIRATION_DELTA': datetime.timedelta(days=1),
}
JWT_EXPIRATION_DELTA
指明token的有效期
我们django创建项目的时候,在settings配置文件中直接就生成了一个serect+key,我们可以直接使用它作为我们jwt的serect_key,其实django rest framework-jwt 默认配置中就使用它。
手动生成jwt(我们暂时用不到)
Django REST framework JWT 扩展的说明文档中提供了手动签发JWT的方法
from rest_framework_jwt.settings import api_settings
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
payload = jwt_payload_handler(user)
token = jwt_encode_handler(payload)
在用户注册或登录成功以后,在序列化器中返回用户信息同时返回token即可
11.3 后端实现登录认证接口
Django REST framework JWT提供了登录获取token的视图,可以直接使用
在子应用路由urls.py中
from rest_framework_jwt.views import obtain_jwt_token
urlpatterns = [
path(r'login/', obtain_jwt_token),
]
在主路由中,引入当前子应用的路由文件
urlpatterns = [
...
path('user/', include("users.urls")),
# include 的值必须是 模块名.urls 格式,字符串中间只能出现一个圆点
]
接下来,我们可以通过postman来测试下功能,但是jwt是通过username和password来进行登录认证处理的,所以我们要给真实数据,jwt会去我们配置的user表中去查询用户数据的,验证通过会返回一个token值。
11.4 前端实现登录功能
在登陆组件中找到登陆按钮,绑定点击事件
<button class="login_btn" @click="loginhander">登录</button>
在methods中请求后端
<script>
export default {
name: 'Login',
data(){
return {
login_type: 0,
username:"",
password:"",
remember:'',
}
},
methods:{
loginHandle(){
this.$axios.post(`${this.$settings.Host}/users/login/`,{
username : this.username,
password : this.password
}).then((res)=>{
console.log(res)
}).catch((error)=>{
console.log(error)
});
})
}
},
};
</script>
11.4.1 前端保存jwt
jwt可以保存在cookie中,也可以保存在浏览器的本地存储里,我们一般保存在浏览器本地存储里
浏览器的本地存储提供了sessionStorage和localStorage两种,从属于window对象:
- sessionStorage浏览器关闭即失效
- localStorage长期有效
使用方法:
sessionStorage.变量名 = 变量值 // 保存数据
sessionStorage.setItem("变量名","变量值") // 保存数据
sessionStorage.变量名 // 读取数据
sessionStorage.getItem("变量名") // 读取数据
sessionStorage.removeItem("变量名") // 清除单个数据
sessionStorage.clear() // 清除所有sessionStorage保存的数据
localStorage.变量名 = 变量值 // 保存数据
localStorage.setItem("变量名","变量值") // 保存数据
localStorage.变量名 // 读取数据
localStorage.getItem("变量名") // 读取数据
localStorage.removeItem("变量名") // 清除单个数据
localStorage.clear() // 清除所有sessionStorage保存的数据
登录组件代码Login.vue
methods:{
loginHandle(){
this.$axios.post(`${this.$settings.Host}/users/login/`,{
username : this.username,
password : this.password
}).then((res)=>{
console.log(res)
console.log(this.remember)
if (this.remember){
localStorage.token = res.data.token;
localStorage.username = res.data.username;
localStorage.id = res.data.id;
sessionStorage.removeItem('token');
sessionStorage.removeItem('username');
sessionStorage.removeItem('id');
}else{
sessionStorage.token = res.data.token;
sessionStorage.username = res.data.username;
sessionStorage.id = res.data.id;
localStorage.removeItem('token');
localStorage.removeItem('username');
localStorage.removeItem('id');
}
this.$router.push('/');
默认的返回值仅有token,我们还需在返回值中添加username和id,方便在客户端页面中显示当前登录用户
通过修改该视图的返回值可以完成
在user/utils.py中
def jwt_response_payload_handler(token, user=None, request=None):
"""
自定义jwt认证成功返回数据
"""
return {
'token': token,
'id': user.id,
'username': user.username
}
修改settings.py
# JWT
JWT_AUTH = {
'JWT_EXPIRATION_DELTA': datetime.timedelta(days=1),
'JWT_RESPONSE_PAYLOAD_HANDLER': 'users.utils.jwt_response_payload_handler',
}
11.4.2 多条件登录
jwt扩展的登录视图,在收到用户名与密码时,也是调用Django的认证系统中提供的authenticate
来检查用户名与密码是否正确的
我们可以通过修改Django认证系统的认证后端(主要是authenticate方法)来支持登录账号既可以是用户名也可以是手机号
官方说:修改Django认证系统的认证后端需要继承django.contrib.auth.backends.ModelBackend,并重写authenticate方法
authenticate(self, request, username=None, password=None, **kwargs)
方法的参数说明:
- request 本次认证的请求对象
- username 本次认证提供的用户账号
- password 本次认证提供的密码
我们想要让用户既可以以用户名登录,也可以以手机号登录,那么对于authenticate方法而言,username参数即表示用户名或手机号。
重写authenticate方法的思路:
- 根据username参数查找用户User对象,username参数可能是用户名,也可能是手机号
- 若查到user对象,调用user对象的check_password方法检查密码是否正确
在users应用下创建一个utils.py文件
from users import models
from django.db.models import Q
def get_user_obj(accout):
try:
user_obj = models.User.objects.get(Q(username=accout)|Q(phone=accout))
except:
return None
return user_obj
from django.contrib.auth.backends import ModelBackend
class CustomeModelBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
user_obj = get_user_obj(username)
if user_obj:
if user_obj.check_password(password):
return user_obj
else:
return None
在settings/dev.py中告知Django使用我们自定义的认证后端
AUTHENTICATION_BACKENDS = [
'users.utils.CustomeModelBackend',
]
以上就实现了我们通过用户名或者手机号的一个多条件登录
跳转到首页之后,我们需要显示的不再是登录注册按钮,而是显示购物车,个人中心等内容
11.5 前端首页实现登录状态的判断
common/Vheader.vue
<script>
export default {
name: "Header",
data(){
return {
// 设置一个登录状态的标记,因为登录注册部分在登录之后会发生变化,false未登录转台
token:false,
s_status:true, //用来标记搜索框是否显示成input框
list_status:false, //用来控制个人中心下拉菜单的动态显示,false不显示
nav_list:[],
count:0, //标记导航栏中哪一个有class类值为active的值
}
},
methods:{
get_nav_data(){
this.$axios.get(`${this.$settings.Host}/home/nav`)
.then((res)=>{
console.log(res)
this.nav_list = res.data
})
.catch((error)=>{
})
},
ulShowHandler(){
this.s_status = false;
console.log(this.$refs.Input);
// this.$refs.Input.focus();
this.$nextTick(()=>{ //延迟回调方法,Vue中DOM更新是异步的,也就是说让Vue去显示我们的input标签的操作是异步的,如果我们直接执行this.$refs.Input.focus();是不行的,因为异步的去显示input标签的操作可能还没有完成,所有我们需要等它完成之后在进行DOM的操作,需要借助延迟回调对DOM进行操作,这是等这次操作对应的所有Vue中DOM的更新完成之后,在进行nextTick的操作。
this.$refs.Input.focus();
})
},
inputShowHandler(){
console.log('xxxxx')
this.s_status = true;
},
personInfoList(){
this.list_status = true;
},
personInfoOut(){
this.list_status = false;
},
check_login(){
this.token = localStorage.token || sessionStorage.token;
},
// 退出登录
logout(){
sessionStorage.removeItem('token');
sessionStorage.removeItem('username');
sessionStorage.removeItem('id');
localStorage.removeItem('token');
localStorage.removeItem('username');
localStorage.removeItem('id');
this.check_login();
},
},
created(){
this.get_nav_data();
this.check_login();
},
}
</script>
11.4.2 多条件登录
jwt扩展的登录视图,在收到用户名与密码时,也是调用Django的认证系统中提供的authenticate
来检查用户名与密码是否正确的
我们可以通过修改Django认证系统的认证后端(主要是authenticate方法)来支持登录账号既可以是用户名也可以是手机号
官方说:修改Django认证系统的认证后端需要继承django.contrib.auth.backends.ModelBackend,并重写authenticate方法
authenticate(self, request, username=None, password=None, **kwargs)
方法的参数说明:
- request 本次认证的请求对象
- username 本次认证提供的用户账号
- password 本次认证提供的密码
我们想要让用户既可以以用户名登录,也可以以手机号登录,那么对于authenticate方法而言,username参数即表示用户名或手机号。
重写authenticate方法的思路:
- 根据username参数查找用户User对象,username参数可能是用户名,也可能是手机号
- 若查到user对象,调用user对象的check_password方法检查密码是否正确
在users应用下创建一个utils.py文件
from users import models
from django.db.models import Q
def get_user_obj(accout):
try:
user_obj = models.User.objects.get(Q(username=accout)|Q(phone=accout))
except:
return None
return user_obj
from django.contrib.auth.backends import ModelBackend
class CustomeModelBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
user_obj = get_user_obj(username)
if user_obj:
if user_obj.check_password(password):
return user_obj
else:
return None
在settings/dev.py中告知Django使用我们自定义的认证后端
AUTHENTICATION_BACKENDS = [
'users.utils.CustomeModelBackend',
]
以上就实现了我们通过用户名或者手机号的一个多条件登录
跳转到首页之后,我们需要显示的不再是登录注册按钮,而是显示购物车,个人中心等内容
11.5 前端首页实现登录状态的判断
common/Vheader.vue
<script>
export default {
name: "Header",
data(){
return {
// 设置一个登录状态的标记,因为登录注册部分在登录之后会发生变化,false未登录转台
token:false,
s_status:true, //用来标记搜索框是否显示成input框
list_status:false, //用来控制个人中心下拉菜单的动态显示,false不显示
nav_list:[],
count:0, //标记导航栏中哪一个有class类值为active的值
}
},
methods:{
get_nav_data(){
this.$axios.get(`${this.$settings.Host}/home/nav`)
.then((res)=>{
console.log(res)
this.nav_list = res.data
})
.catch((error)=>{
})
},
ulShowHandler(){
this.s_status = false;
console.log(this.$refs.Input);
// this.$refs.Input.focus();
this.$nextTick(()=>{ //延迟回调方法,Vue中DOM更新是异步的,也就是说让Vue去显示我们的input标签的操作是异步的,如果我们直接执行this.$refs.Input.focus();是不行的,因为异步的去显示input标签的操作可能还没有完成,所有我们需要等它完成之后在进行DOM的操作,需要借助延迟回调对DOM进行操作,这是等这次操作对应的所有Vue中DOM的更新完成之后,在进行nextTick的操作。
this.$refs.Input.focus();
})
},
inputShowHandler(){
console.log('xxxxx')
this.s_status = true;
},
personInfoList(){
this.list_status = true;
},
personInfoOut(){
this.list_status = false;
},
check_login(){
this.token = localStorage.token || sessionStorage.token;
},
// 退出登录
logout(){
sessionStorage.removeItem('token');
sessionStorage.removeItem('username');
sessionStorage.removeItem('id');
localStorage.removeItem('token');
localStorage.removeItem('username');
localStorage.removeItem('id');
this.check_login();
},
},
created(){
this.get_nav_data();
this.check_login();
},
}
</script>
12. 腾讯防水墙
快速接入:https://007.qq.com/python-access.html?ADTAG=acces.start
-
访问云API秘钥
-
新建验证应用[ 新用户可以领取一个免费的验证码套餐 ]
把密钥和ID保存到settings/dev.py配置文件中
# 腾讯防水墙配置
FSQ = {
"URL": "https://ssl.captcha.qq.com/ticket/verify",
"APPID": "2049354688", # 此处的APPID一定要和客户端的APPID保持一致,否则后面票据会失败!
"App_Secret_Key": "0Sygom4-_8V1WSZap5Cr28Q**",
}
12.1 前端获取显示并校验验证码
把防水墙的前端核心js文件在客户端跟目录下index.html中使用script引入或者在src/main.js中通过import引入
下载地址:https://ssl.captcha.qq.com/TCaptcha.js
Login.vue
<template>
<div class="login box">
<img src="../../static/img/Loginbg.3377d0c.jpg" alt="">
<div class="login">
<div class="login-title">
<img src="../../static/img/Logotitle.1ba5466.png" alt="">
<p>帮助有志向的年轻人通过努力学习获得体面的工作和生活!</p>
</div>
<div class="login_box">
<div class="title">
<span @click="login_type=0">密码登录</span>
<span @click="login_type=1">短信登录</span>
</div>
<div class="inp" v-if="login_type==0">
<input v-model = "username" type="text" placeholder="用户名 / 手机号码" class="user">
<input v-model = "password" type="password" name="" class="pwd" placeholder="密码">
<div id="geetest1"></div>
<div class="rember">
<p>
<input type="checkbox" class="no" name="a" v-model="remember"/>
<span>记住密码</span>
</p>
<p>忘记密码</p>
</div>
<button class="login_btn" @click="loginHandle">登录</button>
<p class="go_login" >没有账号 <router-link to="/user/register">立即注册</router-link></p>
</div>
<div class="inp" v-show="login_type==1">
<input v-model = "username" type="text" placeholder="手机号码" class="user">
<input v-model = "password" type="text" class="pwd" placeholder="短信验证码">
<button id="get_code">获取验证码</button>
<button class="login_btn">登录</button>
<p class="go_login" >没有账号 <span>立即注册</span></p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Login',
data(){
return {
login_type: 0,
username:"",
password:"",
remember:'',
}
},
methods:{
loginHandle(){
var captcha1 = new TencentCaptcha('2049354688', (res)=>{
if (res.ret === 0 ){
this.$axios.post(`${this.$settings.Host}/users/login/`,{
username : this.username,
password : this.password,
ticket:res.ticket,
randstr:res.randstr,
}).then((res)=>{
console.log(res);
console.log(this.remember);
if (this.remember){
localStorage.token = res.data.token;
localStorage.username = res.data.username;
localStorage.id = res.data.id;
sessionStorage.removeItem('token');
sessionStorage.removeItem('username');
sessionStorage.removeItem('id');
}else{
sessionStorage.token = res.data.token;
sessionStorage.username = res.data.username;
sessionStorage.id = res.data.id;
localStorage.removeItem('token');
localStorage.removeItem('username');
localStorage.removeItem('id');
}
this.$confirm('下一步想去哪消费!', '提示', {
confirmButtonText: '去首页',
cancelButtonText: '去个人中心',
type: 'success'
}).then(() => {
this.$router.push('/');
}).catch(() => {
this.$router.push('/person');
});
}).catch((error)=>{
this.$alert('用户名或密码错误', 'error msg', {
confirmButtonText: '确定',
});
})
}
});
captcha1.show();
}
},
};
</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;
top: 0;
left: 0;
margin: auto;
right: 0;
bottom: 0;
top: -338px;
}
.login .login-title{
width: 100%;
text-align: center;
}
.login-title img{
width: 190px;
height: auto;
}
.login-title p{
font-family: PingFangSC-Regular;
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;
}
.login_box .title{
font-size: 20px;
color: #9b9b9b;
letter-spacing: .32px;
border-bottom: 1px solid #e6e6e6;
display: flex;
justify-content: space-around;
padding: 50px 60px 0 60px;
margin-bottom: 20px;
cursor: pointer;
}
.login_box .title span:nth-of-type(1){
color: #4a4a4a;
border-bottom: 2px solid #84cc39;
}
.inp{
width: 350px;
margin: 0 auto;
}
.inp input{
border: 0;
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;
}
.inp .rember p span{
display: inline-block;
font-size: 12px;
width: 100px;
/*position: absolute;*/
/*left: 20px;*/
}
#geetest{
margin-top: 20px;
}
.login_btn{
width: 100%;
height: 45px;
background: #84cc39;
border-radius: 5px;
font-size: 16px;
color: #fff;
letter-spacing: .26px;
margin-top: 30px;
}
.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>
服务端对验证返回的票据进行验证才允许登录
uses/urls.py
from rest_framework_jwt.views import obtain_jwt_token,verify_jwt_token
from django.urls import path
from . import views
urlpatterns = [
path(r'login/', views.CustomLoginView.as_view()),
path('verify/', verify_jwt_token),
]
视图中针对原来的jwt登录进行改造
import re
import random
from django.shortcuts import render
# Create your views here.
from rest_framework_jwt.views import ObtainJSONWebToken
from rest_framework.views import APIView
from rest_framework.generics import CreateAPIView
from rest_framework.response import Response
from rest_framework import status
from .serializers import CustomeSerializer,RegisterModelSerializer
from .utils import get_user_obj
from . import models
from luffyapi.settings import contains
class CustomLoginView(ObtainJSONWebToken):
serializer_class = CustomeSerializer
在原来jwt的序列化器基础上增加验证相关的参数
import re
from django.contrib.auth.hashers import make_password
from rest_framework_jwt.serializers import JSONWebTokenSerializer
from rest_framework import serializers
from rest_framework_jwt.compat import get_username_field, PasswordField
from django.utils.translation import ugettext as _
from django.contrib.auth import authenticate, get_user_model
from rest_framework_jwt.settings import api_settings
from . import models
from .utils import get_user_obj
User = get_user_model()
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
jwt_decode_handler = api_settings.JWT_DECODE_HANDLER
jwt_get_username_from_payload = api_settings.JWT_PAYLOAD_GET_USERNAME_HANDLER
class CustomeSerializer(JSONWebTokenSerializer):
def __init__(self, *args, **kwargs):
"""
Dynamically add the USERNAME_FIELD to self.fields.
"""
super(JSONWebTokenSerializer, self).__init__(*args, **kwargs)
self.fields[self.username_field] = serializers.CharField()
self.fields['password'] = PasswordField(write_only=True)
self.fields['ticket'] = serializers.CharField(write_only=True)
self.fields['randstr'] = serializers.CharField(write_only=True)
#
def validate(self, attrs):
credentials = {
self.username_field: attrs.get(self.username_field),
'password': attrs.get('password'),
'ticket': attrs.get('ticket'),
'randstr': attrs.get('randstr'),
}
#{'username':'root',password:'123'}
if all(credentials.values()):
user = authenticate(self.context['request'],**credentials) #self.context['request']当前请求的request对象
if user:
if not user.is_active:
msg = _('User account is disabled.')
raise serializers.ValidationError(msg)
payload = jwt_payload_handler(user)
return {
'token': jwt_encode_handler(payload),
'user': user
}
else:
msg = _('Unable to log in with provided credentials.')
raise serializers.ValidationError(msg)
else:
msg = _('Must include "{username_field}" and "password".')
msg = msg.format(username_field=self.username_field)
raise serializers.ValidationError(msg)
在自定义认证类的authenticate方法中,增加对验证码的判断逻辑
users/utils.py
from urllib.parse import urlencode
import json, urllib
from urllib.request import urlopen
import requests
from django.conf import settings
def jwt_response_payload_handler(token, user=None, request=None):
# print('>>>>>',user,type(user))
return {
'token': token,
'username': user.username,
'id':user.id
}
from . import models
from django.db.models import Q
def get_user_obj(accout): #666
try:
user_obj = models.User.objects.get(Q(username=accout)|Q(phone=accout))
except:
return None
return user_obj
from django.contrib.auth.backends import ModelBackend
import logging
logger = logging.getLogger('django')
class CustomeModelBackend(ModelBackend):
'''
'
'ticket': attrs.get('ticket'),
'randstr': attrs.get('randstr'),
'''
def authenticate(self, request, username=None, password=None, **kwargs):
try:
user_obj = get_user_obj(username)
if kwargs.get('ticket'):
user_obj = get_user_obj(username)
ticket = kwargs.get('ticket')
userip = request.META['REMOTE_ADDR']
randstr = kwargs.get('randstr')
print('userip:', userip)
'''
https://captcha.tencentcloudapi.com/?Action=DescribeCaptchaResult
&CaptchaType=9
&Ticket=xxxx
&UserIp=127.0.0.1
&Randstr=xxx
&CaptchaAppId=201111111
&AppSecretKey=xxxxxx
'''
# ----------------------------------
# 腾讯验证码后台接入demo
# ----------------------------------
# ----------------------------------
# 请求接口返回内容
# @param string appkey [验证密钥]
# @param string params [请求的参数]
# @return string
# ----------------------------------
params = {
"aid": settings.FSQ.get('APPID'),
"AppSecretKey": settings.FSQ.get('App_Secret_Key'),
"Ticket": ticket,
"Randstr": randstr,
"UserIP": userip
}
params = urlencode(params).encode()
url = settings.FSQ.get('URL')
f = urlopen(url, params)
content = f.read()
res = json.loads(content)
print(res) # {'response': '1', 'evil_level': '0', 'err_msg': 'OK'}
if res.get('response') != '1':
return None
if user_obj:
if user_obj.check_password(password):
return user_obj
else:
return None
except Exception:
logger.error('验证过程代码有误,请联系管理员')
return None
13. 用户的注册认证
前端显示注册页面并调整首页头部和登录页面的注册按钮的链接
Register.vue
<template>
<div class="box">
<img src="../../static/img/Loginbg.3377d0c.jpg" alt="">
<div class="register">
<div class="login-title">
<img src="../../static/img/Logotitle.1ba5466.png" alt="">
<p>帮助有志向的年轻人通过努力学习获得体面的工作和生活!</p>
</div>
<div class="register_box">
<div class="register-title">注册路飞学城</div>
<div class="inp">
<input v-model = "mobile" type="text" placeholder="手机号码" class="user" @blur="checkPhone">
<input v-model = "password" type="text" placeholder="密码" class="user">
<input v-model = "r_password" type="text" placeholder="确认密码" class="user">
<div>
<input v-model = "sms" type="text" placeholder="输入验证码" class="user" style="width: 62%">
<button style="width: 34%;height: 41px;">点击获取验证码</button>
</div>
<button class="register_btn" @click="registerHandler">注册</button>
<p class="go_login" >已有账号 <router-link to="/user/login">直接登录</router-link></p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Register',
data(){
return {
sms:"",
mobile:"",
password:"",
r_password:"",
validateResult:false,
}
},
created(){
},
methods:{
},
};
</script>
<style scoped>
.box{
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.box img{
width: 100%;
min-height: 100%;
}
.box .register {
position: absolute;
width: 500px;
height: 400px;
top: 0;
left: 0;
margin: auto;
right: 0;
bottom: 0;
top: -338px;
}
.register .register-title{
width: 100%;
font-size: 24px;
text-align: center;
padding-top: 30px;
padding-bottom: 30px;
color: #4a4a4a;
letter-spacing: .39px;
}
.register-title img{
width: 190px;
height: auto;
}
.register-title p{
font-family: PingFangSC-Regular;
font-size: 18px;
color: #fff;
letter-spacing: .29px;
padding-top: 10px;
padding-bottom: 50px;
}
.login-title{
margin-top:100px;
width: 100%;
text-align: center;
}
.login-title img{
width: 190px;
height: auto;
}
.login-title p{
font-family: PingFangSC-Regular;
font-size: 18px;
color: #fff;
letter-spacing: .29px;
padding-top: 10px;
padding-bottom: 50px;
}
.register_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;
}
.register_box .title{
font-size: 20px;
color: #9b9b9b;
letter-spacing: .32px;
border-bottom: 1px solid #e6e6e6;
display: flex;
justify-content: space-around;
padding: 50px 60px 0 60px;
margin-bottom: 20px;
cursor: pointer;
}
.register_box .title span:nth-of-type(1){
color: #4a4a4a;
border-bottom: 2px solid #84cc39;
}
.inp{
width: 350px;
margin: 0 auto;
}
.inp input{
border: 0;
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;
}
.inp .rember p span{
display: inline-block;
font-size: 12px;
width: 100px;
/*position: absolute;*/
/*left: 20px;*/
}
#geetest{
margin-top: 20px;
}
.register_btn{
width: 100%;
height: 45px;
background: #84cc39;
border-radius: 5px;
font-size: 16px;
color: #fff;
letter-spacing: .26px;
margin-top: 30px;
}
.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>
前端注册路由
import Vue from 'vue'
import Router from 'vue-router'
import Home from '@/components/Home'
import Login from '@/components/Login'
import Register from '@/components/Register'
Vue.use(Router)
export default new Router({
mode : 'history' ,
routes: [
{
path: '/',
name: '',
component: Home,
},
{
path: '/home',
name: '',
component: Home,
},
{
path: '/user/login',
name: '',
component: Login,
},
{
path: '/user/register',
name: '',
component: Register,
},
],
})
13.1注册功能的实现
Redis数据库
把注册信息如短信验证码和session缓存到redis数据库中
安装django-redis
pip3 install django-redis
settings.py
# 设置redis缓存
CACHES = {
# 默认缓存
"default": {
"BACKEND": "django_redis.cache.RedisCache",
# 项目上线时,需要调整这里的路径
"LOCATION": "redis://127.0.0.1:6379/0",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
}
},
# 提供给xadmin或者admin的session存储
"session": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
}
},
# 提供存储短信验证码
"sms_code":{
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/2",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
}
}
}
# 设置xadmin用户登录时,登录信息session保存到redis
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "session"
关于django-redis 的使用,说明文档可见http://django-redis-chs.readthedocs.io/zh_CN/latest/
django-redis提供了get_redis_connection的方法,通过调用get_redis_connection方法传递redis的配置名称可获取到redis的连接对象,通过redis连接对象可以执行redis命令
https://redis-py.readthedocs.io/en/latest/
使用范例:
from django_redis import get_redis_connection
// 链接redis数据库
redis_conn = get_redis_connection("default")
前端手机号校验和部分注册功能校验和短信验证码校验
Register.vue
<template>
<div class="box">
<img src="../../static/img/Loginbg.3377d0c.jpg" alt="">
<div class="register">
<div class="login-title">
<img src="../../static/img/Logotitle.1ba5466.png" alt="">
<p>帮助有志向的年轻人通过努力学习获得体面的工作和生活!</p>
</div>
<div class="register_box">
<div class="register-title">注册路飞学城</div>
<div class="inp">
<input v-model = "mobile" type="text" placeholder="手机号码" class="user" @blur="checkPhone">
<input v-model = "password" type="text" placeholder="密码" class="user">
<input v-model = "r_password" type="text" placeholder="确认密码" class="user">
<div>
<input v-model = "sms" type="text" placeholder="输入验证码" class="user" style="width: 62%">
<button style="width: 34%;height: 41px;" @click="getSmsCode" :disabled="this.flag">{{btn_msg}}</button>
</div>
<button class="register_btn" @click="registerHandler">注册</button>
<p class="go_login" >已有账号 <router-link to="/user/login">直接登录</router-link></p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Register',
data(){
return {
sms:"",
mobile:"",
password:"",
r_password:"",
validateResult:false,
interval_time:60, //倒计时时间
btn_msg: '点击获取验证码',
flag:false, //判断定时器是否开启
}
},
created(){
},
methods:{
checkPhone(){
let phoneNumber = this.mobile;
// 前端验证
let reg = /^1[3-9][0-9]{9}$/;
if (!reg.test(phoneNumber)){
this.$message.error('手机号格式有误');
return false
}
// 往后端发送请求
this.$axios.get(`${this.$settings.Host}/users/check_phone/?phone=${phoneNumber}`) // request.GET.get('phone')
.then((res)=>{
console.log(res)
})
.catch((error)=>{
this.$message.error(error.response.data.error_msg)
})
},
registerHandler(){
this.$axios.post(`${this.$settings.Host}/users/register/`,{
sms:this.sms,
phone:this.mobile,
password:this.password,
r_password:this.r_password,
}).then((res)=>{
sessionStorage.token = res.data.token;
sessionStorage.username = res.data.username;
sessionStorage.id = res.data.id;
this.$router.push('/');
}).catch((error)=>{
console.log(error.response)
})
},
// 点击获取验证码
getSmsCode(){
this.$axios.get(`${this.$settings.Host}/users/sms_code/${this.mobile}/`)
.then((res)=>{
if (!this.flag){
this.flag = setInterval(()=>{
if(this.interval_time > 0){
this.interval_time--;
this.btn_msg = `${this.interval_time}秒后重新获取`;
}else{
this.interval_time=60;
this.btn_msg = "点击发送验证码";
clearInterval(this.flag);
this.flag = false;
}
},1000)
}
})
.catch((error)=>{
this.$message.error(error.response.data.msg);
})
},
},
};
</script>
<style scoped>
.box{
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.box img{
width: 100%;
min-height: 100%;
}
.box .register {
position: absolute;
width: 500px;
height: 400px;
top: 0;
left: 0;
margin: auto;
right: 0;
bottom: 0;
top: -338px;
}
.register .register-title{
width: 100%;
font-size: 24px;
text-align: center;
padding-top: 30px;
padding-bottom: 30px;
color: #4a4a4a;
letter-spacing: .39px;
}
.register-title img{
width: 190px;
height: auto;
}
.register-title p{
font-family: PingFangSC-Regular;
font-size: 18px;
color: #fff;
letter-spacing: .29px;
padding-top: 10px;
padding-bottom: 50px;
}
.login-title{
margin-top:100px;
width: 100%;
text-align: center;
}
.login-title img{
width: 190px;
height: auto;
}
.login-title p{
font-family: PingFangSC-Regular;
font-size: 18px;
color: #fff;
letter-spacing: .29px;
padding-top: 10px;
padding-bottom: 50px;
}
.register_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;
}
.register_box .title{
font-size: 20px;
color: #9b9b9b;
letter-spacing: .32px;
border-bottom: 1px solid #e6e6e6;
display: flex;
justify-content: space-around;
padding: 50px 60px 0 60px;
margin-bottom: 20px;
cursor: pointer;
}
.register_box .title span:nth-of-type(1){
color: #4a4a4a;
border-bottom: 2px solid #84cc39;
}
.inp{
width: 350px;
margin: 0 auto;
}
.inp input{
border: 0;
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;
}
.inp .rember p span{
display: inline-block;
font-size: 12px;
width: 100px;
/*position: absolute;*/
/*left: 20px;*/
}
#geetest{
margin-top: 20px;
}
.register_btn{
width: 100%;
height: 45px;
background: #84cc39;
border-radius: 5px;
font-size: 16px;
color: #fff;
letter-spacing: .26px;
margin-top: 30px;
}
.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>
后端
后端路由users/urls.py
from rest_framework_jwt.views import obtain_jwt_token,verify_jwt_token
from django.urls import path,re_path
from . import views
urlpatterns = [
path(r'login/', views.CustomLoginView.as_view()),
path('verify/', verify_jwt_token),
path('check_phone/', views.CheckPhoneNumber.as_view()),
path('register/', views.RegisterView.as_view()),
re_path(r'sms_code/(?P<phone>1[3-9][0-9]{9})/', views.GetSMSCodeView.as_view()),
]
users/views.py
import re
import random
from celery.result import AsyncResult
from django.shortcuts import render
# Create your views here.
from rest_framework_jwt.views import ObtainJSONWebToken
from rest_framework.views import APIView
from rest_framework.generics import CreateAPIView
from rest_framework.response import Response
from rest_framework import status
from .serializers import CustomeSerializer,RegisterModelSerializer
from .utils import get_user_obj
from . import models
from luffyapi.settings import contains
from luffyapi.libs.ronglian_sms_sdk.sms import send_message
from django.conf import settings
class CustomLoginView(ObtainJSONWebToken):
serializer_class = CustomeSerializer
class CheckPhoneNumber(APIView):
def get(self,request):
phone_number = request.GET.get('phone')
if not re.match('^1[3-9][0-9]{9}$',phone_number):
return Response({'error_msg':'手机号格式有误,请重新输入'},status=status.HTTP_400_BAD_REQUEST)
ret = get_user_obj(phone_number)
if ret:
return Response({'error_msg':'手机号已被注册,请换个手机号'}, status=status.HTTP_400_BAD_REQUEST)
return Response({'msg':'ok'})
class RegisterView(CreateAPIView):
queryset = models.User.objects.all()
serializer_class = RegisterModelSerializer
import logging
logger = logging.getLogger('django')
from django_redis import get_redis_connection
class GetSMSCodeView(APIView):
def get(self,request,phone):
# 验证是否已经发送过短信了
conn = get_redis_connection('sms_code')
ret = conn.get('mobile_interval_%s'%phone)
if ret:
return Response({'msg':'60秒内已经发送过了,别瞎搞'}, status=status.HTTP_400_BAD_REQUEST)
# 生成验证码
sms_code = "%06d" % random.randint(0,999999)
# 保存验证码
conn.setex('mobile_%s'%phone,contains.SMS_CODE_EXPIRE_TIME,sms_code)
conn.setex('mobile_interval_%s'%phone,contains.SMS_CODE_INTERVAL_TIME,sms_code)
# 发送验证码
# ret = send_message(settings.SMS_INFO.get('TID'),phone,(sms_code,contains.SMS_CODE_INTERVAL_TIME))
#
# if not ret:
# logger.error('{}手机号发送失败'.format(phone))
# return Response({'msg':'短信发送失败,请联系管理员'},status=status.HTTP_500_INTERNAL_SERVER_ERROR)
from mycelery.sms.tasks import sms_codes
sms_codes.delay(phone, sms_code)
# ret = sms_codes.delay(phone,sms_code)
# async_task = AsyncResult(id=ret.id, app=sms_codes)
# result = async_task.get()
# print('xxxxxxx>>>>',result)
return Response({'msg':'ok'})
users/serializers.py
import re
from django.contrib.auth.hashers import make_password
from django_redis import get_redis_connection
from rest_framework_jwt.serializers import JSONWebTokenSerializer
from rest_framework import serializers
from rest_framework_jwt.compat import get_username_field, PasswordField
from django.utils.translation import ugettext as _
from django.contrib.auth import authenticate, get_user_model
from rest_framework_jwt.settings import api_settings
from . import models
from .utils import get_user_obj
User = get_user_model()
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
jwt_decode_handler = api_settings.JWT_DECODE_HANDLER
jwt_get_username_from_payload = api_settings.JWT_PAYLOAD_GET_USERNAME_HANDLER
class CustomeSerializer(JSONWebTokenSerializer):
def __init__(self, *args, **kwargs):
"""
Dynamically add the USERNAME_FIELD to self.fields.
"""
super(JSONWebTokenSerializer, self).__init__(*args, **kwargs)
self.fields[self.username_field] = serializers.CharField()
self.fields['password'] = PasswordField(write_only=True)
self.fields['ticket'] = serializers.CharField(write_only=True)
self.fields['randstr'] = serializers.CharField(write_only=True)
#
def validate(self, attrs):
credentials = {
self.username_field: attrs.get(self.username_field),
'password': attrs.get('password'),
'ticket': attrs.get('ticket'),
'randstr': attrs.get('randstr'),
}
#{'username':'root',password:'123'}
if all(credentials.values()):
user = authenticate(self.context['request'],**credentials) #self.context['request']当前请求的request对象
if user:
if not user.is_active:
msg = _('User account is disabled.')
raise serializers.ValidationError(msg)
payload = jwt_payload_handler(user)
return {
'token': jwt_encode_handler(payload),
'user': user
}
else:
msg = _('Unable to log in with provided credentials.')
raise serializers.ValidationError(msg)
else:
msg = _('Must include "{username_field}" and "password".')
msg = msg.format(username_field=self.username_field)
raise serializers.ValidationError(msg)
class RegisterModelSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(read_only=True)
sms = serializers.CharField(max_length=6,min_length=4,write_only=True)
r_password = serializers.CharField(write_only=True)
token = serializers.CharField(read_only=True)
class Meta:
model = models.User
fields = ['id', 'phone', 'password', 'r_password', 'sms', 'token']
extra_kwargs = {
'password':{'write_only':True},
}
# 校验密码和确认密码
def validate(self,attrs):
# 校验手机号
phone_number = attrs.get('phone')
sms = attrs.get('sms')
if not re.match('^1[3-9][0-9]{9}$',phone_number):
raise serializers.ValidationError('手机号格式不对')
ret = get_user_obj(phone_number)
if ret:
raise serializers.ValidationError('has one')
p1 = attrs.get('password')
p2 = attrs.get('r_password')
if p1 != p2:
raise serializers.ValidationError('两次密码不一致,请核对')
# todo 校验验证码
conn = get_redis_connection('sms_code')
ret = conn.get('mobile_%s'%phone_number)
if not ret:
raise serializers.ValidationError('验证码已失效')
if ret.decode() != sms:
raise serializers.ValidationError('验证码有误')
return attrs
def create(self,validated_data):
validated_data.pop('r_password')
validated_data.pop('sms')
# 密码加密
hash_password = make_password(validated_data['password'])
validated_data['password'] = hash_password
validated_data['username'] = validated_data.get('phone')
user = models.User.objects.create(
**validated_data
)
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
payload = jwt_payload_handler(user)
token = jwt_encode_handler(payload)
user.token = token
return user
需要借助容联云的短信功能
14. 容联云短信
在登录后的平台上面获取以下信息:[https://www.yuntongxun.com/]
ACCOUNT SID:8aaf07086f17620f016f308d0d2c0fa9
AUTH TOKEN : be8d96030fca44ffaf958062e6c658e8
AppID(默认):8aaf07086f17620f016f308d0d850faf
Rest URL(生产): app.cloopen.com:8883 [项目上线时使用真实短信发送服务器]
Rest URL(开发): sandboxapp.cloopen.com:8883 [项目开发时使用沙箱短信发送服务器]
pysdk文档下载(封装好的):https://github.com/cloopen/python-sms-sdk
在开发过程中,为了节约发送短信的成本,可以把自己的或者同事的手机加入到测试号码中,在首页测试号码选项中,一定要加,不然发不了短信
把容联云通讯的sdk文档解压保存到后端libs目录下,
settings/dev.py
SMS_INFO = {
'ACCID':'8a216da8754a45d5017565098de20827',
'ACCTOKEN':'57cc70d02f8940b3aa013359450ce8f9',
'APPID':'8a216da8754a45d5017565098ed4082d',
'TID':1,
}
libs/ronglian_sms_sdk/sms.py
from .SmsSDK import SmsSDK
from django.conf import settings
import json
accId = settings.SMS_INFO.get('ACCID')
accToken = settings.SMS_INFO.get('ACCTOKEN')
appId = settings.SMS_INFO.get('APPID')
def send_message(tid, mobile, datas):
sdk = SmsSDK(accId, accToken, appId)
'''
tid = '1'
mobile = '18500419869'
datas = ('1234', '3')
'''
resp = sdk.sendMessage(tid, mobile, datas)
resp = json.loads(resp)
print(resp)
return resp.get('statusCode') == '000000'
# send_message()
15. Celery
使用celery实现异步发送短信
celery是一个功能完备即插即用的异步任务队列系统,它使用于异步处理问题,当发送邮件,或者文件上传,图像处理等一些比较耗时的操作时,我们可将其异步执行,这样用户不需要等待很久,提高用户体验
文档:http://docs.jinkan.org/docs/celery/getting-started/index.html
celery的特点是:
- 简单,易于使用和维护,有丰富的文档
- 高效,单个celery进程每分钟可以处理数百万个任务
- 灵活,celery中几乎每个部分都可以自定义扩展
任务队列是一种跨线程,跨机器工作的一种机制
任务队列中包含称作任务的工作单元,有专门的工作进程持续不断的监视任务队列,并从中获得新的任务处理
celery通过消息进行通信,通常使用一个叫Broker(中间人)来协client(任务的发出者)和worker(任务的处理者). clients发出消息到队列中,broker将队列中的信息派发给worker来处理。
15.1 celery的架构
celery的架构由三部分组成,消息队列(message broker),任务执行单元(worker)和任务执行结果存储(task result store)组成
一个celery系统可以包含很多的worker和broker
Celery本身不提供消息队列功能,但是可以很方便地和第三方提供的消息中间件进行集成,包括RabbitMQ,Redis,MongoDB等
15.2 安装
pip3 install -U celery #-U是update的意思,有就进行更新,没有就安装
#后面单独将celery运行起来就可以了
也可从官方直接下载安装包:https://pypi.python.org/pypi/celery/
15.3 使用
项目根目录下创建一个 mycelery文件夹
luffyapi/
├── mycelery/
├── config.py # 配置文件
├── __init__.py
├── main.py # 主程序
└── sms/ # 一个目录可以放置多个任务,该目录下存放当前任务执行时需要的模块或依赖,也可以每个任务单独一个目录
└── tasks.py # 任务的文件,名称必须是这个!!!
main.py
from celery import Celery
from importlib import import_module
# 结合django使用,否则 celery单独就可以使用,且和django没有任何一点关系
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'luffyapi.settings.dev')
import django
django.setup()
# 创建celery主程序对象
app = Celery()
# 加载配置
app.config_from_object("mycelery.config")
# 注册任务
app.autodiscover_tasks(["mycelery.sms",])
# 通过终端来启动celery
# celery -A mycelery.main worker --loglevel=info
config.py
# 设置任务队列的url地址
broker_url = "redis://127.0.0.1:6379/14"
# 设置结果队列的url地址
result_backend = "redis://127.0.0.1:6379/15"
创建一个任务文件sms/tasks.py
from mycelery.main import app
from luffyapi.libs.ronglian_sms_sdk.sms import send_message
from django.conf import settings
from luffyapi.settings import contains
import logging
logger = logging.getLogger('django')
@app.task(name="smsCode")
def sms_codes(phone,sms_code):
# todo 发送验证码
ret = send_message(settings.SMS_INFO.get('TID'), phone, (sms_code, contains.SMS_CODE_INTERVAL_TIME))
if not ret:
logger.error('{}手机号发送失败'.format(phone))
return '发送短信成功'
15.4 运行celery
终端,项目根目录下执行指令
celery -A mycelery.main worker --loglevel=info (或者直接写info也行) #-A是指定celery启动入口
效果如下:
- ** ---------- [config]
- ** ---------- .> app: __main__:0x10b24ba50
- ** ---------- .> transport: redis://127.0.0.1:6379/14
- ** ---------- .> results: redis://127.0.0.1:6379/15
- *** --- * --- .> concurrency: 16 (prefork) #表示它开启了16个线程准备来来执行任务,可以在后面执行任务的时候自行测试一下,一共可以有16个任务同时执行
-- ******* ---- .> task events: OFF (enable -E to monitor tasks in this worker) #有没有开启其他的事件(比如事件监听等等一些东西)
运行之后,如果又添加了新的任务,需要重新 启动celery
然后执行任务,可以在mycelery下面创建一个xx.py文件进行测试
from celery.result import AsyncResult
#引入任务
from mycelery.sms.tasks import sms_code2
#执行任务
# sms_code2.delay() #这就是将任务交给worker去执行了,这个任务在上面的时候已经加到队列中了,所以调用它的意思就是让worker去队列中找到send_sms这个任务去执行
# 然后我们运行这个文件,右键运行,celery会在后台一直运行着
在redis中查看,就可以看到任务执行的结果
如果想获取任务结果可以通过get方法,或者AsyncResult这个类来拿
from celery.result import AsyncResult
#引入任务
from mycelery.sms.tasks import sms_code2
#执行任务
# sms_code.delay() #这就是将任务交给worker去执行了,这个任务在上面的时候已经加到队列中了,所以调用它的意思就是让worker去队列中找到send_sms这个任务去执行
ret = sms_code2.delay()
async_task = AsyncResult(id=ret.id,app=sms_code2)
result = async_task.get()
print(result)
15.5 django和celery进行组合
在main.py主程序对django的配置文件进行加载
# 主程序
import os
from celery import Celery
# 创建celery实例对象
app = Celery("luffy") #celery对象可以创建多个,所以我们最好给我们当前的celery应用起个名字,比如叫做luffy
# 把celery和django进行组合,需要识别和加载django的配置文件
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'luffyapi.settings.dev')
#如果只是使用了logging日志功能的话可以不写以下两句,因为logging是python提供的模块,但是将来可能使用celery来执行其他的django任务,所以我们先写上
import django
django.setup()
# 通过app对象加载配置
app.config_from_object("mycelery.config")
# 加载任务
# 参数必须必须是一个列表,里面的每一个任务都是任务的路径名称
# app.autodiscover_tasks(["任务1","任务2"])
app.autodiscover_tasks(["mycelery.sms","mycelery.mail"])
# 启动Celery的命令
# 切换目录到mycelery根目录下启动
# celery -A mycelery.main worker --loglevel=info
在需要django配置的任务中,直接加载配置,所以我们把注册的短信发送功能,整合成一个任务函数
tasks.py
from mycelery.main import app
from luffyapi.libs.ronglian_sms_sdk.sms import send_message
from django.conf import settings
from luffyapi.settings import contains
import logging
logger = logging.getLogger('django')
@app.task(name="smsCode")
def sms_codes(phone,sms_code):
# todo 发送验证码
ret = send_message(settings.SMS_INFO.get('TID'), phone, (sms_code, contains.SMS_CODE_INTERVAL_TIME))
if not ret:
logger.error('{}手机号发送失败'.format(phone))
return '发送短信成功'
@app.task
def sms_code2():
print('xxxxxx222')
return '发送短信成功了22222'
最终在django里面,views.py中需要用到celery的地方异步执行任务,需要下面操作
# 1.声明一个celery一模一样的任务函数,可以通过导包来解决
from mycelery.sms.tasks import sms_codes
# 2.调用任务函数,发布任务
sms_codes.delay(mobile,sms_code)
# sms_codes.delay() 如果调用的任务函数没有参数,则不需要填写任何内容
users/views.py
import logging
logger = logging.getLogger('django')
from django_redis import get_redis_connection
class GetSMSCodeView(APIView):
def get(self,request,phone):
# 验证是否已经发送过短信了
conn = get_redis_connection('sms_code')
ret = conn.get('mobile_interval_%s'%phone)
if ret:
return Response({'msg':'60秒内已经发送过了,别瞎搞'}, status=status.HTTP_400_BAD_REQUEST)
# 生成验证码
sms_code = "%06d" % random.randint(0,999999)
# 保存验证码
conn.setex('mobile_%s'%phone,contains.SMS_CODE_EXPIRE_TIME,sms_code)
conn.setex('mobile_interval_%s'%phone,contains.SMS_CODE_INTERVAL_TIME,sms_code)
# 发送验证码
# ret = send_message(settings.SMS_INFO.get('TID'),phone,(sms_code,contains.SMS_CODE_INTERVAL_TIME))
#
# if not ret:
# logger.error('{}手机号发送失败'.format(phone))
# return Response({'msg':'短信发送失败,请联系管理员'},status=status.HTTP_500_INTERNAL_SERVER_ERROR)
from mycelery.sms.tasks import sms_codes
sms_codes.delay(phone, sms_code)
# ret = sms_codes.delay(phone,sms_code)
# async_task = AsyncResult(id=ret.id, app=sms_codes)
# result = async_task.get()
# print('xxxxxxx>>>>',result)
return Response({'msg':'ok'})
一. 课程列表页
1.1 前端
创建Course.vue组件
<template>
<div class="course">
<Vheader></Vheader>
<div class="main">
<!-- 筛选条件 -->
<div class="condition">
<ul class="cate-list">
<li class="title">课程分类:</li>
<li class="this">全部</li>
<li>Python</li>
<li>Linux运维</li>
<li>Python进阶</li>
<li>开发工具</li>
<li>Go语言</li>
<li>机器学习</li>
<li>技术生涯</li>
</ul>
<div class="ordering">
<ul>
<li class="title">筛 选: </li>
<li class="default this">默认</li>
<li class="hot this">人气</li>
<li class="price this">价格</li>
</ul>
<p class="condition-result">共21个课程</p>
</div>
</div>
<!-- 课程列表 -->
<div class="course-list">
<div class="course-item">
<div class="course-image">
<img src="/static/image/course-cover.jpeg" alt="">
</div>
<div class="course-info">
<h3><router-link to="/course/detail/1">Python开发21天入门</router-link> <span><img src="/static/image/avatar1.svg" alt="">100人已加入学习</span></h3>
<p class="teather-info">Alex 金角大王 老男孩Python教学总监 <span>共154课时/更新完成</span></p>
<ul class="lesson-list">
<li><span class="lesson-title">01 | 第1节:初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码初识编码初识编码初识编码</span> <span class="free">免费</span></li>
</ul>
<div class="pay-box">
<span class="discount-type">限时免费</span>
<span class="discount-price">¥0.00元</span>
<span class="original-price">原价:9.00元</span>
<span class="buy-now">立即购买</span>
</div>
</div>
</div>
<div class="course-item">
<div class="course-image">
<img src="/static/image/course-cover.jpeg" alt="">
</div>
<div class="course-info">
<h3>Python开发21天入门 <span><img src="/static/image/avatar1.svg" alt="">100人已加入学习</span></h3>
<p class="teather-info">Alex 金角大王 老男孩Python教学总监 <span>共154课时/更新完成</span></p>
<ul class="lesson-list">
<li><span class="lesson-title">01 | 第1节:初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码初识编码初识编码初识编码</span> <span class="free">免费</span></li>
</ul>
<div class="pay-box">
<span class="discount-type">限时免费</span>
<span class="discount-price">¥0.00元</span>
<span class="original-price">原价:9.00元</span>
<span class="buy-now">立即购买</span>
</div>
</div>
</div>
<div class="course-item">
<div class="course-image">
<img src="/static/image/course-cover.jpeg" alt="">
</div>
<div class="course-info">
<h3>Python开发21天入门 <span><img src="/static/image/avatar1.svg" alt="">100人已加入学习</span></h3>
<p class="teather-info">Alex 金角大王 老男孩Python教学总监 <span>共154课时/更新完成</span></p>
<ul class="lesson-list">
<li><span class="lesson-title">01 | 第1节:初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码初识编码初识编码初识编码</span> <span class="free">免费</span></li>
</ul>
<div class="pay-box">
<span class="discount-type">限时免费</span>
<span class="discount-price">¥0.00元</span>
<span class="original-price">原价:9.00元</span>
<span class="buy-now">立即购买</span>
</div>
</div>
</div>
<div class="course-item">
<div class="course-image">
<img src="/static/image/course-cover.jpeg" alt="">
</div>
<div class="course-info">
<h3>Python开发21天入门 <span><img src="/static/image/avatar1.svg" alt="">100人已加入学习</span></h3>
<p class="teather-info">Alex 金角大王 老男孩Python教学总监 <span>共154课时/更新完成</span></p>
<ul class="lesson-list">
<li><span class="lesson-title">01 | 第1节:初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码初识编码初识编码初识编码</span> <span class="free">免费</span></li>
</ul>
<div class="pay-box">
<span class="discount-type">限时免费</span>
<span class="discount-price">¥0.00元</span>
<span class="original-price">原价:9.00元</span>
<span class="buy-now">立即购买</span>
</div>
</div>
</div>
</div>
</div>
<Footer></Footer>
</div>
</template>
<script>
import Vheader from "./common/Vheader"
import Footer from "./common/Footer"
export default {
name: "Course",
data(){
return{
category:0,
}
},
components:{
Header,
Footer,
}
}
</script>
<style scoped>
.course{
background: #f6f6f6;
}
.course .main{
width: 1100px;
margin: 35px auto 0;
}
.course .condition{
margin-bottom: 35px;
padding: 25px 30px 25px 20px;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 4px 0 #f0f0f0;
}
.course .cate-list{
border-bottom: 1px solid #333;
border-bottom-color: rgba(51,51,51,.05);
padding-bottom: 18px;
margin-bottom: 17px;
}
.course .cate-list::after{
content:"";
display: block;
clear: both;
}
.course .cate-list li{
float: left;
font-size: 16px;
padding: 6px 15px;
line-height: 16px;
margin-left: 14px;
position: relative;
transition: all .3s ease;
cursor: pointer;
color: #4a4a4a;
border: 1px solid transparent; /* transparent 透明 */
}
.course .cate-list .title{
color: #888;
margin-left: 0;
letter-spacing: .36px;
padding: 0;
line-height: 28px;
}
.course .cate-list .this{
color: #ffc210;
border: 1px solid #ffc210!important;
border-radius: 30px;
}
.course .ordering::after{
content:"";
display: block;
clear: both;
}
.course .ordering ul{
float: left;
}
.course .ordering ul::after{
content:"";
display: block;
clear: both;
}
.course .ordering .condition-result{
float: right;
font-size: 14px;
color: #9b9b9b;
line-height: 28px;
}
.course .ordering ul li{
float: left;
padding: 6px 15px;
line-height: 16px;
margin-left: 14px;
position: relative;
transition: all .3s ease;
cursor: pointer;
color: #4a4a4a;
}
.course .ordering .title{
font-size: 16px;
color: #888;
letter-spacing: .36px;
margin-left: 0;
padding:0;
line-height: 28px;
}
.course .ordering .this{
color: #ffc210;
}
.course .ordering .price{
position: relative;
}
.course .ordering .price::before,
.course .ordering .price::after{
cursor: pointer;
content:"";
display: block;
width: 0px;
height: 0px;
border: 5px solid transparent;
position: absolute;
right: 0;
}
.course .ordering .price::before{
border-bottom: 5px solid #aaa;
margin-bottom: 2px;
top: 2px;
}
.course .ordering .price::after{
border-top: 5px solid #aaa;
bottom: 2px;
}
.course .course-item:hover{
box-shadow: 4px 6px 16px rgba(0,0,0,.5);
}
.course .course-item{
width: 1050px;
background: #fff;
padding: 20px 30px 20px 20px;
margin-bottom: 35px;
border-radius: 2px;
cursor: pointer;
box-shadow: 2px 3px 16px rgba(0,0,0,.1);
/* css3.0 过渡动画 hover 事件操作 */
transition: all .2s ease;
}
.course .course-item::after{
content:"";
display: block;
clear: both;
}
/* 顶级元素 父级元素 当前元素{} */
.course .course-item .course-image{
float: left;
width: 423px;
height: 210px;
margin-right: 30px;
}
.course .course-item .course-image img{
width: 100%;
}
.course .course-item .course-info{
float: left;
width: 596px;
}
.course-item .course-info h3 {
font-size: 26px;
color: #333;
font-weight: normal;
margin-bottom: 8px;
}
.course-item .course-info h3 span{
font-size: 14px;
color: #9b9b9b;
float: right;
margin-top: 14px;
}
.course-item .course-info h3 span img{
width: 11px;
height: auto;
margin-right: 7px;
}
.course-item .course-info .teather-info{
font-size: 14px;
color: #9b9b9b;
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: 1px solid #333;
border-bottom-color: rgba(51,51,51,.05);
}
.course-item .course-info .teather-info span{
float: right;
}
.course-item .lesson-list::after{
content:"";
display: block;
clear: both;
}
.course-item .lesson-list li {
float: left;
width: 44%;
font-size: 14px;
color: #666;
padding-left: 22px;
/* background: url("路径") 是否平铺 x轴位置 y轴位置 */
background: url("/static/image/play-icon-gray.svg") no-repeat left 4px;
margin-bottom: 15px;
}
.course-item .lesson-list li .lesson-title{
/* 以下3句,文本内容过多,会自动隐藏,并显示省略符号 */
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
display:inline-block;
max-width: 200px;
}
.course-item .lesson-list li:hover{
background-image: url("/static/image/play-icon-yellow.svg");
color: #ffc210;
}
.course-item .lesson-list li .free{
width: 34px;
height: 20px;
color: #fd7b4d;
vertical-align: super;
margin-left: 10px;
border: 1px solid #fd7b4d;
border-radius: 2px;
text-align: center;
font-size: 13px;
white-space: nowrap;
}
.course-item .lesson-list li:hover .free{
color: #ffc210;
border-color: #ffc210;
}
.course-item .pay-box::after{
content:"";
display: block;
clear: both;
}
.course-item .pay-box .discount-type{
padding: 6px 10px;
font-size: 16px;
color: #fff;
text-align: center;
margin-right: 8px;
background: #fa6240;
border: 1px solid #fa6240;
border-radius: 10px 0 10px 0;
float: left;
}
.course-item .pay-box .discount-price{
font-size: 24px;
color: #fa6240;
float: left;
}
.course-item .pay-box .original-price{
text-decoration: line-through;
font-size: 14px;
color: #9b9b9b;
margin-left: 10px;
float: left;
margin-top: 10px;
}
.course-item .pay-box .buy-now{
width: 120px;
height: 38px;
background: transparent;
color: #fa6240;
font-size: 16px;
border: 1px solid #fd7b4d;
border-radius: 3px;
transition: all .2s ease-in-out;
float: right;
text-align: center;
line-height: 38px;
}
.course-item .pay-box .buy-now:hover{
color: #fff;
background: #ffc210;
border: 1px solid #ffc210;
}
</style>
注册路由
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
// @ 表示src目录
import Home from "@/components/Home"
import Login from "@/components/Login"
import Register from "@/components/Register"
import Course from "@/components/Course"
export default new Router({
mode:"history",
routes: [
// 。。。
{
path: '/course',
// name: 'Home',
component: Course,
},
]
})
1.2 后端
分析课程列表页面出现的数据之间的关系
数据库之间表关系
1.2.1创建课程子应用
python3 ../../manage.py startapp course
1.2.2注册子应用
INSTALLED_APPS = [
...
'course',
]
1.2.3数据模型创建
course/models.py
from django.db import models
from luffyapi.utils.models import BaseModel
# Create your models here.
class CourseCategory(BaseModel):
"""
课程分类
"""
name = models.CharField(max_length=64, unique=True, verbose_name="分类名称")
class Meta:
db_table = "ly_course_category"
verbose_name = "课程分类"
verbose_name_plural = "课程分类"
def __str__(self):
return "%s" % self.name
class Course(BaseModel):
"""
专题课程
"""
course_type = (
(0, '付费'),
(1, 'VIP专享'),
(2, '学位课程')
)
level_choices = (
(0, '初级'),
(1, '中级'),
(2, '高级'),
)
status_choices = (
(0, '上线'),
(1, '下线'),
(2, '预上线'),
)
name = models.CharField(max_length=128, verbose_name="课程名称")
course_img = models.ImageField(upload_to="course", max_length=255, verbose_name="封面图片", blank=True, null=True)
#费用类型字段是为了后期一些其他功能拓展用的,现在可以先不用,或者去掉它,目前我们项目用不到
course_type = models.SmallIntegerField(choices=course_type,default=0, verbose_name="付费类型")
# 这个字段是课程详情页里面展示的,并且详情介绍里面用户将来可能要上传一些图片之类的,所以我们会嵌入富文本编辑器,让用户填写数据的时候可以上传图片啊、写标题啊、css、html等等内容
brief = models.TextField(max_length=2048, verbose_name="详情介绍", null=True, blank=True)
level = models.SmallIntegerField(choices=level_choices, default=1, verbose_name="难度等级")
pub_date = models.DateField(verbose_name="发布日期", auto_now_add=True)
period = models.IntegerField(verbose_name="建议学习周期(day)", default=7)
#课件资料的存放路径
attachment_path = models.FileField(max_length=128, verbose_name="课件路径", blank=True, null=True)
status = models.SmallIntegerField(choices=status_choices, default=0, verbose_name="课程状态")
course_category = models.ForeignKey("CourseCategory", on_delete=models.CASCADE, null=True, blank=True,verbose_name="课程分类")
students = models.IntegerField(verbose_name="学习人数",default = 0)
lessons = models.IntegerField(verbose_name="总课时数量",default = 0)
#总课时数量可能10个,但是目前之更新了3个,就跟小说、电视剧连载似的。
pub_lessons = models.IntegerField(verbose_name="课时更新数量",default = 0)
#课程原价
price = models.DecimalField(max_digits=6,decimal_places=2, verbose_name="课程原价",default=0)
teacher = models.ForeignKey("Teacher",on_delete=models.DO_NOTHING, null=True, blank=True,verbose_name="授课老师")
class Meta:
db_table = "ly_course"
verbose_name = "专题课程"
verbose_name_plural = "专题课程"
def __str__(self):
return "%s" % self.name
class Teacher(BaseModel):
"""讲师、导师表"""
role_choices = (
(0, '讲师'),
(1, '导师'),
(2, '班主任'),
)
name = models.CharField(max_length=32, verbose_name="讲师title")
role = models.SmallIntegerField(choices=role_choices, default=0, verbose_name="讲师身份")
title = models.CharField(max_length=64, verbose_name="职位、职称")
signature = models.CharField(max_length=255, verbose_name="导师签名", help_text="导师签名", blank=True, null=True)
image = models.ImageField(upload_to="teacher", null=True, verbose_name = "讲师封面")
brief = models.TextField(max_length=1024, verbose_name="讲师描述")
class Meta:
db_table = "ly_teacher"
verbose_name = "讲师导师"
verbose_name_plural = "讲师导师"
def __str__(self):
return "%s" % self.name
class CourseChapter(BaseModel):
"""课程章节"""
course = models.ForeignKey("Course", related_name='coursechapters', on_delete=models.CASCADE, verbose_name="课程名称")
chapter = models.SmallIntegerField(verbose_name="第几章", default=1)
name = models.CharField(max_length=128, verbose_name="章节标题")
summary = models.TextField(verbose_name="章节介绍", blank=True, null=True)
pub_date = models.DateField(verbose_name="发布日期", auto_now_add=True)
class Meta:
db_table = "ly_course_chapter"
verbose_name = "课程章节"
verbose_name_plural = "课程章节"
def __str__(self):
return "%s:(第%s章)%s" % (self.course, self.chapter, self.name)
class CourseLesson(BaseModel):
"""课程课时"""
section_type_choices = (
(0, '文档'),
(1, '练习'),
(2, '视频')
)
chapter = models.ForeignKey("CourseChapter", related_name='coursesections', on_delete=models.CASCADE,verbose_name="课程章节")
name = models.CharField(max_length=128,verbose_name = "课时标题")
#orders = models.PositiveSmallIntegerField(verbose_name="课时排序") #在basemodel里面已经有了排序了
section_type = models.SmallIntegerField(default=2, choices=section_type_choices, verbose_name="课时种类")
section_link = models.CharField(max_length=255, blank=True, null=True, verbose_name="课时链接", help_text = "若是video,填vid,若是文档,填link")
duration = models.CharField(verbose_name="视频时长", blank=True, null=True, max_length=32) # 仅在前端展示使用,所以直接让上传视频的用户直接填写时长进来就可以了。
pub_date = models.DateTimeField(verbose_name="发布时间", auto_now_add=True)
free_trail = models.BooleanField(verbose_name="是否可试看", default=False)
course = models.ForeignKey('Course',related_name='course_lesson',verbose_name='课程',on_delete=models.CASCADE,null=True,blank=True)
is_show_list = models.BooleanField(verbose_name='是否推荐到课程列表',default=False)
lesson = models.IntegerField(verbose_name="第几课时")
class Meta:
db_table = "ly_course_lesson"
verbose_name = "课程课时"
verbose_name_plural = "课程课时"
def __str__(self):
return "%s-%s" % (self.chapter, self.name)
1.2.4执行数据迁移
python3 ../../manage.py makemigrations
python3 ../../manage.py migrate
1.2.5注册xadmin
把新增的课程模型注册到xadmin中
import xadmin
from .models import CourseCategory
class CourseCategoryModelAdmin(object):
"""课程分类管理模型"""
pass
xadmin.site.register(CourseCategory,CourseCategoryModelAdmin)
from .models import Course
class CourseModelAdmin(object):
"""课程模型管理类"""
pass
xadmin.site.register(Course, CourseModelAdmin)
from .models import Teacher
class TeacherModelAdmin(object):
"""老师模型管理类"""
pass
xadmin.site.register(Teacher, TeacherModelAdmin)
from .models import CourseChapter
class CourseChapterModelAdmin(object):
"""课程章节模型管理类"""
pass
xadmin.site.register(CourseChapter, CourseChapterModelAdmin)
from .models import CourseLesson
class CourseLessonModelAdmin(object):
"""课程课时模型管理类"""
pass
xadmin.site.register(CourseLesson, CourseLessonModelAdmin)
在课程分类中手动添加一些测试数据
python
go
Linux
安全
java
新媒体(不展示)
c++(不展示)
1.3 后端实现课程分类列表接口
1.3.1 配置路由
course/urls.py
from django.urls import path
from . import views
urlpatterns = [
path(r'categorys/',views.CategoryView.as_view()),
]
1.3.2 创建视图
course/views.py
from django.shortcuts import render
from rest_framework.generics import ListAPIView
# Create your views here.
from luffyapi.apps.course.pagenations import StandardPageNumberPagination
from . import models
from .serializers import CourseCategoryModelSerializer,CourseModelSerializer
class CategoryView(ListAPIView):
queryset = models.CourseCategory.objects.filter(is_deleted=False,is_show=True)
serializer_class = CourseCategoryModelSerializer
1.3.3 创建序列化器
course/serializers.py
from rest_framework import serializers
from . import models
# 课程分类
class CourseCategoryModelSerializer(serializers.ModelSerializer):
class Meta:
model = models.CourseCategory
fields = ['id','name']
1.4 客户端发送请求获取课程分类信息
高亮效果切换
<template>
<div class="course">
<Vheader></Vheader>
<div class="main">
<!-- 筛选条件 -->
<div class="condition">
<ul class="cate-list">
<li class="title">课程分类:</li>
<li :class="{this:category===0}" @click="category=0">全部</li>
<li v-for="(value, index) in category_list" :key="value.id" @click="category=value.id" :class="{this:category===value.id}">{{value.name}}</li>
</ul>
</div>
<!-- 课程列表 -->
<div class="course-list">
<div class="course-item">
<div class="course-image">
<img src="/static/img/course-cover.jpeg" alt="">
</div>
<div class="course-info">
<h3><router-link to="/course/detail/1">Python开发21天入门</router-link> <span><img src="/static/img/avatar1.svg" alt="">100人已加入学习</span></h3>
<p class="teather-info">Alex 金角大王 老男孩Python教学总监 <span>共154课时/更新完成</span></p>
<ul class="lesson-list">
<li><span class="lesson-title">01 | 第1节:初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码初识编码初识编码初识编码</span> <span class="free">免费</span></li>
</ul>
<div class="pay-box">
<span class="discount-type">限时免费</span>
<span class="discount-price">¥0.00元</span>
<span class="original-price">原价:9.00元</span>
<span class="buy-now">立即购买</span>
</div>
</div>
</div>
<div class="course-item">
<div class="course-image">
<img src="/static/img/course-cover.jpeg" alt="">
</div>
<div class="course-info">
<h3>Python开发21天入门 <span><img src="/static/img/avatar1.svg" alt="">100人已加入学习</span></h3>
<p class="teather-info">Alex 金角大王 老男孩Python教学总监 <span>共154课时/更新完成</span></p>
<ul class="lesson-list">
<li><span class="lesson-title">01 | 第1节:初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码初识编码初识编码初识编码</span> <span class="free">免费</span></li>
</ul>
<div class="pay-box">
<span class="discount-type">限时免费</span>
<span class="discount-price">¥0.00元</span>
<span class="original-price">原价:9.00元</span>
<span class="buy-now">立即购买</span>
</div>
</div>
</div>
<div class="course-item">
<div class="course-image">
<img src="/static/img/course-cover.jpeg" alt="">
</div>
<div class="course-info">
<h3>Python开发21天入门 <span><img src="/static/img/avatar1.svg" alt="">100人已加入学习</span></h3>
<p class="teather-info">Alex 金角大王 老男孩Python教学总监 <span>共154课时/更新完成</span></p>
<ul class="lesson-list">
<li><span class="lesson-title">01 | 第1节:初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码初识编码初识编码初识编码</span> <span class="free">免费</span></li>
</ul>
<div class="pay-box">
<span class="discount-type">限时免费</span>
<span class="discount-price">¥0.00元</span>
<span class="original-price">原价:9.00元</span>
<span class="buy-now">立即购买</span>
</div>
</div>
</div>
<div class="course-item">
<div class="course-image">
<img src="/static/img/course-cover.jpeg" alt="">
</div>
<div class="course-info">
<h3>Python开发21天入门 <span><img src="/static/img/avatar1.svg" alt="">100人已加入学习</span></h3>
<p class="teather-info">Alex 金角大王 老男孩Python教学总监 <span>共154课时/更新完成</span></p>
<ul class="lesson-list">
<li><span class="lesson-title">01 | 第1节:初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码</span> <span class="free">免费</span></li>
<li><span class="lesson-title">01 | 第1节:初识编码初识编码初识编码初识编码</span> <span class="free">免费</span></li>
</ul>
<div class="pay-box">
<span class="discount-type">限时免费</span>
<span class="discount-price">¥0.00元</span>
<span class="original-price">原价:9.00元</span>
<span class="buy-now">立即购买</span>
</div>
</div>
</div>
</div>
</div>
<Footer></Footer>
</div>
</template>
<script>
import Vheader from "./common/Vheader"
import Footer from "./common/Footer"
export default {
name: "Course",
data(){
return{
category:0, //默认分类zhi
category_list:[],
}
},
components:{
Vheader,
Footer,
},
created() {
this.$axios.get(`${this.$settings.Host}/course/categorys/`)
.then((res)=>{
console.log(res.data);
this.category_list = res.data;
})
}
}
</script>
<style scoped>
.course{
background: #f6f6f6;
}
.course .main{
width: 1100px;
margin: 35px auto 0;
}
.course .condition{
margin-bottom: 35px;
padding: 25px 30px 25px 20px;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 4px 0 #f0f0f0;
}
.course .cate-list{
border-bottom: 1px solid #333;
border-bottom-color: rgba(51,51,51,.05);
padding-bottom: 18px;
margin-bottom: 17px;
}
.course .cate-list::after{
content:"";
display: block;
clear: both;
}
.course .cate-list li{
float: left;
font-size: 16px;
padding: 6px 15px;
line-height: 16px;
margin-left: 14px;
position: relative;
transition: all .3s ease;
cursor: pointer;
color: #4a4a4a;
border: 1px solid transparent; /* transparent 透明 */
}
.course .cate-list .title{
color: #888;
margin-left: 0;
letter-spacing: .36px;
padding: 0;
line-height: 28px;
}
.course .cate-list .this{
color: #ffc210;
border: 1px solid #ffc210!important;
border-radius: 30px;
}
.course .ordering::after{
content:"";
display: block;
clear: both;
}
.course .ordering ul{
float: left;
}
.course .ordering ul::after{
content:"";
display: block;
clear: both;
}
.course .ordering .condition-result{
float: right;
font-size: 14px;
color: #9b9b9b;
line-height: 28px;
}
.course .ordering ul li{
float: left;
padding: 6px 15px;
line-height: 16px;
margin-left: 14px;
position: relative;
transition: all .3s ease;
cursor: pointer;
color: #4a4a4a;
}
.course .ordering .title{
font-size: 16px;
color: #888;
letter-spacing: .36px;
margin-left: 0;
padding:0;
line-height: 28px;
}
.course .ordering .this{
color: #ffc210;
}
.course .ordering .price{
position: relative;
}
.course .ordering .price::before,
.course .ordering .price::after{
cursor: pointer;
content:"";
display: block;
width: 0px;
height: 0px;
border: 5px solid transparent;
position: absolute;
right: 0;
}
.course .ordering .price::before{
border-bottom: 5px solid #aaa;
margin-bottom: 2px;
top: 2px;
}
.course .ordering .price::after{
border-top: 5px solid #aaa;
bottom: 2px;
}
.course .course-item:hover{
box-shadow: 4px 6px 16px rgba(0,0,0,.5);
}
.course .course-item{
width: 1050px;
background: #fff;
padding: 20px 30px 20px 20px;
margin-bottom: 35px;
border-radius: 2px;
cursor: pointer;
box-shadow: 2px 3px 16px rgba(0,0,0,.1);
/* css3.0 过渡动画 hover 事件操作 */
transition: all .2s ease;
}
.course .course-item::after{
content:"";
display: block;
clear: both;
}
/* 顶级元素 父级元素 当前元素{} */
.course .course-item .course-image{
float: left;
width: 423px;
height: 210px;
margin-right: 30px;
}
.course .course-item .course-image img{
width: 100%;
}
.course .course-item .course-info{
float: left;
width: 596px;
}
.course-item .course-info h3 {
font-size: 26px;
color: #333;
font-weight: normal;
margin-bottom: 8px;
}
.course-item .course-info h3 span{
font-size: 14px;
color: #9b9b9b;
float: right;
margin-top: 14px;
}
.course-item .course-info h3 span img{
width: 11px;
height: auto;
margin-right: 7px;
}
.course-item .course-info .teather-info{
font-size: 14px;
color: #9b9b9b;
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: 1px solid #333;
border-bottom-color: rgba(51,51,51,.05);
}
.course-item .course-info .teather-info span{
float: right;
}
.course-item .lesson-list::after{
content:"";
display: block;
clear: both;
}
.course-item .lesson-list li {
float: left;
width: 44%;
font-size: 14px;
color: #666;
padding-left: 22px;
/* background: url("路径") 是否平铺 x轴位置 y轴位置 */
background: url("/static/image/play-icon-gray.svg") no-repeat left 4px;
margin-bottom: 15px;
}
.course-item .lesson-list li .lesson-title{
/* 以下3句,文本内容过多,会自动隐藏,并显示省略符号 */
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
display:inline-block;
max-width: 200px;
}
.course-item .lesson-list li:hover{
background-image: url("/static/image/play-icon-yellow.svg");
color: #ffc210;
}
.course-item .lesson-list li .free{
width: 34px;
height: 20px;
color: #fd7b4d;
vertical-align: super;
margin-left: 10px;
border: 1px solid #fd7b4d;
border-radius: 2px;
text-align: center;
font-size: 13px;
white-space: nowrap;
}
.course-item .lesson-list li:hover .free{
color: #ffc210;
border-color: #ffc210;
}
.course-item .pay-box::after{
content:"";
display: block;
clear: both;
}
.course-item .pay-box .discount-type{
padding: 6px 10px;
font-size: 16px;
color: #fff;
text-align: center;
margin-right: 8px;
background: #fa6240;
border: 1px solid #fa6240;
border-radius: 10px 0 10px 0;
float: left;
}
.course-item .pay-box .discount-price{
font-size: 24px;
color: #fa6240;
float: left;
}
.course-item .pay-box .original-price{
text-decoration: line-through;
font-size: 14px;
color: #9b9b9b;
margin-left: 10px;
float: left;
margin-top: 10px;
}
.course-item .pay-box .buy-now{
width: 120px;
height: 38px;
background: transparent;
color: #fa6240;
font-size: 16px;
border: 1px solid #fd7b4d;
border-radius: 3px;
transition: all .2s ease-in-out;
float: right;
text-align: center;
line-height: 38px;
}
.course-item .pay-box .buy-now:hover{
color: #fff;
background: #ffc210;
border: 1px solid #ffc210;
}
</style>
1.5 后端实现课程信息列表接口
数据库中插入数据
INSERT INTO `ly_teacher` (`id`,`orders`,`is_show`,`is_deleted`,`created_time`,`updated_time`,`name`,`role`,`title`,`signature`,`image`,`brief`)
VALUES
(1,1,1,0,'2019-08-13 07:13:01.531992','2019-08-13 07:13:01.532043','李老师',0,'xx公司技术总监','洪七公','teacher/logo2x.png','222');
INSERT INTO `ly_course`
(`id`,`orders`,`is_show`,`is_deleted`,`created_time`,`updated_time`,`name`,`course_img`,`course_type`,`brief`,`level`,`pub_date`,`period`,`attachment_path`,`status`,`students`,`lessons`,`pub_lessons`,`price`,`course_category_id`,`teacher_id`)
VALUES
(1,1,1,0,'2019-08-13 07:13:50.678948','2019-08-15 04:07:11.386224','flask框架','course/Loginbg.3377d0c.jpg',0,'<p>xxxx</p>',1,'2019-08-13',7,'README.md',0,99,110,110,1110.00,1,1),
(2,2,1,0,'2019-08-13 07:15:32.490163','2019-08-15 04:13:22.430368','蘑菇街APP','course/course-cover.jpeg',0,'<p>dxxx</p>',2,'2019-08-13',7,'logo.svg',0,10,50,40,666.00,1,1),
(3,3,1,0,'2019-08-13 07:15:32.490163','2019-08-20 10:49:41.880490','django框架','course/2.jpeg',0,'<p>dxxx</p>',1,'2019-08-13',7,'logo.svg',0,10,50,40,330.00,1,1),
(15,4,1,0,'2019-08-13 07:15:32.490163','2019-08-13 07:15:32.490191','python入门','course/2.jpeg',0,'dxxx',2,'2019-08-13',7,'logo.svg',0,10,50,40,411.00,1,1),
(16,5,1,0,'2019-08-13 07:15:32.490163','2019-08-14 02:28:04.791112','hbase入门','course/2.jpeg',0,'dxxx',1,'2019-08-13',7,'logo.svg',0,10,50,40,400.00,7,1),
(17,6,1,0,'2019-08-13 07:15:32.490163','2019-08-13 07:15:32.490191','路飞学城项目实战','course/2.jpeg',0,'dxxx',2,'2019-08-13',7,'logo.svg',0,10,50,40,899.00,1,1),
(18,7,1,0,'2019-08-13 07:15:32.490163','2019-08-14 02:29:47.667133','负载均衡','course/2.jpeg',0,'dxxx',2,'2019-08-13',7,'logo.svg',0,10,50,40,1088.00,6,1),
(19,8,1,0,'2019-08-13 07:15:32.490163','2019-08-13 07:15:32.490191','MVC','course/2.jpeg',0,'dxxx',2,'2019-08-13',7,'logo.svg',0,10,50,40,1500.00,1,1),
(20,9,1,0,'2019-08-13 07:15:32.490163','2019-08-14 02:28:52.126968','21天java入门','course/2.jpeg',0,'dxxx',0,'2019-08-13',7,'logo.svg',0,10,50,40,3000.00,7,1),
(21,10,1,0,'2019-08-13 07:15:32.490163','2019-08-14 02:27:01.850049','7天玩转Linux运维','course/2.jpeg',0,'dxxx',2,'2019-08-13',7,'logo.svg',0,10,50,40,400.00,3,1),
(22,11,1,0,'2019-08-13 07:15:32.490163','2019-08-13 07:15:32.490191','15天掌握flask框架','course/2.jpeg',0,'dxxx',2,'2019-08-13',7,'logo.svg',0,10,50,40,400.00,1,1),
(23,12,1,0,'2019-08-13 07:15:32.490163','2019-08-13 07:23:56.015167','C编程嵌入式','course/2.jpeg',0,'dxxx',1,'2019-08-13',7,'logo.svg',0,10,50,40,399.00,3,1),
(24,13,1,0,'2019-08-13 07:15:32.490163','2019-08-14 02:29:17.872840','3天玩转树莓派','course/2.jpeg',0,'dxxx',2,'2019-08-13',7,'logo.svg',0,10,50,40,400.00,3,1),
(25,14,1,0,'2019-08-13 07:15:32.490163','2019-08-14 02:28:30.783768','MongoDB','course/2.jpeg',0,'dxxx',0,'2019-08-13',7,'logo.svg',0,10,50,40,400.00,3,1),
(26,15,1,0,'2019-08-13 07:15:32.490163','2019-08-14 02:30:09.348192','Beego框架入门','course/2.jpeg',0,'dxxx',1,'2019-08-13',7,'logo.svg',0,10,50,40,699.00,2,1),
(27,16,1,0,'2019-08-13 07:15:32.490163','2019-08-15 02:35:20.997672','beego框架进阶','course/2.jpeg',0,'<p>dxxx</p>',1,'2019-08-13',7,'logo.svg',0,10,50,50,400.00,2,1),
(28,17,1,0,'2019-08-13 07:15:32.490163','2019-08-13 07:23:44.546598','以太坊入门','course/2.jpeg',0,'dxxx',2,'2019-08-13',7,'logo.svg',0,10,50,40,899.00,2,1),
(29,18,1,0,'2019-08-13 07:15:32.490163','2019-08-15 04:05:10.421736','负载均衡','course/2.jpeg',0,'<div style=\"background:#eeeeee; border:1px solid #cccccc; padding:5px 10px\"><span style=\"color:#16a085\"><span style=\"font-family:Arial,Helvetica,sans-serif\"><span style=\"font-size:28px\"><span style=\"background-color:#f39c12\">dxxx</span></span></span></span><img alt=\"\" src=\"/media/2019/08/15/course-cover.jpeg\" /></div>\r\n\r\n<div style=\"background:#eeeeee; border:1px solid #cccccc; padding:5px 10px\"> </div>\r\n\r\n<div style=\"background:#eeeeee; border:1px solid #cccccc; padding:5px 10px\">\r\n<table border=\"1\" cellpadding=\"1\" cellspacing=\"1\" style=\"width:500px\">\r\n <tbody>\r\n <tr>\r\n <td>12321</td>\r\n <td>3232</td>\r\n <td>111</td>\r\n </tr>\r\n <tr>\r\n <td>33</td>\r\n <td>33</td>\r\n <td>22</td>\r\n </tr>\r\n <tr>\r\n <td>11</td>\r\n <td>22</td>\r\n <td>23</td>\r\n </tr>\r\n </tbody>\r\n</table>\r\n\r\n<p> </p>\r\n</div>',0,'2019-08-13',7,'logo.svg',0,10,50,40,400.00,3,1);
1.5.1路由设置
course/urls.py
from django.urls import path
from . import views
urlpatterns = [
path(r'categorys/', views.CategoryView.as_view(),),
path(r'courses/', views.CourseView.as_view(),),
]
1.5.2 视图
course/views.py
class CourseView(ListAPIView):
queryset = models.Course.objects.filter(is_deleted=False,is_show=True).order_by('id')
serializer_class = CourseModelSerializer
1.5.3 序列化器
# 老师
class TeacherModelSerializer(serializers.ModelSerializer):
class Meta:
model = models.Teacher
fields = ['name','role','title','signature']
# 课程信息
class CourseModelSerializer(serializers.ModelSerializer):
# teacher_name = serializers.CharField(source='teacher.name') #自定义字段,通过source关键字就能获取外键关联的指定字段数据,别忘了在fields指定一下
teacher = TeacherModelSerializer() # 将外键关联的属性指定为关联表的序列化器对象,就能拿到关联表序列化出来的所有数据,还需要在fields中指定一下,注意,名称必须要和外键属性名称相同
class Meta:
model = models.Course
# fields =["id","name","course_img","students","lessons","pub_lessons","price","teacher","teacher_name"] # teacher外键属性默认拿的是id值
fields =["id","name","course_img","students","lessons","pub_lessons","price","teacher","get_lessons"] # teacher外键属性默认拿的是id值
序列化其中的fields属性中的get_lessons是models.py文件中Course类中的一个方法
通过课程对象获取所有课程列表页中要展示的课时信息
class Course(BaseModel):
...
class Meta:
db_table = "ly_course"
verbose_name = "专题课程"
verbose_name_plural = "专题课程"
def __str__(self):
return "%s" % self.name
# 通过课程对象获取所有课程列表页中要展示的课时信息
def get_lessons(self):
chapters = self.coursechapters.all()
lession_list = []
for chapter in chapters:
lessons = chapter.coursesections.filter(is_show_list=True,is_show=True,is_deleted=False)
for lesson in lessons:
lession_list.append({
'name':lesson.name,
'free_trail':lesson.free_trail,
'lesson':lesson.lesson,
})
return lession_list[:4]
添加课程章节的测试数据
INSERT INTO `ly_course_chapter` VALUES (1,1,1,0,'2019-08-13 07:24:21.889515','2019-08-13 07:24:21.889542',1,'flask框架快速入门','flask框架快速入门','2019-08-13',1),(2,2,1,0,'2019-08-13 07:24:37.116231','2019-08-15 03:59:17.598352',2,'flask的路由','flask的路由','2019-08-13',1),(3,3,1,0,'2019-08-13 07:24:51.153812','2019-08-15 03:59:22.067057',3,'flask的视图','flask的视图','2019-08-13',1),(4,4,1,0,'2019-08-13 07:25:00.621686','2019-08-15 03:59:29.642805',4,'flask的模型','flask的模型','2019-08-13',1),(5,5,1,0,'2019-08-13 07:24:21.889515','2019-08-13 07:24:21.889542',1,'django框架快速入门','django框架快速入门','2019-08-13',3),(6,6,1,0,'2019-08-13 07:24:37.116231','2019-08-13 07:24:37.116262',2,'django的路由','django的路由','2019-08-13',3),(7,7,1,0,'2019-08-13 07:24:51.153812','2019-08-13 07:24:51.153846',3,'django的视图','django的视图','2019-08-13',3),(8,8,1,0,'2019-08-13 07:25:00.621686','2019-08-13 07:25:00.621768',4,'django的模型','django的模型','2019-08-13',3);
添加课程课时的测试数据
INSERT INTO `ly_course_lesson`
(`id`,`is_show`,`is_deleted`,`created_time`,`updated_time`,`name`,`orders`,`section_type`,`section_link`,`duration`,`pub_date`,`free_trail`,`chapter_id`,`course_id`,`is_show_list`,`lesson`)
VALUES
(1,1,0,'2019-08-13 07:27:06.873098','2019-08-13 07:27:06.873149','flask基本介绍',1,0,'http://www.baidu.com','3:00','2019-08-13 07:27:06.873188',1,1,1,1,1),
(2,1,0,'2019-08-13 07:27:27.408740','2019-08-13 07:27:27.408766','flask的优缺点',2,2,'http://www.baidu.com','3:30','2019-08-13 07:27:27.408798',1,1,1,0,2),
(3,1,0,'2019-08-13 07:27:45.659948','2019-08-13 07:27:45.659980','flask的安装',3,2,'http://www.baidu.com','5:00','2019-08-13 07:27:45.660016',0,1,1,1,3),
(13,1,0,'2019-08-13 07:27:06.873098','2019-08-13 07:27:06.873149','flask的项目搭建',4,2,'http://www.baidu.com','3:00','2019-08-13 07:27:06.873188',0,1,1,0,4),
(14,1,0,'2019-08-13 07:27:27.408740','2019-08-13 07:27:27.408766','flask的项目基本目录结构',5,2,'http://www.baidu.com','3:30','2019-08-13 07:27:27.408798',0,1,1,1,5),
(15,1,0,'2019-08-13 07:27:45.659948','2019-08-13 07:27:45.659980','flask的运行',6,2,'http://www.baidu.com','5:00','2019-08-13 07:27:45.660016',0,1,1,0,6),
(16,1,0,'2019-08-13 07:27:06.873098','2019-08-26 12:57:10.455292','django基本介绍',1,2,'7ec57198590152fd3a647d73e218e385_7','14:00','2019-08-13 07:27:06.873188',1,5,3,1,1),
(17,1,0,'2019-08-13 07:27:27.408740','2019-08-13 07:27:27.408766','django的优缺点',2,2,'http://www.baidu.com','3:30','2019-08-13 07:27:27.408798',1,5,3,0,2),
(18,1,0,'2019-08-13 07:27:45.659948','2019-08-14 02:13:52.517392','django的安装',3,2,'http://www.baidu.com','5:00','2019-08-13 07:27:45.660016',1,5,3,1,3),
(19,1,0,'2019-08-13 07:27:06.873098','2019-08-13 07:27:06.873149','django的项目搭建',4,2,'http://www.baidu.com','3:00','2019-08-13 07:27:06.873188',0,5,3,1,4),
(20,1,0,'2019-08-13 07:27:27.408740','2019-08-13 07:27:27.408766','django的项目基本目录结构',5,2,'http://www.baidu.com','3:30','2019-08-13 07:27:27.408798',0,5,3,1,5),
(21,1,0,'2019-08-13 07:27:45.659948','2019-08-13 07:27:45.659980','django的运行',6,2,'http://www.baidu.com','5:00','2019-08-13 07:27:45.660016',0,5,3,0,6),
(22,1,0,'2019-08-15 03:50:02.221829','2019-08-15 03:50:29.325498','路由的分类-命名绑定参数',1,2,'http://www.baidu.com','16:00','2019-08-15 03:50:02.221897',0,2,1,1,1);
1.6 客户端发送请求获取课程列表信息
<template>
<div class="course">
<Vheader></Vheader>
<div class="main">
<!-- 筛选条件 -->
<div class="condition">
<ul class="cate-list">
<li class="title">课程分类:</li>
<li :class="{this:category===0}" @click="category=0">全部</li>
<li v-for="(value, index) in category_list" :key="value.id" @click="category=value.id" :class="{this:category===value.id}">{{value.name}}</li>
</ul>
</div>
<!-- 课程列表 -->
<div class="course-list">
<div class="course-item" v-for="(course, courseindex) in course_list">
<div class="course-image">
<img :src="course.course_img" alt="">
</div>
<div class="course-info">
<h3><router-link to="">{{course.name}}</router-link> <span><img src="/static/img/avatar1.svg" alt="">{{course.students}}人已加入学习</span></h3>
<p class="teather-info">{{course.teacher.name}} {{course.teacher.signature}} {{course.teacher.title}} <span>共{{course.lessons}}
课时/{{course.lessons===course.pub_lessons? '更新完成':`已更新${course.pub_lessons}课时`}}</span></p>
<ul class="lesson-list">
<li v-for="(lesson, lessonindex) in course.get_lessons" :key="lessonindex"><span class="lesson-title">0{{lessonindex+1}} | 第{{lesson.lesson}}节:{{lesson.name}}</span><span v-show="lesson.free_trail" class="free">免费</span></li>
</ul>
<div class="pay-box">
<span class="discount-type">限时免费</span>
<span class="discount-price">¥0.00元</span>
<span class="original-price">原价:{{course.price}}元</span>
<span class="buy-now">立即购买</span>
</div>
</div>
</div>
</div>
</div>
<Footer></Footer>
</div>
</template>
<script>
import Vheader from "./common/Vheader"
import Footer from "./common/Footer"
export default {
name: "Course",
data(){
return{
category:0, //默认分类zhi
category_list:[],
course_list:[],
}
},
components:{
Vheader,
Footer,
},
created() {
this.get_categorys();
this.get_course();
},
methods:{
// 获取所有分类数据
get_categorys(){
this.$axios.get(`${this.$settings.Host}/course/categorys/`)
.then((res)=>{
//console.log(res.data);
this.category_list = res.data;
})
},
// 获取课程列表数据
get_course(){
this.$axios.get(`${this.$settings.Host}/course/courses/`)
.then((res)=>{
console.log(res.data);
this.course_list = res.data;
})
}
}
}
</script>
<style scoped>
.course{
background: #f6f6f6;
}
.course .main{
width: 1100px;
margin: 35px auto 0;
}
.course .condition{
margin-bottom: 35px;
padding: 25px 30px 25px 20px;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 4px 0 #f0f0f0;
}
.course .cate-list{
border-bottom: 1px solid #333;
border-bottom-color: rgba(51,51,51,.05);
padding-bottom: 18px;
margin-bottom: 17px;
}
.course .cate-list::after{
content:"";
display: block;
clear: both;
}
.course .cate-list li{
float: left;
font-size: 16px;
padding: 6px 15px;
line-height: 16px;
margin-left: 14px;
position: relative;
transition: all .3s ease;
cursor: pointer;
color: #4a4a4a;
border: 1px solid transparent; /* transparent 透明 */
}
.course .cate-list .title{
color: #888;
margin-left: 0;
letter-spacing: .36px;
padding: 0;
line-height: 28px;
}
.course .cate-list .this{
color: #ffc210;
border: 1px solid #ffc210!important;
border-radius: 30px;
}
.course .ordering::after{
content:"";
display: block;
clear: both;
}
.course .ordering ul{
float: left;
}
.course .ordering ul::after{
content:"";
display: block;
clear: both;
}
.course .ordering .condition-result{
float: right;
font-size: 14px;
color: #9b9b9b;
line-height: 28px;
}
.course .ordering ul li{
float: left;
padding: 6px 15px;
line-height: 16px;
margin-left: 14px;
position: relative;
transition: all .3s ease;
cursor: pointer;
color: #4a4a4a;
}
.course .ordering .title{
font-size: 16px;
color: #888;
letter-spacing: .36px;
margin-left: 0;
padding:0;
line-height: 28px;
}
.course .ordering .this{
color: #ffc210;
}
.course .ordering .price{
position: relative;
}
.course .ordering .price::before,
.course .ordering .price::after{
cursor: pointer;
content:"";
display: block;
width: 0px;
height: 0px;
border: 5px solid transparent;
position: absolute;
right: 0;
}
.course .ordering .price::before{
border-bottom: 5px solid #aaa;
margin-bottom: 2px;
top: 2px;
}
.course .ordering .price::after{
border-top: 5px solid #aaa;
bottom: 2px;
}
.course .course-item:hover{
box-shadow: 4px 6px 16px rgba(0,0,0,.5);
}
.course .course-item{
width: 1050px;
background: #fff;
padding: 20px 30px 20px 20px;
margin-bottom: 35px;
border-radius: 2px;
cursor: pointer;
box-shadow: 2px 3px 16px rgba(0,0,0,.1);
/* css3.0 过渡动画 hover 事件操作 */
transition: all .2s ease;
}
.course .course-item::after{
content:"";
display: block;
clear: both;
}
/* 顶级元素 父级元素 当前元素{} */
.course .course-item .course-image{
float: left;
width: 423px;
height: 210px;
margin-right: 30px;
}
.course .course-item .course-image img{
width: 100%;
}
.course .course-item .course-info{
float: left;
width: 596px;
}
.course-item .course-info h3 {
font-size: 26px;
color: #333;
font-weight: normal;
margin-bottom: 8px;
}
.course-item .course-info h3 span{
font-size: 14px;
color: #9b9b9b;
float: right;
margin-top: 14px;
}
.course-item .course-info h3 span img{
width: 11px;
height: auto;
margin-right: 7px;
}
.course-item .course-info .teather-info{
font-size: 14px;
color: #9b9b9b;
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: 1px solid #333;
border-bottom-color: rgba(51,51,51,.05);
}
.course-item .course-info .teather-info span{
float: right;
}
.course-item .lesson-list::after{
content:"";
display: block;
clear: both;
}
.course-item .lesson-list li {
float: left;
width: 44%;
font-size: 14px;
color: #666;
padding-left: 22px;
/* background: url("路径") 是否平铺 x轴位置 y轴位置 */
background: url("/static/image/play-icon-gray.svg") no-repeat left 4px;
margin-bottom: 15px;
}
.course-item .lesson-list li .lesson-title{
/* 以下3句,文本内容过多,会自动隐藏,并显示省略符号 */
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
display:inline-block;
max-width: 200px;
}
.course-item .lesson-list li:hover{
background-image: url("/static/image/play-icon-yellow.svg");
color: #ffc210;
}
.course-item .lesson-list li .free{
width: 34px;
height: 20px;
color: #fd7b4d;
vertical-align: super;
margin-left: 10px;
border: 1px solid #fd7b4d;
border-radius: 2px;
text-align: center;
font-size: 13px;
white-space: nowrap;
}
.course-item .lesson-list li:hover .free{
color: #ffc210;
border-color: #ffc210;
}
.course-item .pay-box::after{
content:"";
display: block;
clear: both;
}
.course-item .pay-box .discount-type{
padding: 6px 10px;
font-size: 16px;
color: #fff;
text-align: center;
margin-right: 8px;
background: #fa6240;
border: 1px solid #fa6240;
border-radius: 10px 0 10px 0;
float: left;
}
.course-item .pay-box .discount-price{
font-size: 24px;
color: #fa6240;
float: left;
}
.course-item .pay-box .original-price{
text-decoration: line-through;
font-size: 14px;
color: #9b9b9b;
margin-left: 10px;
float: left;
margin-top: 10px;
}
.course-item .pay-box .buy-now{
width: 120px;
height: 38px;
background: transparent;
color: #fa6240;
font-size: 16px;
border: 1px solid #fd7b4d;
border-radius: 3px;
transition: all .2s ease-in-out;
float: right;
text-align: center;
line-height: 38px;
}
.course-item .pay-box .buy-now:hover{
color: #fff;
background: #ffc210;
border: 1px solid #ffc210;
}
</style>
如果不想在组件中写css,可以创建css文件,然后通过下面的方式引入
<style scoped>
@import '../../static/css/xx.css';
</style>
1.7 按照指定分类显示课程信息(过滤器--filter)
在当前项目中安装过滤器对字段进行过滤
pip3 install django-filter
在settings/dev.py配置文件中添加过滤后端的设置
INSTALLED_APPS = [
...
'django_filters', # 需要注册应用,
]
REST_FRAMEWORK = {
...
'DEFAULT_FILTER_BACKENDS': ('django_filters.rest_framework.DjangoFilterBackend',)
}
视图中设置允许过滤的字段名和引入过滤字段核心类
class CourseView(ListAPIView):
queryset = models.Course.objects.filter(is_deleted=False,is_show=True).order_by('id')
serializer_class = CourseModelsSerializer
filter_fields = ('course_category', )
加上过滤之后的前端Course.vue组件
<template>
<div class="course">
<Vheader></Vheader>
<div class="main">
<!-- 筛选条件 -->
<div class="condition">
<ul class="cate-list">
<li class="title">课程分类:</li>
<li :class="{this:category===0}" @click="category=0">全部</li>
<li v-for="(value, index) in category_list" :key="value.id" @click="category=value.id" :class="{this:category===value.id}">{{value.name}}</li>
</ul>
</div>
<!-- 课程列表 -->
<div class="course-list">
<div class="course-item" v-for="(course, courseindex) in course_list">
<div class="course-image">
<img :src="course.course_img" alt="">
</div>
<div class="course-info">
<h3><router-link to="">{{course.name}}</router-link> <span><img src="/static/img/avatar1.svg" alt="">{{course.students}}人已加入学习</span></h3>
<p class="teather-info">{{course.teacher.name}} {{course.teacher.signature}} {{course.teacher.title}} <span>共{{course.lessons}}
课时/{{course.lessons===course.pub_lessons? '更新完成':`已更新${course.pub_lessons}课时`}}</span></p>
<ul class="lesson-list">
<li v-for="(lesson, lessonindex) in course.get_lessons" :key="lessonindex"><span class="lesson-title">0{{lessonindex+1}} | 第{{lesson.lesson}}节:{{lesson.name}}</span><span v-show="lesson.free_trail" class="free">免费</span></li>
</ul>
<div class="pay-box">
<span class="discount-type">限时免费</span>
<span class="discount-price">¥0.00元</span>
<span class="original-price">原价:{{course.price}}元</span>
<span class="buy-now">立即购买</span>
</div>
</div>
</div>
</div>
</div>
<Footer></Footer>
</div>
</template>
<script>
import Vheader from "./common/Vheader"
import Footer from "./common/Footer"
export default {
name: "Course",
data(){
return{
category:0, //默认分类zhi
category_list:[],
course_list:[],
fitlers:{},
}
},
components:{
Vheader,
Footer,
},
created() {
this.get_categorys();
this.get_course();
},
watch:{
category(){
if (this.category>0){
this.fitlers['course_category'] = this.category;
}else {
this.fitlers={}
}
//console.log('>>>>>',this.fitlers)
this.get_course(); //当分类数据发生变化时,出发获取数据的动作
}
},
methods:{
// 获取所有分类数据
get_categorys(){
this.$axios.get(`${this.$settings.Host}/course/categorys/`)
.then((res)=>{
//console.log(res.data);
this.category_list = res.data;
})
},
// 获取课程列表数据
get_course(){
this.$axios.get(`${this.$settings.Host}/course/courses/`,{
params:this.fitlers,
})
.then((res)=>{
//console.log(res.data);
this.course_list = res.data;
})
}
}
}
</script>
<style scoped>
.course{
background: #f6f6f6;
}
.course .main{
width: 1100px;
margin: 35px auto 0;
}
.course .condition{
margin-bottom: 35px;
padding: 25px 30px 25px 20px;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 4px 0 #f0f0f0;
}
.course .cate-list{
border-bottom: 1px solid #333;
border-bottom-color: rgba(51,51,51,.05);
padding-bottom: 18px;
margin-bottom: 17px;
}
.course .cate-list::after{
content:"";
display: block;
clear: both;
}
.course .cate-list li{
float: left;
font-size: 16px;
padding: 6px 15px;
line-height: 16px;
margin-left: 14px;
position: relative;
transition: all .3s ease;
cursor: pointer;
color: #4a4a4a;
border: 1px solid transparent; /* transparent 透明 */
}
.course .cate-list .title{
color: #888;
margin-left: 0;
letter-spacing: .36px;
padding: 0;
line-height: 28px;
}
.course .cate-list .this{
color: #ffc210;
border: 1px solid #ffc210!important;
border-radius: 30px;
}
.course .ordering::after{
content:"";
display: block;
clear: both;
}
.course .ordering ul{
float: left;
}
.course .ordering ul::after{
content:"";
display: block;
clear: both;
}
.course .ordering .condition-result{
float: right;
font-size: 14px;
color: #9b9b9b;
line-height: 28px;
}
.course .ordering ul li{
float: left;
padding: 6px 15px;
line-height: 16px;
margin-left: 14px;
position: relative;
transition: all .3s ease;
cursor: pointer;
color: #4a4a4a;
}
.course .ordering .title{
font-size: 16px;
color: #888;
letter-spacing: .36px;
margin-left: 0;
padding:0;
line-height: 28px;
}
.course .ordering .this{
color: #ffc210;
}
.course .ordering .price{
position: relative;
}
.course .ordering .price::before,
.course .ordering .price::after{
cursor: pointer;
content:"";
display: block;
width: 0px;
height: 0px;
border: 5px solid transparent;
position: absolute;
right: 0;
}
.course .ordering .price::before{
border-bottom: 5px solid #aaa;
margin-bottom: 2px;
top: 2px;
}
.course .ordering .price::after{
border-top: 5px solid #aaa;
bottom: 2px;
}
.course .course-item:hover{
box-shadow: 4px 6px 16px rgba(0,0,0,.5);
}
.course .course-item{
width: 1050px;
background: #fff;
padding: 20px 30px 20px 20px;
margin-bottom: 35px;
border-radius: 2px;
cursor: pointer;
box-shadow: 2px 3px 16px rgba(0,0,0,.1);
/* css3.0 过渡动画 hover 事件操作 */
transition: all .2s ease;
}
.course .course-item::after{
content:"";
display: block;
clear: both;
}
/* 顶级元素 父级元素 当前元素{} */
.course .course-item .course-image{
float: left;
width: 423px;
height: 210px;
margin-right: 30px;
}
.course .course-item .course-image img{
width: 100%;
}
.course .course-item .course-info{
float: left;
width: 596px;
}
.course-item .course-info h3 {
font-size: 26px;
color: #333;
font-weight: normal;
margin-bottom: 8px;
}
.course-item .course-info h3 span{
font-size: 14px;
color: #9b9b9b;
float: right;
margin-top: 14px;
}
.course-item .course-info h3 span img{
width: 11px;
height: auto;
margin-right: 7px;
}
.course-item .course-info .teather-info{
font-size: 14px;
color: #9b9b9b;
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: 1px solid #333;
border-bottom-color: rgba(51,51,51,.05);
}
.course-item .course-info .teather-info span{
float: right;
}
.course-item .lesson-list::after{
content:"";
display: block;
clear: both;
}
.course-item .lesson-list li {
float: left;
width: 44%;
font-size: 14px;
color: #666;
padding-left: 22px;
/* background: url("路径") 是否平铺 x轴位置 y轴位置 */
background: url("/static/image/play-icon-gray.svg") no-repeat left 4px;
margin-bottom: 15px;
}
.course-item .lesson-list li .lesson-title{
/* 以下3句,文本内容过多,会自动隐藏,并显示省略符号 */
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
display:inline-block;
max-width: 200px;
}
.course-item .lesson-list li:hover{
background-image: url("/static/image/play-icon-yellow.svg");
color: #ffc210;
}
.course-item .lesson-list li .free{
width: 34px;
height: 20px;
color: #fd7b4d;
vertical-align: super;
margin-left: 10px;
border: 1px solid #fd7b4d;
border-radius: 2px;
text-align: center;
font-size: 13px;
white-space: nowrap;
}
.course-item .lesson-list li:hover .free{
color: #ffc210;
border-color: #ffc210;
}
.course-item .pay-box::after{
content:"";
display: block;
clear: both;
}
.course-item .pay-box .discount-type{
padding: 6px 10px;
font-size: 16px;
color: #fff;
text-align: center;
margin-right: 8px;
background: #fa6240;
border: 1px solid #fa6240;
border-radius: 10px 0 10px 0;
float: left;
}
.course-item .pay-box .discount-price{
font-size: 24px;
color: #fa6240;
float: left;
}
.course-item .pay-box .original-price{
text-decoration: line-through;
font-size: 14px;
color: #9b9b9b;
margin-left: 10px;
float: left;
margin-top: 10px;
}
.course-item .pay-box .buy-now{
width: 120px;
height: 38px;
background: transparent;
color: #fa6240;
font-size: 16px;
border: 1px solid #fd7b4d;
border-radius: 3px;
transition: all .2s ease-in-out;
float: right;
text-align: center;
line-height: 38px;
}
.course-item .pay-box .buy-now:hover{
color: #fff;
background: #ffc210;
border: 1px solid #ffc210;
}
</style>
1.8分页效果
1.8.1 后端
course/views.py
from django.shortcuts import render
from rest_framework.generics import ListAPIView
# Create your views here.
from luffyapi.apps.course.pagenations import StandardPageNumberPagination
from . import models
from .serializers import CourseCategoryModelSerializer,CourseModelSerializer
class CategoryView(ListAPIView):
queryset = models.CourseCategory.objects.filter(is_deleted=False,is_show=True)
serializer_class = CourseCategoryModelSerializer
class CourseView(ListAPIView):
queryset = models.Course.objects.filter(is_deleted=False,is_show=True).order_by('id')
serializer_class = CourseModelSerializer
filter_fields = ('course_category',)
pagination_class = StandardPageNumberPagination
course/pagenation.py
from rest_framework.pagination import PageNumberPagination
class StandardPageNumberPagination(PageNumberPagination):
# 默认每一页显示的数据量
page_size = 2
# 允许客户端通过get参数来控制每一页的数据量
page_size_query_param = "size"
max_page_size = 10 #客户端通过size指定获取数据的条数时,最大不能超过多少
1.8.2 前端
Course.vue
<template>
<div class="course">
<Vheader></Vheader>
<div class="main">
<!-- 筛选条件 -->
<div class="condition">
<ul class="cate-list">
<li class="title">课程分类:</li>
<li :class="{this:category===0}" @click="category=0">全部</li>
<li v-for="(value, index) in category_list" :key="value.id" @click="category=value.id" :class="{this:category===value.id}">{{value.name}}</li>
</ul>
</div>
<!-- 课程列表 -->
<div class="course-list">
<div class="course-item" v-for="(course, courseindex) in course_list">
<div class="course-image">
<img :src="course.course_img" alt="">
</div>
<div class="course-info">
<h3><router-link to="">{{course.name}}</router-link> <span><img src="/static/img/avatar1.svg" alt="">{{course.students}}人已加入学习</span></h3>
<p class="teather-info">{{course.teacher.name}} {{course.teacher.signature}} {{course.teacher.title}} <span>共{{course.lessons}}
课时/{{course.lessons===course.pub_lessons? '更新完成':`已更新${course.pub_lessons}课时`}}</span></p>
<ul class="lesson-list">
<li v-for="(lesson, lessonindex) in course.get_lessons" :key="lessonindex"><span class="lesson-title">0{{lessonindex+1}} | 第{{lesson.lesson}}节:{{lesson.name}}</span><span v-show="lesson.free_trail" class="free">免费</span></li>
</ul>
<div class="pay-box">
<span class="discount-type">限时免费</span>
<span class="discount-price">¥0.00元</span>
<span class="original-price">原价:{{course.price}}元</span>
<span class="buy-now">立即购买</span>
</div>
</div>
</div>
</div>
</div>
<div class="c1">
<el-pagination
background
:page-size="2"
layout="prev, pager, next, sizes,jumper"
:page-sizes="[2, 5, 10, 15, 20]"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
:total="total">
</el-pagination>
</div>
<Footer></Footer>
</div>
</template>
<script>
import Vheader from "./common/Vheader"
import Footer from "./common/Footer"
export default {
name: "Course",
data(){
return{
category:0, //默认分类zhi
category_list:[],
course_list:[],
fitlers:{},
total:0
}
},
components:{
Vheader,
Footer,
},
created() {
this.get_categorys();
this.get_course();
},
watch:{
category(){
if (this.category>0){
this.fitlers['course_category'] = this.category;
}else {
this.fitlers={}
}
//console.log('>>>>>',this.fitlers)
this.get_course(); //当分类数据发生变化时,出发获取数据的动作
}
},
methods:{
handleSizeChange(val){
this.fitlers['size'] = val
// console.log(val);
this.get_course();
},
handleCurrentChange(val){
this.fitlers['page'] = val
// console.log(val);
this.get_course();
},
// 获取所有分类数据
get_categorys(){
this.$axios.get(`${this.$settings.Host}/course/categorys/`)
.then((res)=>{
//console.log(res.data);
this.category_list = res.data;
})
},
// 获取课程列表数据
get_course(){
this.$axios.get(`${this.$settings.Host}/course/courses/`,{
params:this.fitlers,
})
.then((res)=>{
//console.log(res.data);
this.total = res.data.count
this.course_list = res.data.results;
})
}
}
}
</script>
<style scoped>
.c1 /deep/ .el-pagination{
text-align: center;
font-size: 40px;
}
.course{
background: #f6f6f6;
}
.course .main{
width: 1100px;
margin: 35px auto 0;
}
.course .condition{
margin-bottom: 35px;
padding: 25px 30px 25px 20px;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 4px 0 #f0f0f0;
}
.course .cate-list{
border-bottom: 1px solid #333;
border-bottom-color: rgba(51,51,51,.05);
padding-bottom: 18px;
margin-bottom: 17px;
}
.course .cate-list::after{
content:"";
display: block;
clear: both;
}
.course .cate-list li{
float: left;
font-size: 16px;
padding: 6px 15px;
line-height: 16px;
margin-left: 14px;
position: relative;
transition: all .3s ease;
cursor: pointer;
color: #4a4a4a;
border: 1px solid transparent; /* transparent 透明 */
}
.course .cate-list .title{
color: #888;
margin-left: 0;
letter-spacing: .36px;
padding: 0;
line-height: 28px;
}
.course .cate-list .this{
color: #ffc210;
border: 1px solid #ffc210!important;
border-radius: 30px;
}
.course .ordering::after{
content:"";
display: block;
clear: both;
}
.course .ordering ul{
float: left;
}
.course .ordering ul::after{
content:"";
display: block;
clear: both;
}
.course .ordering .condition-result{
float: right;
font-size: 14px;
color: #9b9b9b;
line-height: 28px;
}
.course .ordering ul li{
float: left;
padding: 6px 15px;
line-height: 16px;
margin-left: 14px;
position: relative;
transition: all .3s ease;
cursor: pointer;
color: #4a4a4a;
}
.course .ordering .title{
font-size: 16px;
color: #888;
letter-spacing: .36px;
margin-left: 0;
padding:0;
line-height: 28px;
}
.course .ordering .this{
color: #ffc210;
}
.course .ordering .price{
position: relative;
}
.course .ordering .price::before,
.course .ordering .price::after{
cursor: pointer;
content:"";
display: block;
width: 0px;
height: 0px;
border: 5px solid transparent;
position: absolute;
right: 0;
}
.course .ordering .price::before{
border-bottom: 5px solid #aaa;
margin-bottom: 2px;
top: 2px;
}
.course .ordering .price::after{
border-top: 5px solid #aaa;
bottom: 2px;
}
.course .course-item:hover{
box-shadow: 4px 6px 16px rgba(0,0,0,.5);
}
.course .course-item{
width: 1050px;
background: #fff;
padding: 20px 30px 20px 20px;
margin-bottom: 35px;
border-radius: 2px;
cursor: pointer;
box-shadow: 2px 3px 16px rgba(0,0,0,.1);
/* css3.0 过渡动画 hover 事件操作 */
transition: all .2s ease;
}
.course .course-item::after{
content:"";
display: block;
clear: both;
}
/* 顶级元素 父级元素 当前元素{} */
.course .course-item .course-image{
float: left;
width: 423px;
height: 210px;
margin-right: 30px;
}
.course .course-item .course-image img{
width: 100%;
}
.course .course-item .course-info{
float: left;
width: 596px;
}
.course-item .course-info h3 {
font-size: 26px;
color: #333;
font-weight: normal;
margin-bottom: 8px;
}
.course-item .course-info h3 span{
font-size: 14px;
color: #9b9b9b;
float: right;
margin-top: 14px;
}
.course-item .course-info h3 span img{
width: 11px;
height: auto;
margin-right: 7px;
}
.course-item .course-info .teather-info{
font-size: 14px;
color: #9b9b9b;
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: 1px solid #333;
border-bottom-color: rgba(51,51,51,.05);
}
.course-item .course-info .teather-info span{
float: right;
}
.course-item .lesson-list::after{
content:"";
display: block;
clear: both;
}
.course-item .lesson-list li {
float: left;
width: 44%;
font-size: 14px;
color: #666;
padding-left: 22px;
/* background: url("路径") 是否平铺 x轴位置 y轴位置 */
background: url("/static/image/play-icon-gray.svg") no-repeat left 4px;
margin-bottom: 15px;
}
.course-item .lesson-list li .lesson-title{
/* 以下3句,文本内容过多,会自动隐藏,并显示省略符号 */
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
display:inline-block;
max-width: 200px;
}
.course-item .lesson-list li:hover{
background-image: url("/static/image/play-icon-yellow.svg");
color: #ffc210;
}
.course-item .lesson-list li .free{
width: 34px;
height: 20px;
color: #fd7b4d;
vertical-align: super;
margin-left: 10px;
border: 1px solid #fd7b4d;
border-radius: 2px;
text-align: center;
font-size: 13px;
white-space: nowrap;
}
.course-item .lesson-list li:hover .free{
color: #ffc210;
border-color: #ffc210;
}
.course-item .pay-box::after{
content:"";
display: block;
clear: both;
}
.course-item .pay-box .discount-type{
padding: 6px 10px;
font-size: 16px;
color: #fff;
text-align: center;
margin-right: 8px;
background: #fa6240;
border: 1px solid #fa6240;
border-radius: 10px 0 10px 0;
float: left;
}
.course-item .pay-box .discount-price{
font-size: 24px;
color: #fa6240;
float: left;
}
.course-item .pay-box .original-price{
text-decoration: line-through;
font-size: 14px;
color: #9b9b9b;
margin-left: 10px;
float: left;
margin-top: 10px;
}
.course-item .pay-box .buy-now{
width: 120px;
height: 38px;
background: transparent;
color: #fa6240;
font-size: 16px;
border: 1px solid #fd7b4d;
border-radius: 3px;
transition: all .2s ease-in-out;
float: right;
text-align: center;
line-height: 38px;
}
.course-item .pay-box .buy-now:hover{
color: #fff;
background: #ffc210;
border: 1px solid #ffc210;
}
</style>
2. 课程详情页面展示
2.1 初始课程详情页面
Detail.vue
<template>
<div class="detail">
<Vheader/>
<div class="main">
<div class="course-info">
<div class="wrap-left">
</div>
<div class="wrap-right">
<h3 class="course-name">flask</h3>
<p class="data">111人在学 课程总时长:111课时/12小时 难度:</p>
<div class="sale-time">
<p class="sale-type">限时免费</p>
<p class="expire">距离结束:仅剩 01天 04小时 33分 <span class="second">08</span> 秒</p>
</div>
<p class="course-price">
<span>活动价</span>
<span class="discount">¥0.00</span>
<span class="original">¥1111</span>
</p>
<div class="buy">
<div class="buy-btn">
<button class="buy-now">立即购买</button>
<button class="free">免费试学</button>
</div>
<div class="add-cart"><img src="/static/img/cart-yellow.svg" alt="">加入购物车</div>
</div>
</div>
</div>
<div class="course-tab">
<ul class="tab-list">
<li :class="tabIndex==1?'active':''" @click="tabIndex=1">详情介绍</li>
<li :class="tabIndex==2?'active':''" @click="tabIndex=2">课程章节 <span :class="tabIndex!=2?'free':''">(试学)</span></li>
<li :class="tabIndex==3?'active':''" @click="tabIndex=3">用户评论 (42)</li>
<li :class="tabIndex==4?'active':''" @click="tabIndex=4">常见问题</li>
</ul>
</div>
<div class="course-content">
<div class="course-tab-list">
<div class="tab-item" v-if="tabIndex==1">
<div class="course-brief" v-html=""></div>
</div>
<div class="tab-item" v-if="tabIndex==2">
<div class="tab-item-title">
<p class="chapter">课程章节</p>
<p class="chapter-length">共11章 147个课时</p>
</div>
<div class="chapter-item">
<p class="chapter-title"><img src="/static/img/1.png" alt="">第1章·Linux硬件基础</p>
<ul class="lesson-list">
<li class="lesson-item">
<p class="name"><span class="index">1-1</span> 课程介绍-学习流程<span class="free">免费</span></p>
<p class="time">07:30 <img src="/static/img/chapter-player.svg"></p>
<button class="try">立即试学</button>
</li>
<li class="lesson-item">
<p class="name"><span class="index">1-2</span> 服务器硬件-详解<span class="free">免费</span></p>
<p class="time">07:30 <img src="/static/img/chapter-player.svg"></p>
<button class="try">立即试学</button>
</li>
</ul>
</div>
<div class="chapter-item">
<p class="chapter-title"><img src="/static/img/1.png" alt="">第2章·Linux发展过程</p>
<ul class="lesson-list">
<li class="lesson-item">
<p class="name"><span class="index">2-1</span> 操作系统组成-Linux发展过程</p>
<p class="time">07:30 <img src="/static/img/chapter-player.svg"></p>
<button class="try">立即购买</button>
</li>
<li class="lesson-item">
<p class="name"><span class="index">2-2</span> 自由软件-GNU-GPL核心讲解</p>
<p class="time">07:30 <img src="/static/img/chapter-player.svg"></p>
<button class="try">立即购买</button>
</li>
</ul>
</div>
</div>
<div class="tab-item" v-if="tabIndex==3">
用户评论
</div>
<div class="tab-item" v-if="tabIndex==4">
常见问题
</div>
</div>
<div class="course-side">
<div class="teacher-info">
<h4 class="side-title"><span>授课老师</span></h4>
<div class="teacher-content">
<div class="cont1">
<img src="">
<div class="name">
<p class="teacher-name">xxx</p>
<p class="teacher-title">ssss</p>
</div>
</div>
<p class="narrative" >kkkk</p>
</div>
</div>
</div>
</div>
</div>
<Footer/>
</div>
</template>
<script>
import Vheader from "./common/Vheader"
import Footer from "./common/Footer"
export default {
name: "Detail",
data(){
return {
tabIndex:1,
}
},
created(){
},
methods: {
},
components:{
Vheader,
Footer,
}
}
</script>
<style scoped>
.main{
background: #fff;
padding-top: 30px;
}
.course-info{
width: 1200px;
margin: 0 auto;
overflow: hidden;
}
.wrap-left{
float: left;
width: 690px;
height: 388px;
background-color: #000;
}
.wrap-right{
float: left;
position: relative;
height: 388px;
}
.course-name{
font-size: 20px;
color: #333;
padding: 10px 23px;
letter-spacing: .45px;
}
.data{
padding-left: 23px;
padding-right: 23px;
padding-bottom: 16px;
font-size: 14px;
color: #9b9b9b;
}
.sale-time{
width: 464px;
background: #fa6240;
font-size: 14px;
color: #4a4a4a;
padding: 10px 23px;
overflow: hidden;
}
.sale-type {
font-size: 16px;
color: #fff;
letter-spacing: .36px;
float: left;
}
.sale-time .expire{
font-size: 14px;
color: #fff;
float: right;
}
.sale-time .expire .second{
width: 24px;
display: inline-block;
background: #fafafa;
color: #5e5e5e;
padding: 6px 0;
text-align: center;
}
.course-price{
background: #fff;
font-size: 14px;
color: #4a4a4a;
padding: 5px 23px;
}
.discount{
font-size: 26px;
color: #fa6240;
margin-left: 10px;
display: inline-block;
margin-bottom: -5px;
}
.original{
font-size: 14px;
color: #9b9b9b;
margin-left: 10px;
text-decoration: line-through;
}
.buy{
width: 464px;
padding: 0px 23px;
position: absolute;
left: 0;
bottom: 20px;
overflow: hidden;
}
.buy .buy-btn{
float: left;
}
.buy .buy-now{
width: 125px;
height: 40px;
border: 0;
background: #ffc210;
border-radius: 4px;
color: #fff;
cursor: pointer;
margin-right: 15px;
outline: none;
}
.buy .free{
width: 125px;
height: 40px;
border-radius: 4px;
cursor: pointer;
margin-right: 15px;
background: #fff;
color: #ffc210;
border: 1px solid #ffc210;
}
.add-cart{
float: right;
font-size: 14px;
color: #ffc210;
text-align: center;
cursor: pointer;
margin-top: 10px;
}
.add-cart img{
width: 20px;
height: 18px;
margin-right: 7px;
vertical-align: middle;
}
.course-tab{
width: 100%;
background: #fff;
margin-bottom: 30px;
box-shadow: 0 2px 4px 0 #f0f0f0;
}
.course-tab .tab-list{
width: 1200px;
margin: auto;
color: #4a4a4a;
overflow: hidden;
}
.tab-list li{
float: left;
margin-right: 15px;
padding: 26px 20px 16px;
font-size: 17px;
cursor: pointer;
}
.tab-list .active{
color: #ffc210;
border-bottom: 2px solid #ffc210;
}
.tab-list .free{
color: #fb7c55;
}
.course-content{
width: 1200px;
margin: 0 auto;
background: #FAFAFA;
overflow: hidden;
padding-bottom: 40px;
}
.course-tab-list{
width: 880px;
height: auto;
padding: 20px;
background: #fff;
float: left;
box-sizing: border-box;
overflow: hidden;
position: relative;
box-shadow: 0 2px 4px 0 #f0f0f0;
}
.tab-item{
width: 880px;
background: #fff;
padding-bottom: 20px;
box-shadow: 0 2px 4px 0 #f0f0f0;
}
.tab-item-title{
justify-content: space-between;
padding: 25px 20px 11px;
border-radius: 4px;
margin-bottom: 20px;
border-bottom: 1px solid #333;
border-bottom-color: rgba(51,51,51,.05);
overflow: hidden;
}
.chapter{
font-size: 17px;
color: #4a4a4a;
float: left;
}
.chapter-length{
float: right;
font-size: 14px;
color: #9b9b9b;
letter-spacing: .19px;
}
.chapter-title{
font-size: 16px;
color: #4a4a4a;
letter-spacing: .26px;
padding: 12px;
background: #eee;
border-radius: 2px;
display: -ms-flexbox;
display: flex;
-ms-flex-align: center;
align-items: center;
}
.chapter-title img{
width: 18px;
height: 18px;
margin-right: 7px;
vertical-align: middle;
}
.lesson-list{
padding:0 20px;
}
.lesson-list .lesson-item{
padding: 15px 20px 15px 36px;
cursor: pointer;
justify-content: space-between;
position: relative;
overflow: hidden;
}
.lesson-item .name{
font-size: 14px;
color: #666;
float: left;
}
.lesson-item .index{
margin-right: 5px;
}
.lesson-item .free{
font-size: 12px;
color: #fff;
letter-spacing: .19px;
background: #ffc210;
border-radius: 100px;
padding: 1px 9px;
margin-left: 10px;
}
.lesson-item .time{
font-size: 14px;
color: #666;
letter-spacing: .23px;
opacity: 1;
transition: all .15s ease-in-out;
float: right;
}
.lesson-item .time img{
width: 18px;
height: 18px;
margin-left: 15px;
vertical-align: text-bottom;
}
.lesson-item .try{
width: 86px;
height: 28px;
background: #ffc210;
border-radius: 4px;
font-size: 14px;
color: #fff;
position: absolute;
right: 20px;
top: 10px;
opacity: 0;
transition: all .2s ease-in-out;
cursor: pointer;
outline: none;
border: none;
}
.lesson-item:hover{
background: #fcf7ef;
box-shadow: 0 0 0 0 #f3f3f3;
}
.lesson-item:hover .name{
color: #333;
}
.lesson-item:hover .try{
opacity: 1;
}
.course-side{
width: 300px;
height: auto;
margin-left: 20px;
float: right;
}
.teacher-info{
background: #fff;
margin-bottom: 20px;
box-shadow: 0 2px 4px 0 #f0f0f0;
}
.side-title{
font-weight: normal;
font-size: 17px;
color: #4a4a4a;
padding: 18px 14px;
border-bottom: 1px solid #333;
border-bottom-color: rgba(51,51,51,.05);
}
.side-title span{
display: inline-block;
border-left: 2px solid #ffc210;
padding-left: 12px;
}
.teacher-content{
padding: 30px 20px;
box-sizing: border-box;
}
.teacher-content .cont1{
margin-bottom: 12px;
overflow: hidden;
}
.teacher-content .cont1 img{
width: 54px;
height: 54px;
margin-right: 12px;
float: left;
}
.teacher-content .cont1 .name{
float: right;
}
.teacher-content .cont1 .teacher-name{
width: 188px;
font-size: 16px;
color: #4a4a4a;
padding-bottom: 4px;
}
.teacher-content .cont1 .teacher-title{
width: 188px;
font-size: 13px;
color: #9b9b9b;
white-space: nowrap;
}
.teacher-content .narrative{
font-size: 14px;
color: #666;
line-height: 24px;
}
</style>
router/index.js
import Vue from 'vue'
import Router from 'vue-router'
import Home from '@/components/Home'
import Login from '@/components/Login'
import Register from '@/components/Register'
import Course from '@/components/Course'
import Detail from '@/components/Detail'
Vue.use(Router)
export default new Router({
mode : 'history' ,
routes: [
...
{
path: '/course/detail/:id/', // /course/detail/1/ this.$route.params.id 可以捕获后面的id参数
name: '',
component: Detail,
},
],
})
2.2 后端设计
课程详情页面和课程章节分开用两个视图来写
2.2.1 路由
course/urls.py
from django.urls import path,re_path
from . import views
urlpatterns = [
path(r'categorys/',views.CategoryView.as_view()),
path(r'courses/',views.CourseView.as_view()),
re_path(r'detail/(?P<pk>\d+)/', views.CourseDetailView.as_view()),
path(r'chapter/',views.ChapterView.as_view()),
]
2.2.2 视图
course/views.py
from django.shortcuts import render
from rest_framework.generics import ListAPIView,RetrieveAPIView
# Create your views here.
from luffyapi.apps.course.pagenations import StandardPageNumberPagination
from . import models
from .serializers import CourseCategoryModelSerializer, CourseModelSerializer, CourseDetailModelSerializer, \
CourseChapterModelSerializer
# 课程分类
class CategoryView(ListAPIView):
queryset = models.CourseCategory.objects.filter(is_deleted=False,is_show=True)
serializer_class = CourseCategoryModelSerializer
# 课程
class CourseView(ListAPIView):
queryset = models.Course.objects.filter(is_deleted=False,is_show=True).order_by('id')
serializer_class = CourseModelSerializer
filter_fields = ('course_category',)
pagination_class = StandardPageNumberPagination
# 课程详情
class CourseDetailView(RetrieveAPIView):
queryset = models.Course.objects.filter(is_deleted=False,is_show=True)
serializer_class = CourseDetailModelSerializer
# 课程章节
from django_filters.rest_framework import DjangoFilterBackend
class ChapterView(ListAPIView):
queryset = models.CourseChapter.objects.filter(is_show=True,is_deleted=False)
serializer_class = CourseChapterModelSerializer
filter_backends = [DjangoFilterBackend,]
filter_fields = ('course',)
# 按课程名称过滤每个课程对应的章节
2.2.3 序列化器
course/serializers.py
from rest_framework import serializers
from . import models
# 课程分类
class CourseCategoryModelSerializer(serializers.ModelSerializer):
class Meta:
model = models.CourseCategory
fields = ['id','name']
# 老师
class TeacherModelSerializer(serializers.ModelSerializer):
class Meta:
model = models.Teacher
fields = ['name','role','title','signature']
# 课程信息
class CourseModelSerializer(serializers.ModelSerializer):
# teacher_name = serializers.CharField(source='teacher.name') #自定义字段,通过source关键字就能获取外键关联的指定字段数据,别忘了在fields指定一下
teacher = TeacherModelSerializer() # 课程表和老师表为多对一的关系,当课程表序列化器嵌套老师表序列化器时,不需要加上many=True,实例化的嵌套序列化器对象的变量名称必须为外键属性名称,就能拿到关联表序列化出来的所有数据,还需要在fields中指定一下,注意,名称必须要和外键属性名称相同
class Meta:
model = models.Course
# fields =["id","name","course_img","students","lessons","pub_lessons","price","teacher","teacher_name"] # teacher外键属性默认拿的是id值
fields =["id","name","course_img","students","lessons","pub_lessons","price","teacher","get_lessons"] # teacher外键属性默认拿的是id值
class CourseDetailModelSerializer(serializers.ModelSerializer):
#
teacher = TeacherModelSerializer()
class Meta:
model = models.Course
fields = ["id", "name", "course_img", "students", "lessons", "pub_lessons", "price", "teacher","level_name", "course_video","new_brief"]
class CourseLessonModelSerializer(serializers.ModelSerializer):
class Meta:
model = models.CourseLesson
fields = ['name','section_link','duration','free_trail','lesson']
class CourseChapterModelSerializer(serializers.ModelSerializer):
# 课程章节表和课程课时表为一对多的关系,所以当课程章节表序列化器嵌套课程课时表序列化器时,需要加上many=True,实例化的嵌套序列化器对象的变量名必须使用外键关联时指定的related_name对应的值
coursesections = CourseLessonModelSerializer(many=True)
class Meta:
model = models.CourseChapter
fields = ['chapter','name','coursesections']
注意:
A表和B表为一对多的关系
A表序列化器嵌套B表序列化器时, 需要加上many=True,实例化的嵌套序列化器对象名称必须 使用外键关联时指定的related_name对应的值
B表序列化器嵌套A表序列化器时,不需要加many=True,实例化的嵌套序列化器对象的变量名称必须为外键属性名称
2.2.4 模型Models
course/models.py
from django.db import models
from luffyapi.settings import contains
# Create your models here.
from django.db import models
from luffyapi.utils.models import BaseModel
# Create your models here.
class CourseCategory(BaseModel):
"""
课程分类
"""
name = models.CharField(max_length=64, unique=True, verbose_name="分类名称")
class Meta:
db_table = "ly_course_category"
verbose_name = "课程分类"
verbose_name_plural = "课程分类"
def __str__(self):
return "%s" % self.name
from ckeditor_uploader.fields import RichTextUploadingField
class Course(BaseModel):
"""
专题课程
"""
course_type = (
(0, '付费'),
(1, 'VIP专享'),
(2, '学位课程')
)
level_choices = (
(0, '初级'),
(1, '中级'),
(2, '高级'),
)
status_choices = (
(0, '上线'),
(1, '下线'),
(2, '预上线'),
)
name = models.CharField(max_length=128, verbose_name="课程名称")
course_img = models.ImageField(upload_to="course", max_length=255, verbose_name="封面图片", blank=True, null=True)
course_video = models.FileField(upload_to='video',verbose_name='封面video',blank=True,null=True,max_length=255)
# 费用类型字段是为了后期一些其他功能拓展用的,现在可以先不用,或者去掉它,目前我们项目用不到
course_type = models.SmallIntegerField(choices=course_type, default=0, verbose_name="付费类型")
# 这个字段是课程详情页里面展示的,并且详情介绍里面用户将来可能要上传一些图片之类的,所以我们会嵌入富文本编辑器,让用户填写数据的时候可以上传图片啊、写标题啊、css、html等等内容
brief = RichTextUploadingField(max_length=2048, verbose_name="详情介绍", null=True, blank=True)
level = models.SmallIntegerField(choices=level_choices, default=1, verbose_name="难度等级")
pub_date = models.DateField(verbose_name="发布日期", auto_now_add=True)
period = models.IntegerField(verbose_name="建议学习周期(day)", default=7)
# 课件资料的存放路径
attachment_path = models.FileField(max_length=128, verbose_name="课件路径", blank=True, null=True)
status = models.SmallIntegerField(choices=status_choices, default=0, verbose_name="课程状态")
course_category = models.ForeignKey("CourseCategory", on_delete=models.CASCADE, null=True, blank=True,
verbose_name="课程分类")
students = models.IntegerField(verbose_name="学习人数", default=0)
lessons = models.IntegerField(verbose_name="总课时数量", default=0)
# 总课时数量可能10个,但是目前之更新了3个,就跟小说、电视剧连载似的。
pub_lessons = models.IntegerField(verbose_name="课时更新数量", default=0)
# 课程原价
price = models.DecimalField(max_digits=6, decimal_places=2, verbose_name="课程原价", default=0)
teacher = models.ForeignKey("Teacher", on_delete=models.DO_NOTHING, null=True, blank=True, verbose_name="授课老师")
class Meta:
db_table = "ly_course"
verbose_name = "专题课程"
verbose_name_plural = "专题课程"
def __str__(self):
return "%s" % self.name
# 通过课程对象获取所有课程列表页中要展示的课时信息
def get_lessons(self):
chapters = self.coursechapters.all()
lession_list = [] # 课时信息列表
for chapter in chapters:
lessons = chapter.coursesections.filter(is_show_list=True,is_show=True,is_deleted=False)
for lesson in lessons:
lession_list.append({
'name':lesson.name,
'free_trail':lesson.free_trail,
'lesson':lesson.lesson,
})
return lession_list[:4]
def level_name(self):
return self.get_level_display()
def new_brief(self):
data = self.brief
server_addr = contains.SERVER_ADDR
data = data.replace('src="/media',f'class="img_xx" src="{server_addr}/media')
return data
class Teacher(BaseModel):
"""讲师、导师表"""
role_choices = (
(0, '讲师'),
(1, '导师'),
(2, '班主任'),
)
name = models.CharField(max_length=32, verbose_name="讲师title")
role = models.SmallIntegerField(choices=role_choices, default=0, verbose_name="讲师身份")
title = models.CharField(max_length=64, verbose_name="职位、职称")
signature = models.CharField(max_length=255, verbose_name="导师签名", help_text="导师签名", blank=True, null=True)
image = models.ImageField(upload_to="teacher", null=True, verbose_name="讲师封面")
brief = models.TextField(max_length=1024, verbose_name="讲师描述")
class Meta:
db_table = "ly_teacher"
verbose_name = "讲师导师"
verbose_name_plural = "讲师导师"
def __str__(self):
return "%s" % self.name
class CourseChapter(BaseModel):
"""课程章节"""
course = models.ForeignKey("Course", related_name='coursechapters', on_delete=models.CASCADE, verbose_name="课程名称")
chapter = models.SmallIntegerField(verbose_name="第几章", default=1)
name = models.CharField(max_length=128, verbose_name="章节标题")
summary = models.TextField(verbose_name="章节介绍", blank=True, null=True)
pub_date = models.DateField(verbose_name="发布日期", auto_now_add=True)
class Meta:
db_table = "ly_course_chapter"
verbose_name = "课程章节"
verbose_name_plural = "课程章节"
def __str__(self):
return "%s:(第%s章)%s" % (self.course, self.chapter, self.name)
class CourseLesson(BaseModel):
"""课程课时"""
section_type_choices = (
(0, '文档'),
(1, '练习'),
(2, '视频')
)
chapter = models.ForeignKey("CourseChapter", related_name='coursesections', on_delete=models.CASCADE,
verbose_name="课程章节")
name = models.CharField(max_length=128, verbose_name="课时标题")
# orders = models.PositiveSmallIntegerField(verbose_name="课时排序") #在basemodel里面已经有了排序了
section_type = models.SmallIntegerField(default=2, choices=section_type_choices, verbose_name="课时种类")
section_link = models.CharField(max_length=255, blank=True, null=True, verbose_name="课时链接",
help_text="若是video,填vid,若是文档,填link")
duration = models.CharField(verbose_name="视频时长", blank=True, null=True,
max_length=32) # 仅在前端展示使用,所以直接让上传视频的用户直接填写时长进来就可以了。
pub_date = models.DateTimeField(verbose_name="发布时间", auto_now_add=True)
free_trail = models.BooleanField(verbose_name="是否可试看", default=False)
course = models.ForeignKey('Course', related_name='course_lesson', verbose_name='课程', on_delete=models.CASCADE,
null=True, blank=True)
is_show_list = models.BooleanField(verbose_name='是否推荐到课程列表', default=False)
lesson = models.IntegerField(verbose_name="第几课时")
class Meta:
db_table = "ly_course_lesson"
verbose_name = "课程课时"
verbose_name_plural = "课程课时"
def __str__(self):
return "%s-%s" % (self.chapter, self.name)
3. CKEditor富文本编辑器
富文本即具备丰富样式格式的文本,在运营后台,运营人员需要录入课程的相关描述,可以是包含了HTML语法格式的字符串,也可以是图片信息。
3.1 安装
pip3 install django-ckeditor
3.2 添加应用
settings/dev.py
INSTALLED_APPS = [
...
'ckeditor', # 富文本编辑器
'ckeditor_uploader', # 富文本编辑器上传图片模块
...
]
# 富文本编辑器ckeditor配置
CKEDITOR_CONFIGS = {
'default': {
'toolbar': 'full', # 工具条功能,full表示全部,Basic表示基本功能,功能少很多,还有个Custom自定义功能选项
'height': 300, # 编辑器高度
# 'width': 300, # 编辑器宽
},
}
CKEDITOR_UPLOAD_PATH = '' # 上传图片保存路径,留空则调用django的文件上传功能
#自定义
CKEDITOR_CONFIGS = {
'default': {
'toolbar': 'Custom',
'toolbar_Custom': [
['Bold', 'Italic', 'Underline','Image'], #通过浏览器f12来查看每个功能的标签,就看到了类值cke_button_工具名称[注意使用驼峰式来写]
['NumberedList', 'BulletedList', '-', 'Outdent', 'Indent', '-', 'JustifyLeft', 'JustifyCenter', 'JustifyRight', 'JustifyBlock'],
['Link', 'Unlink'],
['RemoveFormat', 'Source']
]
}
}
3.3 添加ckeditor
path(r'^ckeditor/', include('ckeditor_uploader.urls')),
3.4 为模型类添加字段
ckeditor提供了两两种类型的Django模型类字段
ckeditor.fields.RichTextField
不支持上传文件的富文本字段ckeitor_uploader.fields.RichTextUploadingField
支持上传文件的富文本字段
修改course/models.py里面的字段信息
from ckeditor_uploader.fields import RichTextUploadingField
class Course(models.Model):
"""
专题课程
"""
...
brief = RichTextUploadingField(max_length=2048, verbose_name="课程概述", null=True, blank=True)
python3 manage.py makemigrations
python3 manage.py migrate
4. Vue-video
视频播放组件
4.1 安装
npm install vue-video-player --save
4.2 配置
main.js中注册加载组件
require('video.js/dist/video-js.css');
require('vue-video-player/src/custom-theme.css');
import VideoPlayer from 'vue-video-player'
Vue.use(VideoPlayer);
Detail.vue组件
<script>
import Vheader from "./common/Vheader"
import Footer from "./common/Footer"
import {videoPlayer} from 'vue-video-player';
export default {
name: "Detail",
data(){
return {
tabIndex:1,
course_id:0,
course_data:{
teacher:{}
},
chapter_data:{},
playerOptions: {
playbackRates: [0.7, 1.0, 1.5, 2.0], // 播放速度
autoplay: false, //如果true,则自动播放
muted: false, // 默认情况下将会消除任何音频。
loop: false, // 循环播放
preload: 'auto', // 建议浏览器在<video>加载元素后是否应该开始下载视频数据。auto浏览器选择最佳行为,立即开始加载视频(如果浏览器支持)
language: 'zh-CN',
aspectRatio: '16:9', // 将播放器置于流畅模式,并在计算播放器的动态大小时使用该值。值应该代表一个比例 - 用冒号分隔的两个数字(例如"16:9"或"4:3")
fluid: true, // 当true时,Video.js player将拥有流体大小。换句话说,它将按比例缩放以适应其容器。
sources: [{ // 播放资源和资源格式
type: "video/mp4",
src: "" //你的视频地址(必填)
}],
poster: "", //视频封面图
width: document.documentElement.clientWidth, // 默认视频全屏时的最大宽度
notSupportedMessage: '此视频暂无法播放,请稍后再试', //允许覆盖Video.js无法播放媒体源时显示的默认信息。
}
}
},
methods: {
onPlayerPlay(e){
alert('kai shi');
},
onPlayerPause(e){
alert('zan ting');
},
},
components:{
Vheader,
Footer,
videoPlayer, //别忘了挂载
}
}
</script>
后端
课程详情页中的视频是一个封面视频,给course表添加一个封面视频字段
course_video = models.FileField(upload_to='video',null=True,blank=True,verbose_name='封面视频')
5. 购物车实现
5.1 创建应用并注册
创建子应用cart
python3 ../../manage.py startapp cart
注册应用
INSTALLED_APPS = [
...
'cart',
]
因为购物车中的商品信息经常会被用户操作,为了减轻mysql服务器的压力,可以选择把购物车信息通过redis来存储
settings/dev.py 配置如下
# 设置redis缓存
CACHES = {
# 默认缓存
....
"cart":{
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/3",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
}
},
}
商品的存储格式
购物车商品信息格式:
商品数量[因为目前路飞学城的商品是视频,所以没有数量限制,如果以后做到真实商品,则必须有数量]
商品id
用户id
课程有效期【一个课程有多个有效期选项,要记录用户勾选哪一个?】
商品勾选状态【是否要结算】
五种数据类型
string字符串
键:值
hash哈希字典
键:{
域:值,
}
user_id:{
1:course_expire
2:course_expire
3:course_expire
}
user_id_selected:{1,2,3,4}
list列表
键:[值1,值2,....]
set集合
键:{值1,值2,....}
zset有序集合
键:{
权重值1:值,
权重值2:值,
}
经过比较可以发现没有一种数据类型,可以同时存储4个字段数据的,所以我们才有2种数据结构来保存购物车数据
可以发现,上面5种数据类型中,哈希hash可以存储的数据量是最多的。
hash:
键[用户ID]:{
域[商品ID]:值[课程有效期],
域[商品ID]:值[课程有效期],
域[商品ID]:值[课程有效期],
域[商品ID]:值[课程有效期],
}
set:
键[用户ID]:{商品ID1,商品ID2....}
5.2 后端接口实现
5.2.1 访问路由
总路由
path(r'cart/',include('cart.urls')),
子应用路由cart/urls.py
from django.urls import path,re_path
from . import views
urlpatterns = [
path('add_cart/', views.AddCartView.as_view({'post':'add'}))
]
为了保证系统的日志记录可以跟进redis部分,我们还可以在之前定义异常处理中增加关于redis的异常捕获
utils.exceptions.py
from rest_framework.views import exception_handler
from django.db import DatabaseError
from redis import RedisError
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) or isinstance(exc, RedisError):
# 数据库异常
logger.error('[%s] %s' % (view, exc))
response = Response({'message': '服务器内部错误'}, status=status.HTTP_507_INSUFFICIENT_STORAGE)
return response
5.2.2 视图view
cart/views.py
from django.shortcuts import render
from rest_framework.viewsets import ViewSet
# Create your views here.
from django_redis import get_redis_connection
from course import models
from rest_framework.response import Response
from rest_framework import status
from luffyapi.settings import contains
import logging
logger = logging.getLogger('django')
class AddCartView(ViewSet):
def add(self,request):
course_id = request.data.get('course_id')
user_id = 1
expire = 0 # 表示永久有效
conn = get_redis_connection('cart')
try:
models.Course.objects.get(id=course_id)
except:
return Response({'msg':'课程不存在'},status=status.HTTP_400_BAD_REQUEST)
pipe = conn.pipeline() # 创建管道
pipe.multi()
# 哈希字典类型 批量操作
pipe.hset('cart_%s' % user_id, course_id, expire)
pipe.execute()
# 集合类型 set
# conn.sadd('cart_%s'%user_id,course_id)
# cart_length = conn.scard('cart_%s'% user_id)
cart_length = conn.hlen('cart_%s' % user_id)
print('cart_length',cart_length)
return Response({'msg':'添加成功','cart_length':cart_length})
5.3 前端访问后端接口添加购物车
Detail.vue
methods: {
addCart(){
// 校验是否登录
let token = localStorage.token || sessionStorage.token;
if (token){
this.$axios.post(`${this.$settings.Host}/users/verify/`,{
token:token,
}).then((res)=>{
this.$axios.post(`${this.$settings.Host}/cart/add_cart/`,{
course_id:this.course_id,
}).then((res)=>{
this.$message.success(res.data.msg);
this.$store.commit('add_cart',res.data.cart_length);
console.log(this.$store.state);
})
}).catch((error)=>{
this.$confirm('你还没有登录','31s',{
confirmButtonText:'去登录',
cancelButtonText:'取消',
type:'warning'
}).then(()=>{
this.$router.push('/user/login');
});
sessionStorage.removeItem('token');
sessionStorage.removeItem('username');
sessionStorage.removeItem('id');
localStorage.removeItem('token');
localStorage.removeItem('username');
localStorage.removeItem('id');
})
}else{
this.$confirm('你还没有登录','31s',{
confirmButtonText:'去登录',
cancelButtonText:'取消',
type:'warning'
}).then(()=>{
this.$router.push('/user/login');
})
}
},
Login.vue(返回上一页实现)
methods:{
loginHandle(){
var captcha1 = new TencentCaptcha('2049354688', (res)=>{
if (res.ret === 0 ){
this.$axios.post(`${this.$settings.Host}/users/login/`,{
username : this.username,
password : this.password,
ticket:res.ticket,
randstr:res.randstr,
}).then((res)=>{
console.log(res);
console.log(this.remember);
if (this.remember){
localStorage.token = res.data.token;
localStorage.username = res.data.username;
localStorage.id = res.data.id;
sessionStorage.removeItem('token');
sessionStorage.removeItem('username');
sessionStorage.removeItem('id');
}else{
sessionStorage.token = res.data.token;
sessionStorage.username = res.data.username;
sessionStorage.id = res.data.id;
localStorage.removeItem('token');
localStorage.removeItem('username');
localStorage.removeItem('id');
}
this.$confirm('下一步想去哪消费!', '提示', {
confirmButtonText: '去首页',
cancelButtonText: '回到上一页',
type: 'success'
}).then(() => {
this.$router.push('/');
}).catch(() => {
this.$router.go(-1); //回到上一页
});
}).catch((error)=>{
this.$alert('用户名或密码错误', 'error msg', {
confirmButtonText: '确定',
});
})
}
});
captcha1.show();
}
},
5.4 展示购物车数量
获取商品总数是在vheader组件中使用到,但是我们可以在购物车中或者商品课程的详情页中修改购物车中商品总数,因为对于一些数据,需要在多个组件中即时共享,我们可以使用本地存储来完成,也可以通过vuex
组件来完成
5.4.1 安装vuex
npm install -S vuex
把vuex注册到vue中
在src目录下创建store目录,并在store目录下创建一个index.js文件
index.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex);
export default new Vuex.Store({ //别忘了抛出对象
// 数据仓库,类似vue组件里面的data
state: {
},
// 数据操作方法,类似vue里面的methods
mutations: {
}
});
把上面index.js中创建的store对象注册到main.js的vue中
new Vue({
el: '#app',
router,
store,
components: { App },
template: '<App/>'
})
在页面中需要使用的地方直接使用
{{$store.state.cart_length}}
index.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex);
export default new Vuex.Store({ //别忘了抛出对象
// 数据仓库,类似vue组件里面的data
state: {
cart_length:0,
},
// 数据操作方法,类似vue里面的methods
mutations: {
add_cart(state,cart_length){
state.cart_length = cart_length;
}
}
});
注意: 由于vuex的数据是保存在内存中的,刷新页面的时候会导致vuex中的所有属性重新加载,导致数量丢失
解决方法
App.vue
<template>
<div id="app">
<router-view/>
</div>
</template>
<script>
export default {
name: 'App',
created(){
window.addEventListener('beforeunload',()=>{
console.log('页面要刷新啦!!!,赶紧保存数据!!!!');
//sessionStorage.setItem('cart_length',this.$store.state.cart_length);
localStorage.setItem('cart_length',this.$store.state.cart_length);
});
},
}
</script>
<style>
</style>
5.5 Detail完整页面
<template>
<div class="detail">
<Vheader/>
<div class="main">
<div class="course-info">
<div class="wrap-left">
<videoPlayer class="video-player vjs-custom-skin"
ref="videoPlayer"
:playsinline="true"
:options="playerOptions"
@play="onPlayerPlay($event)"
@pause="onPlayerPause($event)">
</videoPlayer>
</div>
<div class="wrap-right">
<h3 class="course-name">{{course_data.name}}</h3>
<p class="data">{{course_data.students}}人在学 课程总时长:{{course_data.lessons}}课时 难度:{{course_data.level_name}}</p>
<div class="sale-time" v-if="course_data.discount_name">
<p class="sale-type">{{course_data.discount_name}}</p>
<p class="expire">距离结束:仅剩 {{course_data.left_time/60/60/24 | pInt}}天 {{course_data.left_time/60/60 %24 | pInt}}小时 {{course_data.left_time/60 % 60 | pInt}}分 <span class="second">{{course_data.left_time %60 | pInt}}</span> 秒</p>
</div>
<p class="course-price">
<span>活动价</span>
<span class="discount">¥{{course_data.real_price}}</span>
<span class="original" v-if="course_data.discount_name">{{course_data.price}}</span>
</p>
<div class="buy">
<div class="buy-btn">
<button class="buy-now">立即购买</button>
<button class="free">免费试学</button>
</div>
<div class="add-cart" @click="addCart"><img src="/static/img/cart-yellow.svg" alt="">加入购物车</div>
</div>
</div>
</div>
<div class="course-tab">
<ul class="tab-list">
<li :class="tabIndex==1?'active':''" @click="tabIndex=1">详情介绍</li>
<li :class="tabIndex==2?'active':''" @click="tabIndex=2">课程章节 <span :class="tabIndex!=2?'free':''">(试学)</span></li>
<li :class="tabIndex==3?'active':''" @click="tabIndex=3">用户评论 (42)</li>
<li :class="tabIndex==4?'active':''" @click="tabIndex=4">常见问题</li>
</ul>
</div>
<div class="course-content">
<div class="course-tab-list">
<div class="tab-item" v-if="tabIndex==1">
<div class="course-brief" v-html="course_data.new_brief"></div>
</div>
<div class="tab-item" v-if="tabIndex==2">
<div class="tab-item-title">
<p class="chapter">课程章节</p>
<p class="chapter-length">共{{chapter_data.length}}章 </p>
</div>
<div class="chapter-item" v-for="(chapter,chapterindex) in chapter_data" :key="chapterindex">
<p class="chapter-title"><img src="/static/img/1.png" alt="">第{{chapter.chapter}}章 {{chapter.name}}</p>
<ul class="lesson-list">
<li class="lesson-item" v-for="(lesson,lessonindex) in chapter.coursesections" :key="lessonindex">
<p class="name"><span class="index">{{chapter.chapter}}-{{lesson.lesson}}</span> 课程介绍-{{lesson.name}}<span v-show="lesson.free_trail" class="free">免费</span></p>
<p class="time">{{lesson.duration}} <img src="/static/img/chapter-player.svg"></p>
<button class="try" v-if="lesson.free_trail">立即试学</button>
<button class="try" v-else>立即购买</button>
</li>
</ul>
</div>
</div>
<div class="tab-item" v-if="tabIndex==3">
用户评论
</div>
<div class="tab-item" v-if="tabIndex==4">
常见问题
</div>
</div>
<div class="course-side">
<div class="teacher-info">
<h4 class="side-title"><span>授课老师</span></h4>
<div class="teacher-content">
<div class="cont1">
<img src="">
<div class="name">
<p class="teacher-name">{{course_data.teacher.name}}</p>
<p class="teacher-title">{{course_data.teacher.title}}</p>
</div>
</div>
<p class="narrative" >{{course_data.teacher.signature}}</p>
</div>
</div>
</div>
</div>
</div>
<Footer/>
</div>
</template>
<script>
import Vheader from "./common/Vheader"
import Footer from "./common/Footer"
import {videoPlayer} from 'vue-video-player'
export default {
name: "Detail",
data(){
return {
tabIndex:1,
course_id:0,
course_data:{
teacher:{}
},
chapter_data:{},
playerOptions: {
playbackRates: [0.7, 1.0, 1.5, 2.0], // 播放速度
autoplay: false, //如果true,则自动播放
muted: false, // 默认情况下将会消除任何音频。
loop: false, // 循环播放
preload: 'auto', // 建议浏览器在<video>加载元素后是否应该开始下载视频数据。auto浏览器选择最佳行为,立即开始加载视频(如果浏览器支持)
language: 'zh-CN',
aspectRatio: '16:9', // 将播放器置于流畅模式,并在计算播放器的动态大小时使用该值。值应该代表一个比例 - 用冒号分隔的两个数字(例如"16:9"或"4:3")
fluid: true, // 当true时,Video.js player将拥有流体大小。换句话说,它将按比例缩放以适应其容器。
sources: [{ // 播放资源和资源格式
type: "video/mp4",
src: "" //你的视频地址(必填)
}],
poster: "", //视频封面图
width: document.documentElement.clientWidth, // 默认视频全屏时的最大宽度
notSupportedMessage: '此视频暂无法播放,请稍后再试', //允许覆盖Video.js无法播放媒体源时显示的默认信息。
},
}
},
filters:{
pInt(val){
let a = parseInt(val);
if (a < 10){
a = `0${a}`;
}
return a
}
},
created(){
this.get_course_id();
this.get_course_data();
this.get_chapter_data();
},
methods: {
addCart(){
let token = localStorage.token || sessionStorage.token;
if (token){
this.$axios.post(`${this.$settings.Host}/users/verify/`,{
token:token,
}).then((res)=>{
this.$axios.post(`${this.$settings.Host}/cart/add_cart/`,{
course_id:this.course_id,
},{
headers:{
'Authorization':'jwt ' + token
}
}).then((res)=>{
this.$message.success(res.data.msg);
this.$store.commit('add_cart',res.data.cart_length,);
console.log(this.$store.state);
console.log(token)
})
}).catch((error)=>{
this.$confirm('你还没有登录','31s',{
confirmButtonText:'去登录',
cancelButtonText:'取消',
type:'warning'
}).then(()=>{
this.$router.push('/user/login');
});
sessionStorage.removeItem('token');
sessionStorage.removeItem('username');
sessionStorage.removeItem('id');
localStorage.removeItem('token');
localStorage.removeItem('username');
localStorage.removeItem('id');
})
}else{
this.$confirm('你还没有登录','31s',{
confirmButtonText:'去登录',
cancelButtonText:'取消',
type:'warning'
}).then(()=>{
this.$router.push('/user/login');
})
}
},
get_course_id(){
this.course_id = this.$route.params.id;
// 可以判断id的合法性
},
get_course_data(){
this.$axios.get(`${this.$settings.Host}/course/detail/${this.course_id}/`)
.then((res)=>{
console.log(res.data);
this.course_data = res.data;
this.playerOptions.sources[0].src=res.data.course_video;
this.playerOptions.poster=res.data.course_img;
setInterval(()=>{
this.course_data.left_time--
},1000)
})
},
get_chapter_data(){
this.$axios.get(`${this.$settings.Host}/course/chapter/`,{
params:{
course:this.course_id,
}
})
.then((res)=>{
console.log(res.data);
this.chapter_data = res.data;
})
},
onPlayerPlay(e){
alert('开始');
},
onPlayerPause(e){
alert('暂停');
},
},
components:{
Vheader,
Footer,
videoPlayer,
}
}
</script>
<style scoped>
.main{
background: #fff;
padding-top: 30px;
}
.course-info{
width: 1200px;
margin: 0 auto;
overflow: hidden;
}
.wrap-left{
float: left;
width: 690px;
height: 388px;
background-color: #000;
}
.wrap-right{
float: left;
position: relative;
height: 388px;
}
.course-name{
font-size: 20px;
color: #333;
padding: 10px 23px;
letter-spacing: .45px;
}
.data{
padding-left: 23px;
padding-right: 23px;
padding-bottom: 16px;
font-size: 14px;
color: #9b9b9b;
}
.sale-time{
width: 464px;
background: #fa6240;
font-size: 14px;
color: #4a4a4a;
padding: 10px 23px;
overflow: hidden;
}
.sale-type {
font-size: 16px;
color: #fff;
letter-spacing: .36px;
float: left;
}
.sale-time .expire{
font-size: 14px;
color: #fff;
float: right;
}
.sale-time .expire .second{
width: 24px;
display: inline-block;
background: #fafafa;
color: #5e5e5e;
padding: 6px 0;
text-align: center;
}
.course-price{
background: #fff;
font-size: 14px;
color: #4a4a4a;
padding: 5px 23px;
}
.discount{
font-size: 26px;
color: #fa6240;
margin-left: 10px;
display: inline-block;
margin-bottom: -5px;
}
.original{
font-size: 14px;
color: #9b9b9b;
margin-left: 10px;
text-decoration: line-through;
}
.buy{
width: 464px;
padding: 0px 23px;
position: absolute;
left: 0;
bottom: 20px;
overflow: hidden;
}
.buy .buy-btn{
float: left;
}
.buy .buy-now{
width: 125px;
height: 40px;
border: 0;
background: #ffc210;
border-radius: 4px;
color: #fff;
cursor: pointer;
margin-right: 15px;
outline: none;
}
.buy .free{
width: 125px;
height: 40px;
border-radius: 4px;
cursor: pointer;
margin-right: 15px;
background: #fff;
color: #ffc210;
border: 1px solid #ffc210;
}
.add-cart{
float: right;
font-size: 14px;
color: #ffc210;
text-align: center;
cursor: pointer;
margin-top: 10px;
}
.add-cart img{
width: 20px;
height: 18px;
margin-right: 7px;
vertical-align: middle;
}
.course-tab{
width: 100%;
background: #fff;
margin-bottom: 30px;
box-shadow: 0 2px 4px 0 #f0f0f0;
}
.course-tab .tab-list{
width: 1200px;
margin: auto;
color: #4a4a4a;
overflow: hidden;
}
.tab-list li{
float: left;
margin-right: 15px;
padding: 26px 20px 16px;
font-size: 17px;
cursor: pointer;
}
.tab-list .active{
color: #ffc210;
border-bottom: 2px solid #ffc210;
}
.tab-list .free{
color: #fb7c55;
}
.course-content{
width: 1200px;
margin: 0 auto;
background: #FAFAFA;
overflow: hidden;
padding-bottom: 40px;
}
.course-tab-list{
width: 880px;
height: auto;
padding: 20px;
background: #fff;
float: left;
box-sizing: border-box;
overflow: hidden;
position: relative;
box-shadow: 0 2px 4px 0 #f0f0f0;
}
.tab-item{
width: 880px;
background: #fff;
padding-bottom: 20px;
box-shadow: 0 2px 4px 0 #f0f0f0;
}
.tab-item-title{
justify-content: space-between;
padding: 25px 20px 11px;
border-radius: 4px;
margin-bottom: 20px;
border-bottom: 1px solid #333;
border-bottom-color: rgba(51,51,51,.05);
overflow: hidden;
}
.chapter{
font-size: 17px;
color: #4a4a4a;
float: left;
}
.chapter-length{
float: right;
font-size: 14px;
color: #9b9b9b;
letter-spacing: .19px;
}
.chapter-title{
font-size: 16px;
color: #4a4a4a;
letter-spacing: .26px;
padding: 12px;
background: #eee;
border-radius: 2px;
display: -ms-flexbox;
display: flex;
-ms-flex-align: center;
align-items: center;
}
.chapter-title img{
width: 18px;
height: 18px;
margin-right: 7px;
vertical-align: middle;
}
.lesson-list{
padding:0 20px;
}
.lesson-list .lesson-item{
padding: 15px 20px 15px 36px;
cursor: pointer;
justify-content: space-between;
position: relative;
overflow: hidden;
}
.lesson-item .name{
font-size: 14px;
color: #666;
float: left;
}
.lesson-item .index{
margin-right: 5px;
}
.lesson-item .free{
font-size: 12px;
color: #fff;
letter-spacing: .19px;
background: #ffc210;
border-radius: 100px;
padding: 1px 9px;
margin-left: 10px;
}
.lesson-item .time{
font-size: 14px;
color: #666;
letter-spacing: .23px;
opacity: 1;
transition: all .15s ease-in-out;
float: right;
}
.lesson-item .time img{
width: 18px;
height: 18px;
margin-left: 15px;
vertical-align: text-bottom;
}
.lesson-item .try{
width: 86px;
height: 28px;
background: #ffc210;
border-radius: 4px;
font-size: 14px;
color: #fff;
position: absolute;
right: 20px;
top: 10px;
opacity: 0;
transition: all .2s ease-in-out;
cursor: pointer;
outline: none;
border: none;
}
.lesson-item:hover{
background: #fcf7ef;
box-shadow: 0 0 0 0 #f3f3f3;
}
.lesson-item:hover .name{
color: #333;
}
.lesson-item:hover .try{
opacity: 1;
}
.course-side{
width: 300px;
height: auto;
margin-left: 20px;
float: right;
}
.teacher-info{
background: #fff;
margin-bottom: 20px;
box-shadow: 0 2px 4px 0 #f0f0f0;
}
.side-title{
font-weight: normal;
font-size: 17px;
color: #4a4a4a;
padding: 18px 14px;
border-bottom: 1px solid #333;
border-bottom-color: rgba(51,51,51,.05);
}
.side-title span{
display: inline-block;
border-left: 2px solid #ffc210;
padding-left: 12px;
}
.teacher-content{
padding: 30px 20px;
box-sizing: border-box;
}
.teacher-content .cont1{
margin-bottom: 12px;
overflow: hidden;
}
.teacher-content .cont1 img{
width: 54px;
height: 54px;
margin-right: 12px;
float: left;
}
.teacher-content .cont1 .name{
float: right;
}
.teacher-content .cont1 .teacher-name{
width: 188px;
font-size: 16px;
color: #4a4a4a;
padding-bottom: 4px;
}
.teacher-content .cont1 .teacher-title{
width: 188px;
font-size: 13px;
color: #9b9b9b;
white-space: nowrap;
}
.teacher-content .narrative{
font-size: 14px;
color: #666;
line-height: 24px;
}
</style>
5.6 Cart.vue初始化页面
<template>
<div class="cart">
<Vheader></Vheader>
<div class="cart_info">
<div class="cart_title">
<span class="text">我的购物车</span>
<span class="total">共4门课程</span>
</div>
<div class="cart_table">
<div class="cart_head_row">
<span class="doing_row"></span>
<span class="course_row">课程</span>
<span class="expire_row">有效期</span>
<span class="price_row">单价</span>
<span class="do_more">操作</span>
</div>
<div class="cart_course_list">
<CartItem></CartItem>
<CartItem></CartItem>
<CartItem></CartItem>
<CartItem></CartItem>
</div>
<div class="cart_footer_row">
<span class="cart_select"><label> <el-checkbox v-model="checked"></el-checkbox><span>全选</span></label></span>
<span class="cart_delete"><i class="el-icon-delete"></i> <span>删除</span></span>
<span class="goto_pay">去结算</span>
<span class="cart_total">总计:¥0.0</span>
</div>
</div>
</div>
<Footer></Footer>
</div>
</template>
<script>
import Vheader from "./common/Vheader"
import Footer from "./common/Footer"
import CartItem from "./common/CartItem"
export default {
name: "Cart",
data(){
return {
checked: false,
}
},
methods:{
},
components:{
Vheader,
Footer,
CartItem,
}
}
</script>
<style scoped>
.cart_info{
width: 1200px;
margin: 0 auto 50px;
}
.cart_title{
margin: 25px 0;
}
.cart_title .text{
font-size: 18px;
color: #666;
}
.cart_title .total{
font-size: 12px;
color: #d0d0d0;
}
.cart_table{
width: 1170px;
}
.cart_table .cart_head_row{
background: #F7F7F7;
width: 100%;
height: 80px;
line-height: 80px;
padding-right: 30px;
}
.cart_table .cart_head_row::after{
content: "";
display: block;
clear: both;
}
.cart_table .cart_head_row .doing_row,
.cart_table .cart_head_row .course_row,
.cart_table .cart_head_row .expire_row,
.cart_table .cart_head_row .price_row,
.cart_table .cart_head_row .do_more{
padding-left: 10px;
height: 80px;
float: left;
}
.cart_table .cart_head_row .doing_row{
width: 78px;
}
.cart_table .cart_head_row .course_row{
width: 530px;
}
.cart_table .cart_head_row .expire_row{
width: 188px;
}
.cart_table .cart_head_row .price_row{
width: 162px;
}
.cart_table .cart_head_row .do_more{
width: 162px;
}
.cart_footer_row{
padding-left: 36px;
background: #F7F7F7;
width: 100%;
height: 80px;
line-height: 80px;
}
.cart_footer_row .cart_select span{
margin-left: 14px;
font-size: 18px;
color: #666;
}
.cart_footer_row .cart_delete{
margin-left: 58px;
}
.cart_delete .el-icon-delete{
font-size: 18px;
}
.cart_delete span{
margin-left: 15px;
cursor: pointer;
font-size: 18px;
color: #666;
}
.cart_total{
float: right;
margin-right: 62px;
font-size: 18px;
color: #666;
}
.goto_pay{
float: right;
width: 159px;
height: 80px;
outline: none;
border: none;
background: #ffc210;
font-size: 18px;
color: #fff;
text-align: center;
cursor: pointer;
}
</style>
CartItem.vue
<template>
<div class="cart_item">
<div class="cart_column column_1">
<el-checkbox class="my_el_checkbox" v-model="checked"></el-checkbox>
</div>
<div class="cart_column column_2">
<img src="/static/image/course-cover.jpeg" alt="">
<span><router-link to="/course/detail/1">爬虫从入门到进阶</router-link></span>
</div>
<div class="cart_column column_3">
<el-select v-model="expire" size="mini" placeholder="请选择购买有效期" class="my_el_select">
<el-option label="1个月有效" value="30" key="30"></el-option>
<el-option label="2个月有效" value="60" key="60"></el-option>
<el-option label="3个月有效" value="90" key="90"></el-option>
<el-option label="永久有效" value="10000" key="10000"></el-option>
</el-select>
</div>
<div class="cart_column column_4">¥499.0</div>
<div class="cart_column column_4">删除</div>
</div>
</template>
<script>
export default {
name: "CartItem",
data(){
return {
checked:false,
expire: "1个月有效",
}
}
}
</script>
<style scoped>
/*.cart_item{*/
/* height: 100px;*/
/*}*/
.cart_item::after{
content: "";
display: block;
clear: both;
}
.cart_column{
float: left;
height: 150px;
display: flex;
align-items: center;
}
.cart_item .column_1{
width: 88px;
position: relative;
}
.my_el_checkbox{
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
margin: auto;
width: 16px;
height: 16px;
}
.cart_item .column_2 {
/*padding: 67px 10px;*/
width: 520px;
/*height: 116px;*/
}
.cart_item .column_2 img{
width: 175px;
/*height: 115px;*/
margin-right: 35px;
/*vertical-align: middle;*/
}
.cart_item .column_3{
width: 197px;
position: relative;
padding-left: 10px;
}
.my_el_select{
width: 117px;
height: 28px;
position: absolute;
top: 0;
bottom: 0;
margin: auto;
}
.cart_item .column_4{
/*padding: 67px 10px;*/
/*height: 116px;*/
width: 142px;
/*line-height: 116px;*/
}
</style>
5.7 Cart.vue完整页面
<template>
<div class="cart">
<Vheader></Vheader>
<div class="cart_info">
<div class="cart_title">
<span class="text">我的购物车</span>
<span class="total">共4门课程</span>
</div>
<div class="cart_table">
<div class="cart_head_row">
<span class="doing_row"></span>
<span class="course_row">课程</span>
<span class="expire_row">有效期</span>
<span class="price_row">单价</span>
<span class="do_more">操作</span>
</div>
<div class="cart_course_list">
<CartItem v-for="(value,index) in cart_data_list" :key="index" :cart="value" @cal_t_p="cal_total_price"></CartItem>
</div>
<div class="cart_footer_row">
<span class="cart_select"><label> <el-checkbox v-model="checked"></el-checkbox><span>全选</span></label></span>
<span class="cart_delete"><i class="el-icon-delete"></i> <span>删除</span></span>
<span class="goto_pay">去结算</span>
<span class="cart_total">总计:{{total_price}}</span>
</div>
</div>
</div>
<Footer></Footer>
</div>
</template>
<script>
import Vheader from "./common/Vheader"
import Footer from "./common/Footer"
import CartItem from "./common/CartItem"
export default {
name: "Cart",
data(){
return {
checked: false,
cart_data_list:[],
total_price:0,
}
},
methods:{
cal_total_price(){
let t_price = 0;
this.cart_data_list.forEach((v,k)=>{
if(v.selected){
t_price += v.real_price
}
});
console.log('total_price>>>',t_price);
this.total_price = t_price
}
},
created(){
let token = sessionStorage.token || localStorage.token;
if (token){
this.$axios.get(`${this.$settings.Host}/cart/add_cart/`,{
headers:{
'Authorization' : 'jwt ' + token
}
})
.then((res)=>{
this.cart_data_list = res.data.cart_data_list;
console.log(this.cart_data_list)
})
.catch((error)=>{
this.$message.error(error.response.data.msg);
})
}
else{
this.$router.push('/user/login/');
}
},
components:{
Vheader,
Footer,
CartItem,
}
}
</script>
<style scoped>
.cart_info{
width: 1200px;
margin: 0 auto 50px;
}
.cart_title{
margin: 25px 0;
}
.cart_title .text{
font-size: 18px;
color: #666;
}
.cart_title .total{
font-size: 12px;
color: #d0d0d0;
}
.cart_table{
width: 1170px;
}
.cart_table .cart_head_row{
background: #F7F7F7;
width: 100%;
height: 80px;
line-height: 80px;
padding-right: 30px;
}
.cart_table .cart_head_row::after{
content: "";
display: block;
clear: both;
}
.cart_table .cart_head_row .doing_row,
.cart_table .cart_head_row .course_row,
.cart_table .cart_head_row .expire_row,
.cart_table .cart_head_row .price_row,
.cart_table .cart_head_row .do_more{
padding-left: 10px;
height: 80px;
float: left;
}
.cart_table .cart_head_row .doing_row{
width: 78px;
}
.cart_table .cart_head_row .course_row{
width: 530px;
}
.cart_table .cart_head_row .expire_row{
width: 188px;
}
.cart_table .cart_head_row .price_row{
width: 162px;
}
.cart_table .cart_head_row .do_more{
width: 162px;
}
.cart_footer_row{
padding-left: 36px;
background: #F7F7F7;
width: 100%;
height: 80px;
line-height: 80px;
}
.cart_footer_row .cart_select span{
margin-left: 14px;
font-size: 18px;
color: #666;
}
.cart_footer_row .cart_delete{
margin-left: 58px;
}
.cart_delete .el-icon-delete{
font-size: 18px;
}
.cart_delete span{
margin-left: 15px;
cursor: pointer;
font-size: 18px;
color: #666;
}
.cart_total{
float: right;
margin-right: 62px;
font-size: 18px;
color: #666;
}
.goto_pay{
float: right;
width: 159px;
height: 80px;
outline: none;
border: none;
background: #ffc210;
font-size: 18px;
color: #fff;
text-align: center;
cursor: pointer;
}
</style>
5.8 CartItem.vue完整页面
<template>
<div class="cart_item">
<div class="cart_column column_1">
<el-checkbox class="my_el_checkbox" v-model="cart.selected"></el-checkbox>
</div>
<div class="cart_column column_2">
<img :src="cart.course_img" alt="">
<span><router-link to="'/course/detail/' + cart.course_id + '/'">{{cart.name}}</router-link></span>
</div>
<div class="cart_column column_3">
<el-select v-model="cart.expire_id" size="mini" placeholder="请选择购买有效期" class="my_el_select">
<el-option label="1个月有效" value="30" key="30"></el-option>
<el-option label="2个月有效" value="60" key="60"></el-option>
<el-option label="3个月有效" value="90" key="90"></el-option>
<el-option label="永久有效" value="0" key="10000"></el-option>
</el-select>
</div>
<div class="cart_column column_4">¥{{cart.real_price}}</div>
<div class="cart_column column_4">删除</div>
</div>
</template>
<script>
export default {
name: "CartItem",
data(){
return {
//checked:false,
}
},
props:['cart',],
watch:{
'cart.selected':function(){
// 添加选中
let token = sessionStorage.token || sessionStorage.token;
if (this.cart.selected){
this.$axios.patch(`${this.$settings.Host}/cart/add_cart/`,{
course_id:this.cart.course_id,
},{
headers:{
'Authorization':'jwt ' + token
}
}).then((res)=>{
this.$message.success(res.data.msg);
this.$emit('cal_t_p')
}).catch((error)=>{
this.$message.error(res.data.msg)
})
}
else{
// 取消选中
this.$axios.put(`${this.$settings.Host}/cart/add_cart/`,{
course_id:this.cart.course_id,
},{
headers:{
'Authorization': 'jwt ' + token
}
}).then((res)=>{
this.$message.success(res.data.msg);
this.$emit('cal_t_p')
}).catch((error)=>{
this.$message.error(res.data.msg);
})
}
}
},
}
</script>
<style scoped>
/*.cart_item{*/
/* height: 100px;*/
/*}*/
.cart_item::after{
content: "";
display: block;
clear: both;
}
.cart_column{
float: left;
height: 150px;
display: flex;
align-items: center;
}
.cart_item .column_1{
width: 88px;
position: relative;
}
.my_el_checkbox{
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
margin: auto;
width: 16px;
height: 16px;
}
.cart_item .column_2 {
/*padding: 67px 10px;*/
width: 520px;
/*height: 116px;*/
}
.cart_item .column_2 img{
width: 175px;
/*height: 115px;*/
margin-right: 35px;
/*vertical-align: middle;*/
}
.cart_item .column_3{
width: 197px;
position: relative;
padding-left: 10px;
}
.my_el_select{
width: 117px;
height: 28px;
position: absolute;
top: 0;
bottom: 0;
margin: auto;
}
.cart_item .column_4{
/*padding: 67px 10px;*/
/*height: 116px;*/
width: 142px;
/*line-height: 116px;*/
}
</style>
5.9 购物车后端
cart/urls.py
from django.urls import path,re_path
from . import views
urlpatterns = [
path(r'add_cart/', views.AddCartView.as_view({'post':'add','get':'cart_list','patch':'change_select','put':'cancel_select'})),
]
cart/views.py
from django.shortcuts import render
from rest_framework.viewsets import ViewSet
# Create your views here.
from django_redis import get_redis_connection
from course import models
from rest_framework.response import Response
from rest_framework import status
from luffyapi.settings import contains
from rest_framework.permissions import IsAuthenticated
import logging
logger = logging.getLogger('django')
class AddCartView(ViewSet):
permission_classes = [IsAuthenticated,] # 请求头里必须带着token
# 往购物车里添加一条信息
def add(self,request):
course_id = request.data.get('course_id')
user_id = request.user.id
expire = 0 # 表示永久有效
conn = get_redis_connection('cart')
try:
models.Course.objects.get(id=course_id)
except:
return Response({'msg':'课程不存在'},status=status.HTTP_400_BAD_REQUEST)
pipe = conn.pipeline() # 创建管道
pipe.multi()
# 哈希字典类型 批量操作
pipe.hset('cart_%s' % user_id, course_id, expire)
pipe.execute()
# 集合类型 set
# conn.sadd('cart_%s'%user_id,course_id)
# cart_length = conn.scard('cart_%s'% user_id)
cart_length = conn.hlen('cart_%s' % user_id)
print('cart_length',cart_length)
return Response({'msg':'添加成功','cart_length':cart_length})
# 获取购物车数据
def cart_list(self,request):
user_id = request.user.id
user_name= request.user.username
conn = get_redis_connection('cart')
conn.delete('selected_cart_%s' % user_id)
ret = conn.hgetall('cart_%s' % user_id) #dict {b'1': b'0', b'2': b'0'}
cart_data_list = []
print(ret)
try:
for cid, eid in ret.items():
course_id = cid.decode('utf-8')
expire_id = eid.decode('utf-8')
course_obj = models.Course.objects.get(id=course_id)
cart_data_list.append({
'course_id': course_obj.id,
'name':course_obj.name,
'course_img':contains.SERVER_ADDR + course_obj.course_img.url,
'price':course_obj.price,
'real_price': course_obj.real_price(),
'expire_id':expire_id,
'selected':False, # 默认没有勾选
})
except Exception:
logger.error('获取购物车数据失败')
return Response({'msg':'后台数据库出问题了,请联系管理员'},status=status.HTTP_507_INSUFFICIENT_STORAGE)
print(cart_data_list)
return Response({'msg':'haha','cart_data_list':cart_data_list})
# 选中勾选
def change_select(self,request):
course_id = request.data.get('course_id')
try:
models.Course.objects.get(id=course_id)
except:
return Response({'msg':'没有该课程,别乱搞'},status=status.HTTP_400_BAD_REQUEST)
user_id = request.user.id
conn = get_redis_connection('cart')
conn.sadd('selected_cart_%s' % user_id,course_id)
return Response({'msg':'恭喜你!勾选成功!'})
# 取消勾选
def cancel_select(self,request):
course_id = request.data.get('course_id')
try:
models.Course.objects.get(id=course_id)
except:
return Response({'msg':'课程不存在,别乱搞'},status=status.HTTP_400_BAD_REQUEST)
user_id = request.user.id
conn = get_redis_connection('cart')
conn.srem('selected_cart_%s'% user_id,course_id)
return Response({'msg':'恭喜你!少花钱了,但是你真的不学习了吗?'})
模型
course/models.py
from django.db import models
from luffyapi.settings import contains
# Create your models here.
from django.db import models
from luffyapi.utils.models import BaseModel
# Create your models here.
class CourseCategory(BaseModel):
"""
课程分类
"""
name = models.CharField(max_length=64, unique=True, verbose_name="分类名称")
class Meta:
db_table = "ly_course_category"
verbose_name = "课程分类"
verbose_name_plural = "课程分类"
def __str__(self):
return "%s" % self.name
from ckeditor_uploader.fields import RichTextUploadingField
class Course(BaseModel):
"""
专题课程
"""
course_type = (
(0, '付费'),
(1, 'VIP专享'),
(2, '学位课程')
)
level_choices = (
(0, '初级'),
(1, '中级'),
(2, '高级'),
)
status_choices = (
(0, '上线'),
(1, '下线'),
(2, '预上线'),
)
name = models.CharField(max_length=128, verbose_name="课程名称")
course_img = models.ImageField(upload_to="course", max_length=255, verbose_name="封面图片", blank=True, null=True)
course_video = models.FileField(upload_to='video',verbose_name='封面video',blank=True,null=True,max_length=255)
# 费用类型字段是为了后期一些其他功能拓展用的,现在可以先不用,或者去掉它,目前我们项目用不到
course_type = models.SmallIntegerField(choices=course_type, default=0, verbose_name="付费类型")
# 这个字段是课程详情页里面展示的,并且详情介绍里面用户将来可能要上传一些图片之类的,所以我们会嵌入富文本编辑器,让用户填写数据的时候可以上传图片啊、写标题啊、css、html等等内容
brief = RichTextUploadingField(max_length=2048, verbose_name="详情介绍", null=True, blank=True)
level = models.SmallIntegerField(choices=level_choices, default=1, verbose_name="难度等级")
pub_date = models.DateField(verbose_name="发布日期", auto_now_add=True)
period = models.IntegerField(verbose_name="建议学习周期(day)", default=7)
# 课件资料的存放路径
attachment_path = models.FileField(max_length=128, verbose_name="课件路径", blank=True, null=True)
status = models.SmallIntegerField(choices=status_choices, default=0, verbose_name="课程状态")
course_category = models.ForeignKey("CourseCategory", on_delete=models.CASCADE, null=True, blank=True,
verbose_name="课程分类")
students = models.IntegerField(verbose_name="学习人数", default=0)
lessons = models.IntegerField(verbose_name="总课时数量", default=0)
# 总课时数量可能10个,但是目前之更新了3个,就跟小说、电视剧连载似的。
pub_lessons = models.IntegerField(verbose_name="课时更新数量", default=0)
# 课程原价
price = models.DecimalField(max_digits=6, decimal_places=2, verbose_name="课程原价", default=0)
teacher = models.ForeignKey("Teacher", on_delete=models.DO_NOTHING, null=True, blank=True, verbose_name="授课老师")
class Meta:
db_table = "ly_course"
verbose_name = "专题课程"
verbose_name_plural = "专题课程"
def __str__(self):
return "%s" % self.name
# 通过课程对象获取所有课程列表页中要展示的课时信息
def get_lessons(self):
chapters = self.coursechapters.all()
lession_list = [] # 课时信息列表
for chapter in chapters:
lessons = chapter.coursesections.filter(is_show_list=True,is_show=True,is_deleted=False)
for lesson in lessons:
lession_list.append({
'name':lesson.name,
'free_trail':lesson.free_trail,
'lesson':lesson.lesson,
})
return lession_list[:4]
def level_name(self):
return self.get_level_display()
def new_brief(self):
data = self.brief
server_addr = contains.SERVER_ADDR
data = data.replace('src="/media',f'class="img_xx" src="{server_addr}/media')
return data
def activity(self):
import datetime
now = datetime.datetime.now()
activity_list = self.activeprices.filter(is_show=True,is_deleted=False,active__created_time__lte=now,active__end_time__gte=now)
return activity_list
def discount_name(self):
dis_name = ''
a = self.activity()
if a:
discount_n_list = []
for i in a:
discount_n = i.discount.discount_type.name
discount_n_list.append(discount_n)
dis_name = discount_n_list[0]
return dis_name
def real_price(self):
price = float(self.price)
r_price = price
a = self.activity()
if a:
sale = a[0].discount.sale
condition_price = a[0].discount.condition
# 限时免费
if not sale.strip():
r_price = 0
# 限时折扣
elif "*" in sale:
if price >= condition_price:
_,d = sale.split("*")
r_price = price * float(d)
# 限时减免
elif sale.strip().startswith("-"):
if price >= condition_price:
_,d = sale.split("-")
r_price = price - float(d)
# 满减
# '''
# 满100-10
# 满300 - 50
# 满600 - 100
# 满200-25
#
# '''
# 限时满减
elif "满" in sale:
if price > condition_price:
l1 = sale.split('\r\n')
dis_list = []
for i in l1:
a,b = i[1:].split('-')
if price >= float(a):
dis_list.append(float(b))
max_dis = max(dis_list)
r_price = price - max_dis
return r_price
# 活动倒计时时间戳
def left_time(self):
import datetime
now = datetime.datetime.now().timestamp()
left_t = 0
a = self.activity()
if a:
end_time = a[0].active.end_time.timestamp()
left_t = end_time - now
return left_t
class Teacher(BaseModel):
"""讲师、导师表"""
role_choices = (
(0, '讲师'),
(1, '导师'),
(2, '班主任'),
)
name = models.CharField(max_length=32, verbose_name="讲师title")
role = models.SmallIntegerField(choices=role_choices, default=0, verbose_name="讲师身份")
title = models.CharField(max_length=64, verbose_name="职位、职称")
signature = models.CharField(max_length=255, verbose_name="导师签名", help_text="导师签名", blank=True, null=True)
image = models.ImageField(upload_to="teacher", null=True, verbose_name="讲师封面")
brief = models.TextField(max_length=1024, verbose_name="讲师描述")
class Meta:
db_table = "ly_teacher"
verbose_name = "讲师导师"
verbose_name_plural = "讲师导师"
def __str__(self):
return "%s" % self.name
class CourseChapter(BaseModel):
"""课程章节"""
course = models.ForeignKey("Course", related_name='coursechapters', on_delete=models.CASCADE, verbose_name="课程名称")
chapter = models.SmallIntegerField(verbose_name="第几章", default=1)
name = models.CharField(max_length=128, verbose_name="章节标题")
summary = models.TextField(verbose_name="章节介绍", blank=True, null=True)
pub_date = models.DateField(verbose_name="发布日期", auto_now_add=True)
class Meta:
db_table = "ly_course_chapter"
verbose_name = "课程章节"
verbose_name_plural = "课程章节"
def __str__(self):
return "%s:(第%s章)%s" % (self.course, self.chapter, self.name)
class CourseLesson(BaseModel):
"""课程课时"""
section_type_choices = (
(0, '文档'),
(1, '练习'),
(2, '视频')
)
chapter = models.ForeignKey("CourseChapter", related_name='coursesections', on_delete=models.CASCADE,
verbose_name="课程章节")
name = models.CharField(max_length=128, verbose_name="课时标题")
# orders = models.PositiveSmallIntegerField(verbose_name="课时排序") #在basemodel里面已经有了排序了
section_type = models.SmallIntegerField(default=2, choices=section_type_choices, verbose_name="课时种类")
section_link = models.CharField(max_length=255, blank=True, null=True, verbose_name="课时链接",
help_text="若是video,填vid,若是文档,填link")
duration = models.CharField(verbose_name="视频时长", blank=True, null=True,
max_length=32) # 仅在前端展示使用,所以直接让上传视频的用户直接填写时长进来就可以了。
pub_date = models.DateTimeField(verbose_name="发布时间", auto_now_add=True)
free_trail = models.BooleanField(verbose_name="是否可试看", default=False)
course = models.ForeignKey('Course', related_name='course_lesson', verbose_name='课程', on_delete=models.CASCADE,
null=True, blank=True)
is_show_list = models.BooleanField(verbose_name='是否推荐到课程列表', default=False)
lesson = models.IntegerField(verbose_name="第几课时")
class Meta:
db_table = "ly_course_lesson"
verbose_name = "课程课时"
verbose_name_plural = "课程课时"
def __str__(self):
return "%s-%s" % (self.chapter, self.name)
"""价格相关的模型"""
class CourseDiscountType(BaseModel):
"""课程优惠类型"""
name = models.CharField(max_length=32, verbose_name="优惠类型名称")
remark = models.CharField(max_length=250, blank=True, null=True, verbose_name="备注信息")
class Meta:
db_table = "ly_course_discount_type"
verbose_name = "课程优惠类型"
verbose_name_plural = "课程优惠类型"
def __str__(self):
return "%s" % (self.name)
class CourseDiscount(BaseModel):
"""课程优惠模型"""
discount_type = models.ForeignKey("CourseDiscountType", on_delete=models.CASCADE, related_name='coursediscounts', verbose_name="优惠类型")
condition = models.IntegerField(blank=True, default=0, verbose_name="满足优惠的价格条件",help_text="设置参与优惠的价格门槛,表示商品必须在xx价格以上的时候才参与优惠活动,<br>如果不填,则不设置门槛") #因为有的课程不足100,你减免100,还亏钱了
sale = models.TextField(verbose_name="优惠公式",blank=True,null=True, help_text="""
不填表示免费;<br>
*号开头表示折扣价,例如*0.82表示八二折;<br>
-号开头则表示减免,例如-20表示原价-20;<br>
如果需要表示满减,则需要使用 原价-优惠价格,例如表示课程价格大于100,优惠10;大于200,优惠25,格式如下:<br>
满100-10<br>
满200-25<br>
""")
class Meta:
db_table = "ly_course_discount"
verbose_name = "价格优惠策略"
verbose_name_plural = "价格优惠策略"
def __str__(self):
return "价格优惠:%s,优惠条件:%s,优惠值:%s" % (self.discount_type.name, self.condition, self.sale)
class Activity(BaseModel):
"""优惠活动"""
name = models.CharField(max_length=150, verbose_name="活动名称")
start_time = models.DateTimeField(verbose_name="优惠策略的开始时间")
end_time = models.DateTimeField(verbose_name="优惠策略的结束时间")
remark = models.CharField(max_length=250, blank=True, null=True, verbose_name="备注信息")
class Meta:
db_table = "ly_activity"
verbose_name="商品活动"
verbose_name_plural="商品活动"
def __str__(self):
return self.name
class CoursePriceDiscount(BaseModel):
"""课程与优惠策略的关系表"""
course = models.ForeignKey("Course",on_delete=models.CASCADE, related_name="activeprices",verbose_name="课程")
active = models.ForeignKey("Activity",on_delete=models.DO_NOTHING, related_name="activecourses",verbose_name="活动")
discount = models.ForeignKey("CourseDiscount",on_delete=models.CASCADE,related_name="discountcourse",verbose_name="优惠折扣")
class Meta:
db_table = "ly_course_price_dicount"
verbose_name="课程与优惠策略的关系表"
verbose_name_plural="课程与优惠策略的关系表"
def __str__(self):
return "课程:%s,优惠活动: %s,开始时间:%s,结束时间:%s" % (self.course.name, self.active.name, self.active.start_time,self.active.end_time)
class CourseExpire(BaseModel):
course = models.ForeignKey('Course',related_name='courseexpire',on_delete=models.CASCADE,verbose_name='课程')
expire = models.IntegerField(null=True, blank=True, verbose_name='课程有效期', help_text='1个月等于30天')
price = models.DecimalField(max_digits=6,decimal_places=2,default=0,verbose_name='课程价格')
class Meta:
db_table = 'ly_course_expire'
verbose_name = "课程与有效期的关系表"
verbose_name_plural = "课程与有效期的关系表"
def __str__(self):
return "课程:%s, 有效期:%s, 价格:%s" % (self.course.name,self.expire,self.price)
序列化器
course/serializers.py
from rest_framework import serializers
from . import models
# 课程分类
class CourseCategoryModelSerializer(serializers.ModelSerializer):
class Meta:
model = models.CourseCategory
fields = ['id','name']
# 老师
class TeacherModelSerializer(serializers.ModelSerializer):
class Meta:
model = models.Teacher
fields = ['name','role','title','signature']
# 课程信息
class CourseModelSerializer(serializers.ModelSerializer):
# teacher_name = serializers.CharField(source='teacher.name') #自定义字段,通过source关键字就能获取外键关联的指定字段数据,别忘了在fields指定一下
teacher = TeacherModelSerializer() # 将外键关联的属性指定为关联表的序列化器对象,就能拿到关联表序列化出来的所有数据,还需要在fields中指定一下,注意,名称必须要和外键属性名称相同
class Meta:
model = models.Course
# fields =["id","name","course_img","students","lessons","pub_lessons","price","teacher","teacher_name"] # teacher外键属性默认拿的是id值
fields =["id","name","course_img","students","discount_name","real_price","lessons","pub_lessons","price","teacher","get_lessons"] # teacher外键属性默认拿的是id值
class CourseDetailModelSerializer(serializers.ModelSerializer):
teacher = TeacherModelSerializer()
class Meta:
model = models.Course
fields = ["id", "name", "course_img", "students","discount_name","real_price","left_time", "lessons", "pub_lessons", "price", "teacher","level_name", "course_video","new_brief"]
class CourseLessonModelSerializer(serializers.ModelSerializer):
class Meta:
model = models.CourseLesson
fields = ['name','section_link','duration','free_trail','lesson']
class CourseChapterModelSerializer(serializers.ModelSerializer):
coursesections = CourseLessonModelSerializer(many=True)
class Meta:
model = models.CourseChapter
fields = ['chapter','name','coursesections']
因为我们登陆注册的时候只是获取和校验了一下token的有效性,并没有真实的用token来核对用户信息是否是正确的,所以需要认证一下
前端发送axios请求时
headers:{
'Authorization': 'jwt ' + token
}
后端
from rest_framework.permissions import IsAuthenticated
class AddCartView(ViewSet):
permission_classes = [IsAuthenticated,] # 请求头里必须带着token
写的模型要在xadmin中注册一下,这样才能用xadmin来管理
import xadmin
from .models import CourseCategory
class CourseCategoryModelAdmin(object):
"""课程分类管理模型"""
pass
xadmin.site.register(CourseCategory,CourseCategoryModelAdmin)
from .models import Course
class CourseModelAdmin(object):
"""课程模型管理类"""
pass
xadmin.site.register(Course, CourseModelAdmin)
from .models import Teacher
class TeacherModelAdmin(object):
"""老师模型管理类"""
pass
xadmin.site.register(Teacher, TeacherModelAdmin)
from .models import CourseChapter
class CourseChapterModelAdmin(object):
"""课程章节模型管理类"""
pass
xadmin.site.register(CourseChapter, CourseChapterModelAdmin)
from .models import CourseLesson
class CourseLessonModelAdmin(object):
"""课程课时模型管理类"""
pass
xadmin.site.register(CourseLesson, CourseLessonModelAdmin)
from .models import CourseDiscountType
class CourseExpireModelAdmin(object):
"""课程与有效期模型管理类"""
pass
xadmin.site.register(CourseDiscountType, CourseExpireModelAdmin)
from .models import CourseDiscount
class PriceDiscountTypeModelAdmin(object):
"""价格优惠类型"""
pass
xadmin.site.register(CourseDiscount, PriceDiscountTypeModelAdmin)
from .models import Activity
class PriceDiscountModelAdmin(object):
"""价格优惠公式"""
pass
xadmin.site.register(Activity, PriceDiscountModelAdmin)
from .models import CoursePriceDiscount
class CoursePriceDiscountModelAdmin(object):
"""商品优惠和活动的关系"""
pass
xadmin.site.register(CoursePriceDiscount, CoursePriceDiscountModelAdmin)
from .models import CourseExpire
class CourseExpireModelAdmin(object):
""" 课程与有效期的关系"""
pass
xadmin.site.register(CourseExpire,CourseExpireModelAdmin)
手动添加一些数据
课程优惠类型
- 满减
- 限时减免
- 限时折扣
- 限时免费
价格优惠策略
-
满减
满100-10 满200-25 满300-50
-
限时减免
满足优惠的价格条件100 -80
-
限时折扣
满足优惠的价格条件100 *0.5
商品活动(时间自己填吧)
- 双十一快乐购
- 双十二
- 圣诞联欢
数据都可以自己手动填写的