路飞学城四:课程管理模块实现

四:课程管理模块实现

课程列表页

前端显示课程列表页面

views/Course.vue,代码:

<template>
  <div class="course">
    <Header></Header>
    <div class="top-wrap">
        <div class="actual-header">
            <div class="actual-header-wrap">
                <div class="banner">
                    <router-link class="title" to="/course"><img class="h100" src="../assets/coding-title.png" alt=""></router-link>
                    <div>真实项目实战演练</div>
                </div>
                <div class="actual-header-search">
                    <div class="search-inner">
                        <input class="actual-search-input" placeholder="搜索感兴趣的实战课程内容" type="text" autocomplete="off">
                        <img class="actual-search-button" src="../assets/search.svg" />
                    </div>
                    <div class="actual-searchtags">
                    </div>
                    <div class="search-hot">
                        <span>热搜:</span>
                        <a href="">Java工程师</a>
                        <a href="">Vue</a>
                    </div>
                </div>
            </div>
        </div>
        <div class="type">
            <div class="type-wrap">
                <div class="one warp">
                    <span class="name">方向:</span>
                    <ul class="items">
                        <li class="cur"><a href="">全部</a></li>
                        <li><a href="">前端开发</a></li>
                        <li><a href="">后端开发</a></li>
                        <li><a href="">移动开发</a></li>
                        <li><a href="">计算机基础</a></li>
                        <li><a href="">前沿技术</a></li>
                        <li><a href="">云计算&amp;大数据</a></li>
                        <li><a href="">运维&amp;测试</a></li>
                        <li><a href="">数据库</a></li>
                        <li><a href="">UI设计&amp;多媒体</a></li>
                        <li><a href="">游戏</a></li>
                        <li><a href="">求职面试</a></li>
                    </ul>
                </div>
                <div class="two warp">
                    <span class="name">分类:</span>
                    <ul class="items">
                        <li class="cur"><a href="">不限</a></li>
                        <li><a href="">Vue.js</a></li>
                        <li><a href="">Typescript</a></li>
                        <li><a href="">React.JS</a></li>
                        <li><a href="">HTML/CSS</a></li>
                        <li><a href="">JavaScript</a></li>
                        <li><a href="">Angular</a></li>
                        <li><a href="">Node.js</a></li>
                        <li><a href="">WebApp</a></li>
                        <li><a href="">小程序</a></li>
                        <li><a href="">前端工具</a></li>
                        <li><a href="">CSS</a></li>
                        <li><a href="">Html5</a></li>
                        <li><a href="">CSS3</a></li>
                    </ul>
                </div>
            </div>
        </div>
    </div>
    <div class="main">
        <div class="main-wrap">
            <div class="filter clearfix">
                <div class="sort l">
                  <a href="" class="on">最新</a>
                  <a href="">销量</a>
                  <a href="">升级</a>
                </div>
                <div class="other r clearfix"><a class="course-line l" href="" target="_blank">学习路线</a></div>
            </div>
            <ul class="course-list clearfix">
              <li class="course-card">
                <a target="_blank" href="">
                    <div class="img"><img src="../assets/course-1.png" alt=""></div>
                    <p class="title ellipsis2">全面的Docker 系统性入门+进阶实践(2021最新版)</p>
                    <p class="one">
                        <span>进阶 · 611人报名</span>
                        <span class="discount r"><i class="name">优惠价</i></span>
                    </p>
                    <p class="two clearfix">
                        <span class="price l red bold">¥428.00</span>
                        <span class="origin-price l delete-line">¥488.00</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </a>
              </li>
              <li class="course-card">
                <a target="_blank" href="">
                    <div class="img"><img src="../assets/course-2.png" alt=""></div>
                    <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>
                    <p class="one">
                        <span>进阶 · 246人报名</span>
                        <span class="discount r"><i class="name">限时优惠</i><i class="countdown">6<span class="day">天</span>01:39:21</i></span>
                    </p>
                    <p class="two clearfix">
                        <span class="price l red bold">¥328.00</span>
                        <span class="origin-price l delete-line">¥368.00</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </a>
              </li>
              <li class="course-card">
                <a target="_blank" href="">
                    <div class="img"><img src="../assets/course-3.png" alt=""></div>
                    <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>
                    <p class="one">
                        <span>进阶 · 246人报名</span>
                        <span class="discount r"><i class="name">限时优惠</i><i class="countdown">16<span class="day">天</span>01:39:21</i></span>
                    </p>
                    <p class="two clearfix">
                        <span class="price l red bold">¥328.00</span>
                        <span class="origin-price l delete-line">¥368.00</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </a>
              </li>
              <li class="course-card">
                <a target="_blank" href="">
                    <div class="img"><img src="../assets/course-4.png" alt=""></div>
                    <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>
                    <p class="one"><span>进阶 · 246人报名</span></p>
                    <p class="two clearfix">
                        <span class="price l red bold">¥399.00</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </a>
              </li>
              <li class="course-card">
                <a target="_blank" href="">
                    <div class="img"><img src="../assets/course-5.png" alt=""></div>
                    <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>
                    <p class="one"><span>进阶 · 246人报名</span></p>
                    <p class="two clearfix">
                        <span class="price l red bold">¥399.00</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </a>
              </li>
              
            </ul>
            <div class="page">
                <span class="disabled_page">首页</span>
                <span class="disabled_page">上一页</span>
                <a href="" class="active">1</a>
                <a href="">2</a>
                <a href="">3</a>
                <a href="">4</a>
                <a href="">下一页</a>
                <a href="">尾页</a>
            </div>
        </div>
    </div>
    <Footer></Footer>
  </div>
</template>

<script setup>
import {reactive,ref} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"

</script>

<style scoped>
.top-wrap {
	background-color: #f5f7fa;
	background-repeat: no-repeat;
	background-position: top center;
	background-size: cover
}
.actual-header{
  max-width: 1500px;
  margin: 0 auto;
}
.actual-header .actual-header-wrap {
	height: 100%;
	display: -webkit-box;
	display: -ms-flexbox;
	display: -webkit-flex;
	display: flex;
	-webkit-box-align: center;
	-ms-flex-align: center;
	-webkit-align-items: center;
	align-items: center;
	-webkit-box-pack: justify;
	-ms-flex-pack: justify;
	-webkit-justify-content: space-between;
	justify-content: space-between;
	padding-top: 8px
}

.actual-header .actual-header-wrap .banner {
	display: -webkit-box;
	display: -ms-flexbox;
	display: -webkit-flex;
	display: flex;
	-webkit-box-align: center;
	-ms-flex-align: center;
	-webkit-align-items: center;
	align-items: center
}

.actual-header .actual-header-wrap .banner .title {
	height: 46px;
	margin-right: 8px
}

.actual-header .actual-header-wrap .actual-header-search {
	position: relative;
	width: 320px
}

.actual-header .actual-header-wrap .actual-header-search .search-inner {
	width: 100%;
	border-radius: 4px;
	overflow: hidden;
	margin: 17px 0 7px;
	border: 1px solid rgba(84,92,99,.2)
}

.actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-input {
	width: 275px;
	font-size: 12px;
	color: #93999f;
	line-height: 24px;
	padding: 5px 12px;
	border: none;
	border-radius: 0;
	box-sizing: border-box;
	background: 0 0
}

.actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-input::-webkit-input-placeholder {
	color: #9199a1
}

.actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-input::-moz-placeholder {
	color: #9199a1
}

.actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-input:-moz-placeholder {
	color: #9199a1
}

.actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-input:-ms-input-placeholder {
	color: #9199a1
}

.actual-header .actual-header-wrap .actual-header-search .search-inner .actual-search-button {
  width: 26px;
  padding-top: 4px;
  padding-bottom: 4px;
  padding-right: 4px;
  padding-left: 6px;
  height: 26px;
  font-size: 18px;
  text-align: center;
  line-height: 26px;
	color: #fff;
  background-color: rgba(84,92,99,.2);
	cursor: pointer;
	border-top-right-radius: 4px;
	border-bottom-right-radius: 4px;
	float: right
}

.actual-header .actual-header-wrap .actual-header-search .actual-searchtags {
	position: absolute;
	right: 128px;
	top: 0;
	height: 48px;
	line-height: 48px;
	text-align: right
}

.actual-header .actual-header-wrap .actual-header-search .actual-searchtags a {
	margin-left: 24px;
	font-size: 12px;
	color: #4d555d;
	line-height: 48px
}

.actual-header .actual-header-wrap .actual-header-search .actual-searchtags a:hover {
	color: #f01414
}

.actual-header .actual-header-wrap .actual-header-search .actual-history-item a {
	float: left;
	font-size: 12px;
	color: rgba(7,17,27,.6);
	line-height: 16px;
	padding: 4px 12px;
	margin-right: 8px;
	background: rgba(7,17,27,.05);
	border-radius: 12px;
	transition: .3s background,color linear;
	margin-top: 8px
}

.actual-header .actual-header-wrap .actual-header-search .actual-history-item a:hover {
	background: rgba(7,17,27,.1);
	color: #07111b
}

.actual-header .actual-header-wrap .actual-header-search li {
	display: block;
	width: 100%;
	height: 48px;
	transition: .3s background linear;
	padding: 12px 16px;
	box-sizing: border-box;
	font-size: 14px;
	color: #4d555d;
	line-height: 24px;
	cursor: pointer;
	z-index: 1
}

.actual-header .actual-header-wrap .actual-header-search li:hover {
	background: #f3f5f7;
	color: #07111b
}

.actual-header .actual-header-wrap .actual-header-search .search-hot {
	height: 21px;
	overflow: hidden;
	padding-left: 14px
}

.actual-header .actual-header-wrap .actual-header-search .search-hot a,
.actual-header .actual-header-wrap .actual-header-search .search-hot span {
	color: rgba(84,92,99,.7);
	font-size: 12px;
	line-height: 16px
}

.actual-header .actual-header-wrap .actual-header-search .search-hot a {
	margin-right: 14px
}

.actual-header .actual-header-wrap .actual-header-search .search-hot a:last-child {
	margin-right: 0
}

.type {
  max-width: 1500px;
  margin: 0 auto;
	padding-bottom: 27px
}

.type .type-wrap {
	position: relative;
	height: 109px
}

.type .type-wrap .warp {
	display: -webkit-box;
	display: -ms-flexbox;
	display: -webkit-flex;
	display: flex;
	position: absolute;
	width: 1430px;
	height: 54px;
	overflow: hidden;
	padding: 10px;
	box-sizing: border-box;
	box-shadow: 0 12px 20px 0 rgba(95,101,105,0);
	border-radius: 8px;
	transition: all .2s
}

.type .type-wrap .warp.one {
	margin-bottom: 25px;
	z-index: 3
}

.type .type-wrap .warp.two {
	top: 59px;
	z-index: 2
}

.type .type-wrap .warp .name {
	width: 3em;
	color: #07111b;
	line-height: 32px;
	font-weight: 700;
	margin-right: 6px
}

.type .type-wrap .warp .items {
	width: 0;
	-webkit-box-flex: 1;
	-ms-flex: 1;
	-webkit-flex: 1;
	flex: 1
}

.type .type-wrap .warp .items li {
	float: left;
	line-height: 16px;
	padding: 8px;
	border-radius: 6px;
	margin: 0 12px 12px 0
}

.type .type-wrap .warp .items li a {
	color: #1c1f21
}

.type .type-wrap .warp .items li.cur {
	background-color: rgba(233,142,70,.1)
}

.type .type-wrap .warp .items li.cur a {
	color: #e98e46
}
.delete-line{
  text-decoration: line-through;
}
/******** 课程列表 ********/
.l{
  float: left;
}
.r{
  float: right;
}
.red{
  color: red;
}
.bold{
  font-weight: 700;
}
.main {
	margin-bottom: 60px
}
.main .main-wrap{
  max-width: 1500px;
  margin: 0 auto;
}
.clearfix:after {
	content: '';
	display: block;
	height: 0;
	clear: both;
	visibility: hidden
}

.main .filter {
	margin: 20px 0
}

.main .filter .sort {
	overflow: hidden
}

.main .filter .sort a {
	display: inline-block;
	float: left;
	font-size: 12px;
	color: #545c63;
	line-height: 16px;
	padding: 4px 12px;
	border-radius: 100px;
	margin-right: 12px
}

.main .filter .sort a:last-child {
	margin-right: 0
}

.main .filter .sort a.on {
	color: #fff;
	background-color: #545c63
}

.main .filter .other {
	font-size: 12px
}

.main .filter .other .course-line {
	color: #e98e46;
	line-height: 16px;
	padding: 4px 16px;
	border-radius: 100px;
	background-color: rgba(233,142,70,.1);
	margin-left: 24px
}

.main .course-list .course-card {
	position: relative;
	width: 270px;
	height: 270px;
	float: left;
	margin: 0 37px 20px 0;
	box-shadow: 0 4px 8px 0 rgba(95,101,105,.05);
	border-radius: 8px;
	background-color: #fff;
	transition: transform .2s,box-shadow .2s
}

.main .course-list .course-card:nth-child(5n) {
	margin-right: 0
}

.main .course-list .course-card:hover {
	transform: translateY(-2px);
	box-shadow: 0 12px 20px 0 rgba(95,101,105,.1)
}

.main .course-list .course-card a {
	display: inline-block;
	width: 100%
}

.main .course-list .course-card .img {
	height: 152px;
	background: no-repeat center/cover;
	margin-bottom: 8px;
	border-radius: 8px 8px 0 0;
	overflow: hidden
}

.main .course-list .course-card .title {
	color: #545c63;
	line-height: 20px;
	height: 40px;
	margin-bottom: 8px;
	padding: 0 8px
}

.main .course-list .course-card .title.ellipsis2 {
	overflow: hidden;
	text-overflow: ellipsis;
	display: -webkit-box;
	-webkit-line-clamp: 2;
	-webkit-box-orient: vertical
}

.main .course-list .course-card .one,
.main .course-list .course-card .two {
	font-size: 12px;
	color: #9199a1;
	line-height: 18px;
	padding: 0 8px;
	margin-bottom: 8px
}

.main .course-list .course-card .one .add-shop-cart .icon,
.main .course-list .course-card .one .star .icon,
.main .course-list .course-card .two .add-shop-cart .icon,
.main .course-list .course-card .two .star .icon {
	display: inline-block;
	margin-right: 2px;
	font-size: 14px
}
.imv2-shopping-cart{
  width: 14px;
}
.main .course-list .course-card .one .add-shop-cart.add-shop-cart,
.main .course-list .course-card .one .add-shop-cart.stared,
.main .course-list .course-card .one .star.add-shop-cart,
.main .course-list .course-card .one .star.stared,
.main .course-list .course-card .two .add-shop-cart.add-shop-cart,
.main .course-list .course-card .two .add-shop-cart.stared,
.main .course-list .course-card .two .star.add-shop-cart,
.main .course-list .course-card .two .star.stared {
	color: #ff655d
}


.main .course-list .course-card .one .discount i,
.main .course-list .course-card .two .discount i {
	font-style: normal;
	padding: 3px 4px
}

.main .course-list .course-card .one .discount i.name,
.main .course-list .course-card .two .discount i.name {
	color: #fff;
	background-color: rgba(242,13,13,.6)
}

.main .course-list .course-card .one .price,
.main .course-list .course-card .two .price {
	line-height: 20px;
	margin-right: 2px
}

.main .course-list .course-card .one .discount,
.main .course-list .course-card .two .discount {
	border: 1px solid rgba(242,13,13,.2);
	border-radius: 2px;
	font-size: 12px;
	line-height: 1;
	margin-right: 4px;
	overflow: hidden;
	display: -webkit-box;
	display: -ms-flexbox;
	display: -webkit-flex;
	display: flex;
	-webkit-box-align: center;
	-ms-flex-align: center;
	-webkit-align-items: center;
	align-items: center
}

.main .course-list .course-card .one .discount i,
.main .course-list .course-card .two .discount i {
	font-style: normal;
	padding: 3px 4px
}

.main .course-list .course-card .one .discount i.name,
.main .course-list .course-card .two .discount i.name {
	color: #fff;
	background-color: rgba(242,13,13,.6)
}

.main .course-list .course-card .one .discount i.countdown,
.main .course-list .course-card .two .discount i.countdown {
	display: flex;
	font-family: DINCondensed,'微软雅黑';
	color: #f76e6e;
	padding-top: 4px;
	padding-bottom: 2px
}

.main .course-list .course-card .one .discount i.countdown .day,
.main .course-list .course-card .two .discount i.countdown .day {
	display: inline-block;
	width: 12px;
	height: 12px;
  transform:scale(0.8);
}


/**** 页码 *****/
.page {
	margin: 25px 0 auto;
	overflow: hidden;
	clear: both;
	text-align: center
}

.page a {
	display: inline-block;
	margin: 0 12px;
	width: 36px;
	height: 36px;
	line-height: 36px;
	font-size: 14px;
	color: #4d555d;
	text-align: center;
	border-radius: 50%;
	-webkit-transition: border-color .2s;
	-moz-transition: border-color .2s;
	transition: border-color .2s
}

.page a:hover {
	text-decoration: none;
	background-color: #d9dde1
}

.page a.active {
	background: #4d555d;
	color: #fff
}

.page a:first-child,
.page a:last-child,
.page a:nth-child(2),
.page a:nth-last-child(2) {
	width: auto
}

.page a:first-child:hover,
.page a:last-child:hover,
.page a:nth-child(2):hover,
.page a:nth-last-child(2):hover {
	background-color: transparent
}

.page span {
	display: inline-block;
	padding: 0 12px;
	min-width: 20px;
	height: 39px;
	line-height: 39px;
	font-size: 14px;
	color: #93999f;
	text-align: center
}
</style>

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

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

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


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

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


// 暴露路由对象
export default router

课程功能管理的设计

分析课程列表页面中的出现的数据之间的关系

学习方向:
课程分类:
课程信息:
课程章节:
课时信息:
老师信息:
价格策略:(限时免费\限时折扣\限时满减\原价)
优惠券/积分:

E-R图

http://draw.io

E-R图描述的是数据库设计过程中,实体与实体之间的关系的,实体与属性之间的关联的。

矩形表示实体,所谓的实体就是可以相互区分的,独立的事物。实体在数据库中会被转换成数据表。

椭圆形表示属性,用于描述实体的特征。实体的属性在数据库中会被转换成数据表中的字段。

菱形则表示实体之间的关系,根据范式理论第三条,实体之间的关系存在如下:

1:N 1对多

1:1 1对1

N:M 多对多

image-20220927084933473

image-20220927085005947

UML图

物理模型(根据具体数据库来设计的,powerdesigner、navicat)

image-20220927085212268

合并分支打标签

# 确认前面功能已经开发完整,review代码结束,想公司申请合并分支,开发合并分支
cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 展示课程列表页"
git push
git checkout master
git merge feature/user	# 这里合并会报错,删除/__pycache__/临时数据包即可
git branch -d feature/user	# 删除本地分支

# 查看线上本地所有的分支列表,可以看到本地的feature/user分支已经删除,但是线上的依然存在。
git branch --all
# 本地删除了分支以后,线上分支也要同步一下。
git push origin --delete feature/user

# 因为属于一个较大功能的开发合并,往往项目中都会打一个标签
git tag v0.0.3
# 提交标签版本
git push --tag
# git push origin v0.0.3

课程子应用创建

image-20220927085243451

git checkout -b feature/course
cd /home/moluo/Desktop/luffycity/luffycityapi/luffycityapi/apps/
python ../../manage.py startapp courses

注册子应用

settings.dev,代码:

INSTALLED_APPS = [
		...
    'courses',
]

注册子路由,course.urls,代码:

from django.urls import path,re_path
from . import views

urlpatterns = [
]

luffycityapi.urls,代码:

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

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

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

数据模型创建

courses/models.py,代码:

from luffycityapi.utils.models import models,BaseModel

# Create your models here.
class CourseDirection(BaseModel):
    name = models.CharField(max_length=255, unique=True, verbose_name="方向名称")
    remark = models.TextField(default="", blank=True, null=True, verbose_name="方向描述")
    recomment_home_hot = models.BooleanField(default=False, verbose_name="是否推荐到首页新课栏目")
    recomment_home_top = models.BooleanField(default=False, verbose_name="是否推荐到首页必学栏目")
    class Meta:
        db_table = "lf_course_direction"
        verbose_name = "学习方向"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name

class CourseCategory(BaseModel):
    name = models.CharField(max_length=255, unique=True, verbose_name="分类名称")
    remark = models.TextField(default="", blank=True, null=True, verbose_name="分类描述")
    direction = models.ForeignKey("CourseDirection", related_name="category_list", on_delete=models.DO_NOTHING, db_constraint=False, verbose_name="学习方向")

    class Meta:
        db_table = "lf_course_category"
        verbose_name = "课程分类"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name

class Course(BaseModel):
    course_type = (
        (0, '付费购买'),
        (1, '会员专享'),
        (2, '学位课程'),
    )
    level_choices = (
        (0, '初级'),
        (1, '中级'),
        (2, '高级'),
    )
    status_choices = (
        (0, '上线'),
        (1, '下线'),
        (2, '预上线'),
    )
    course_cover = models.ImageField(upload_to="course/cover", max_length=255, verbose_name="封面图片", blank=True, null=True)
    course_video = models.FileField(upload_to="course/video", max_length=255, verbose_name="封面视频", blank=True, null=True)
    course_type = models.SmallIntegerField(choices=course_type,default=0, verbose_name="付费类型")
    level = models.SmallIntegerField(choices=level_choices, default=1, verbose_name="难度等级")
    description = models.TextField(null=True, blank=True, verbose_name="详情介绍")
    pub_date = models.DateField(auto_now_add=True, verbose_name="发布日期")
    period = models.IntegerField(default=7, verbose_name="建议学习周期(day)")
    attachment_path = models.FileField(max_length=1000, blank=True, null=True, verbose_name="课件路径")
    attachment_link = models.CharField(max_length=1000, blank=True, null=True, verbose_name="课件链接")
    status = models.SmallIntegerField(choices=status_choices, default=0, verbose_name="课程状态")
    students = models.IntegerField(default=0, verbose_name="学习人数")
    lessons = models.IntegerField(default=0, verbose_name="总课时数量")
    pub_lessons = models.IntegerField(default=0, verbose_name="已更新课时数量")
    price = models.DecimalField(max_digits=10,decimal_places=2, verbose_name="课程原价",default=0)
    recomment_home_hot = models.BooleanField(default=False, verbose_name="是否推荐到首页新课栏目")
    recomment_home_top = models.BooleanField(default=False, verbose_name="是否推荐到首页必学栏目")
    direction = models.ForeignKey("CourseDirection", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="学习方向")
    category = models.ForeignKey("CourseCategory", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="课程分类")
    teacher = models.ForeignKey("Teacher", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="授课老师")

    class Meta:
        db_table = "lf_course_info"
        verbose_name = "课程信息"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s" % self.name

class Teacher(BaseModel):
    role_choices = (
        (0, '讲师'),
        (1, '导师'),
        (2, '班主任'),
    )

    role = models.SmallIntegerField(choices=role_choices, default=0, verbose_name="讲师身份")
    title = models.CharField(max_length=64, verbose_name="职位、职称")
    signature = models.CharField(max_length=255, blank=True, null=True, verbose_name="导师签名")
    avatar = models.ImageField(upload_to="teacher", null=True, verbose_name="讲师头像")
    brief = models.TextField(max_length=1024, verbose_name="讲师描述")

    class Meta:
        db_table = "lf_teacher"
        verbose_name = "讲师信息"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s" % self.name

class CourseChapter(BaseModel):
    """课程章节"""
    orders = models.SmallIntegerField(default=1, verbose_name="第几章")
    summary = models.TextField(blank=True, null=True, verbose_name="章节介绍")
    pub_date = models.DateField(auto_now_add=True, verbose_name="发布日期")
    course = models.ForeignKey("Course", related_name='chapter_list', on_delete=models.CASCADE, db_constraint=False, verbose_name="课程名称")

    class Meta:
        db_table = "lf_course_chapter"
        verbose_name = "课程章节"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s-第%s章-%s" % (self.course.name, self.orders, self.name)


class CourseLesson(BaseModel):
    """课程课时"""
    lesson_type_choices = (
        (0, '文档'),
        (1, '练习'),
        (2, '视频'),
    )

    orders = models.SmallIntegerField(default=1, verbose_name="第几节")
    lesson_type = models.SmallIntegerField(default=2, choices=lesson_type_choices, verbose_name="课时种类")
    lesson_link = models.CharField(max_length=255, blank=True, null=True, help_text="若是video,填视频地址或者视频id,若是文档,填文档地址", verbose_name="课时链接")
    duration = models.CharField(blank=True, null=True, max_length=32, verbose_name="课时时长")  # 仅在前端展示使用
    pub_date = models.DateTimeField(auto_now_add=True, verbose_name="发布时间")
    free_trail = models.BooleanField(default=False, verbose_name="是否可试看")
    recomment = models.BooleanField(default=False, verbose_name="是否推荐到课程列表")
    chapter = models.ForeignKey("CourseChapter", related_name='lesson_list', on_delete=models.CASCADE, db_constraint=False, verbose_name="章节")
    course = models.ForeignKey("Course", related_name="lesson_list", on_delete=models.DO_NOTHING, db_constraint=False, verbose_name="课程")

    class Meta:
        db_table = "lf_course_lesson"
        verbose_name = "课程课时"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s-%s" % (self.chapter, self.name)

执行数据迁移

cd /home/moluo/Desktop/luffycity/luffycityapi
python manage.py makemigrations
python manage.py migrate

因为我们一次性创建6个数据表,所以我们需要提供一个后台运营站点给将来工作人员添加对应的数据。当然我们现在也要添加数据,所以这里,我们采用simpleui来美化django内置的admin站点。

提交版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 课程子应用与课程相关模型的创建"
git push

simpleui美化admin站点

官网:https://simpleui.72wo.com/simpleui/

simpleui 免费版本

simplePro 收费版本

安装simpleui

pip install django-simpleui -i https://pypi.tuna.tsinghua.edu.cn/simple

注册simpleui,settings/dev.py,代码:

INSTALLED_APPS = [
    'simpleui', # admin界面美化,必须写在admin上面
    'django.contrib.admin', # 内置的admin运营站点

    # ...
]

LANGUAGE_CODE = 'zh-hans'
# LANGUAGE_CODE = 'en-us'

TIME_ZONE = 'Asia/Shanghai'
# TIME_ZONE = 'UTC'

把当前新增的课程的相关模型注册到admin里面.simpleUI仅仅是修改了admin站点的外观效果以及新增了部分配置功能,原有的admin站点的所有功能,simpleUI都没有进行改动或者删减。

courses/apps.py,代码:

from django.apps import AppConfig


class CoursesConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'courses'
    verbose_name="课程管理"
    verbose_name_plural = verbose_name
    

users/apps.py,代码:

from django.apps import AppConfig


class UsersConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'users'
    verbose_name="用户管理"
    verbose_name_plural = verbose_name

courses/admin.py,代码:

from django.contrib import admin
from .models import CourseDirection, CourseCategory, Course, Teacher, CourseChapter, CourseLesson


# Register your models here.
class CourseDirectionModelAdmin(admin.ModelAdmin):
    """学习方向的模型管理器"""
     list_display = ('id', 'name')


admin.site.register(CourseDirection, CourseDirectionModelAdmin)


class CourseCategoryModelAdmin(admin.ModelAdmin):
    """课程分类的模型管理器"""
    pass


admin.site.register(CourseCategory, CourseCategoryModelAdmin)


class CourseModelAdmin(admin.ModelAdmin):
    """课程信息的模型管理器"""
    pass


admin.site.register(Course, CourseModelAdmin)


class TeacherModelAdmin(admin.ModelAdmin):
    """讲师信息的模型管理器"""
    pass


admin.site.register(Teacher, TeacherModelAdmin)


class CourseChapterModelAdmin(admin.ModelAdmin):
    """课程章节的模型管理器"""
    pass


admin.site.register(CourseChapter, CourseChapterModelAdmin)


class CourseLessonModelAdmin(admin.ModelAdmin):
    """课程课时的模型管理器"""
    pass


admin.site.register(CourseLesson, CourseLessonModelAdmin)

users/admin.py,代码:

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User
# Register your models here.


class UserModelAdmin(UserAdmin):
    pass


admin.site.register(User, UserModelAdmin)

给admin后台站点添加富文本编辑器

所谓的富文本编辑器,实际上就是前端javasctript实现的页面插件,这个插件允许我们替换多行文本框,让使用者可以在不懂html、css的情况下,也能像使用word编写文章那样,对页面的部分内容进行图文排版。

常见的富文本编辑器插件:

ckeditor:https://ckeditor.com/ckeditor-5/demo/

kindeditor:http://kindeditor.net/demo.php

我们在django中一般使用就是ckeditor编辑器,可以通过pip安装。

django-ckeditor:https://github.com/django-ckeditor/django-ckeditor

pip install django-ckeditor

注册到项目中,settings/dev.py,代码:

# ckeditor富文本编辑器配置
INSTALLED_APPS = [
    'simpleui', # admin界面美化,必须写在admin上面
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    "rest_framework",  # 注意:记得加入 rest_framework
    'corsheaders',  # cors跨域子应用
    'ckeditor',   # 富文本编辑器
    'ckeditor_uploader',  # 富文本编辑器上传文件子应用

    "home",
    'users',
    'courses',
]



# 上传文件的存储路径
CKEDITOR_UPLOAD_PATH = "ckeditor/"

# 工具条配置
CKEDITOR_CONFIGS = {
    'default': {
        # 'toolbar': 'full', # full 显示全部工具
        # 'toolbar': 'Basic', # Basic 显示基本工具
        'toolbar': 'Custom',  # 自定义工具条的显示数量
        'toolbar_Custom': [
            ['Bold', 'Italic', 'Underline', 'Image', 'Styles', 'Format', 'Font', 'Fontsize'],
            ['NumberedList', 'BulletedList', '-', 'Outdent', 'Indent', '-', 'JustifyLeft', 'JustifyCenter',
             'JustifyRight', 'JustifyBlock'],
            ['Link', 'Unlink', 'Table'],
            ['RemoveFormat', 'Source']
        ],
        # 设置编辑器的高度
        'height': 120,
    },
}

总路由,luffycityapi.urls,代码:

urlpatterns = [
    # ...
    path('ckeditor/', include('ckeditor_uploader.urls')),
    # ...
]

经过上面的配置以后,我们就可以让admin站点在显示模型管理器中的字段时,把models.TextField字段换成富文本字段。

ckeditor安装成功可以允许开发者在模型中设置2个富文本字段。

# 不支持上传文件
from ckeditor.fields import RichTextField
# 支持上传文件
from ckeditor_uploader.fields import RichTextUploadingField

# 原来的models.TextField字段中所有的设置信息全部不需要改动,因为上面这2个字段都是models.TextField的子类。

所以我们现在可以把课程相关模型所有的models.TextField字段替换成富文本字段了。

courses.models,代码:

from luffycityapi.utils.models import models, BaseModel
from ckeditor.fields import RichTextField  # 不支持上传文件
from ckeditor_uploader.fields import RichTextUploadingField  # 支持上传文件

# Create your models here.
class CourseDirection(BaseModel):
    name = models.CharField(max_length=255, unique=True, verbose_name="方向名称")
    remark = RichTextField(default="", blank=True, null=True, verbose_name="方向描述")
    recomment_home_hot = models.BooleanField(default=False, verbose_name="是否推荐到首页新课栏目")
    recomment_home_top = models.BooleanField(default=False, verbose_name="是否推荐到首页必学栏目")

    class Meta:
        db_table = "lf_course_direction"
        verbose_name = "学习方向"
        verbose_name_plural = verbose_name


class CourseCategory(BaseModel):
    name = models.CharField(max_length=255, unique=True, verbose_name="分类名称")
    remark = RichTextField(default="", blank=True, null=True, verbose_name="分类描述")
    direction = models.ForeignKey("CourseDirection", related_name="category_list", db_constraint=False, on_delete=models.DO_NOTHING, verbose_name="学习方向")

    class Meta:
        db_table = "lf_course_category"
        verbose_name = "课程分类"
        verbose_name_plural = verbose_name


class Course(BaseModel):
    course_type = (
        (0, '付费购买'),
        (1, '会员专享'),
        (2, '学位课程'),
    )
    level_choices = (
        (0, '初级'),
        (1, '中级'),
        (2, '高级'),
    )
    status_choices = (
        (0, '上线'),
        (1, '下线'),
        (2, '预上线'),
    )
    course_cover = models.ImageField(upload_to="course/cover", max_length=255, verbose_name="封面图片", blank=True, null=True)
    course_video = models.FileField(upload_to="course/video", max_length=255, verbose_name="封面视频", blank=True, null=True)
    course_type = models.SmallIntegerField(choices=course_type,default=0, verbose_name="付费类型")
    level = models.SmallIntegerField(choices=level_choices, default=1, verbose_name="难度等级")
    description = RichTextUploadingField(null=True, blank=True, verbose_name="详情介绍")
    pub_date = models.DateField(auto_now_add=True, verbose_name="发布日期")
    period = models.IntegerField(default=7, verbose_name="建议学习周期(day)")
    attachment_path = models.FileField(max_length=1000, blank=True, null=True, verbose_name="课件路径")
    attachment_link = models.CharField(max_length=1000, blank=True, null=True, verbose_name="课件链接")
    status = models.SmallIntegerField(choices=status_choices, default=0, verbose_name="课程状态")
    students = models.IntegerField(default=0, verbose_name="学习人数")
    lessons = models.IntegerField(default=0, verbose_name="总课时数量")
    pub_lessons = models.IntegerField(default=0, verbose_name="已更新课时数量")
    price = models.DecimalField(max_digits=10,decimal_places=2, verbose_name="课程原价",default=0)
    recomment_home_hot = models.BooleanField(default=False, verbose_name="是否推荐到首页新课栏目")
    recomment_home_top = models.BooleanField(default=False, verbose_name="是否推荐到首页必学栏目")
    direction = models.ForeignKey("CourseDirection", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="学习方向")
    category = models.ForeignKey("CourseCategory", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="课程分类")
    teacher = models.ForeignKey("Teacher", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="授课老师")

    class Meta:
        db_table = "lf_course_info"
        verbose_name = "课程信息"
        verbose_name_plural = verbose_name


class Teacher(BaseModel):
    role_choices = (
        (0, '讲师'),
        (1, '导师'),
        (2, '班主任'),
    )

    role = models.SmallIntegerField(choices=role_choices, default=0, verbose_name="讲师身份")
    title = models.CharField(max_length=64, verbose_name="职位、职称")
    signature = models.CharField(max_length=255, blank=True, null=True, verbose_name="导师签名")
    avatar = models.ImageField(upload_to="teacher", null=True, verbose_name="讲师头像")
    brief = RichTextUploadingField(max_length=1024, verbose_name="讲师描述")

    class Meta:
        db_table = "lf_teacher"
        verbose_name = "讲师信息"
        verbose_name_plural = verbose_name


class CourseChapter(BaseModel):
    """课程章节"""
    orders = models.SmallIntegerField(default=1, verbose_name="第几章")
    summary = RichTextUploadingField(blank=True, null=True, verbose_name="章节介绍")
    pub_date = models.DateField(auto_now_add=True, verbose_name="发布日期")
    course = models.ForeignKey("Course", related_name='chapter_list', on_delete=models.CASCADE, db_constraint=False, verbose_name="课程名称")

    class Meta:
        db_table = "lf_course_chapter"
        verbose_name = "课程章节"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s-第%s章-%s" % (self.course.name, self.orders, self.name)


class CourseLesson(BaseModel):
    """课程课时"""
    lesson_type_choices = (
        (0, '文档'),
        (1, '练习'),
        (2, '视频'),
    )

    orders = models.SmallIntegerField(default=1, verbose_name="第几课时")
    lesson_type = models.SmallIntegerField(default=2, choices=lesson_type_choices, verbose_name="课时种类")
    lesson_link = models.CharField(max_length=255, blank=True, null=True, help_text="若是video,填视频地址或者视频id,若是文档,填文档地址", verbose_name="课时链接")
    duration = models.CharField(blank=True, null=True, max_length=32, verbose_name="课时时长")  # 仅在前端展示使用
    pub_date = models.DateTimeField(auto_now_add=True, verbose_name="发布时间")
    free_trail = models.BooleanField(default=False, verbose_name="是否可试看")
    recomment = models.BooleanField(default=False, verbose_name="是否推荐到课程列表")
    chapter = models.ForeignKey("CourseChapter", related_name='lesson_list', on_delete=models.CASCADE, db_constraint=False, verbose_name="章节")
    course = models.ForeignKey("Course", related_name="lesson_list", on_delete=models.DO_NOTHING, db_constraint=False, verbose_name="课程")

    class Meta:
        db_table = "lf_course_lesson"
        verbose_name = "课程课时"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s-第%s章-%s-第%s课时-%s" % (self.course.name,self.chapter.orders, self.chapter.name, self.orders, self.name)

因为这个字段属于ckeditor修改前端外观的而已,所以对于ORM而言,本质上来说还是models.TextField字段,所以不用执行数据迁移。

课程相关模型的admin站点配置在列表页中展示模型字段,courses/admin.py,代码:

from django.contrib import admin
from .models import CourseDirection, CourseCategory, Course, Teacher, CourseChapter, CourseLesson

class CourseCategoryInLine(admin.StackedInline):
    """课程分类的内嵌类"""
    model = CourseCategory
    fields = ["id","name","orders"]


class CourseDirectionModelAdmin(admin.ModelAdmin):
    """学习方向的模型管理器"""
    list_display = ["id","name","recomment_home_hot","recomment_home_top"]
    # 默认排序字段
    ordering = ["id"]
    # 字段过滤
    list_filter = ["recomment_home_hot", "recomment_home_top"]
    # 搜索字段
    search_fields = ["name"]
    # 内嵌外键数据
    inlines = [CourseCategoryInLine, ]
    # 分页配置,一页数据量
    list_per_page = 10

admin.site.register(CourseDirection, CourseDirectionModelAdmin)


class CourseCategoryModelAdmin(admin.ModelAdmin):
    """课程分类的模型管理器"""
    list_display = ["id","name","direction"]

    ordering = ["id"]
    list_filter = ["direction"]
    search_fields = ["name"]
    # 分页配置,一页数据量
    list_per_page = 10

    # 更新数据时的表单配置项
    fieldsets = (
        ("必填", {'fields': ('name','direction', 'remark')}),
        ("选填", {
            'classes': ('collapse',),
            'fields': ('is_show', 'orders'),
        }),
    )

    # 添加数据时的表单配置项
    add_fieldsets = (
        (None, {
            'classes': ('wide',),
            'fields': ('name', 'direction', 'remark'),
        }),
    )

    # 当前方法会在显示表单的时候,自动执行,返回值就是表单配置项
    def get_fieldsets(self, request, obj=None):
        """
        获取表单配置项
        :param request: 客户端的http请求对象
        :param obj:     本次修改的模型对象,如果是添加数据操作,则obj为None
        :return:
        """
        if not obj:
            return self.add_fieldsets
        return super().get_fieldsets(request, obj)


admin.site.register(CourseCategory, CourseCategoryModelAdmin)


class CourseModelAdmin(admin.ModelAdmin):
    """课程信息的模型管理器"""
    list_display = ["id","name",'course_cover',"course_type","level","pub_date","students","lessons","price"]
    # 分页配置,一页数据量
    list_per_page = 10

admin.site.register(Course, CourseModelAdmin)


class TeacherModelAdmin(admin.ModelAdmin):
    """讲师信息的模型管理器"""
    list_display = ["id","name","avatar","title","role","signature"]
    # 分页配置,一页数据量
    list_per_page = 10
    # 搜索字段
    search_fields = ["name", "title", "role", "signature"]


admin.site.register(Teacher, TeacherModelAdmin)


class CourseChapterModelAdmin(admin.ModelAdmin):
    """课程章节的模型管理器"""
    list_display = ["id","text", "pub_date",]
    # 分页配置,一页数据量
    list_per_page = 10


admin.site.register(CourseChapter, CourseChapterModelAdmin)


class CourseLessonModelAdmin(admin.ModelAdmin):
    """课程课时的模型管理器"""
    list_display = ["id", "text2", "lesson_type", "duration", "pub_date", "free_trail"]
    # 分页配置,一页数据量
    list_per_page = 10

    # 下面是旧版本写法,django2.0版本  ->  django3.0以后,建议在模型中声明自定义字段  ---> text2属于新版本写法
    # def text(self, obj):
        # return obj.__str__()
    # text.admin_order_field = "orders"
    # text.short_description = "课时名称"


admin.site.register(CourseLesson, CourseLessonModelAdmin)

courses/modes.py,代码:

class CourseChapter(BaseModel):
    """课程章节"""
    orders = models.SmallIntegerField(default=1, verbose_name="第几章")
    summary = RichTextUploadingField(blank=True, null=True, verbose_name="章节介绍")
    pub_date = models.DateField(auto_now_add=True, verbose_name="发布日期")
    course = models.ForeignKey("Course", related_name='chapter_list', on_delete=models.CASCADE, db_constraint=False, verbose_name="课程名称")

    class Meta:
        db_table = "lf_course_chapter"
        verbose_name = "课程章节"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s-第%s章-%s" % (self.course.name, self.orders, self.name)
    # 自定义字段
    def text(self):
        return self.__str__()
    # admin站点配置排序规则和显示的字段文本提示
    text.short_description = "章节名称"
    text.allow_tags = True
    text.admin_order_field = "orders"


class CourseLesson(BaseModel):
    """课程课时"""
    lesson_type_choices = (
        (0, '文档'),
        (1, '练习'),
        (2, '视频'),
    )

    orders = models.SmallIntegerField(default=1, verbose_name="第几课时")
    lesson_type = models.SmallIntegerField(default=2, choices=lesson_type_choices, verbose_name="课时种类")
    lesson_link = models.CharField(max_length=255, blank=True, null=True, help_text="若是video,填视频地址或者视频id,若是文档,填文档地址", verbose_name="课时链接")
    duration = models.CharField(blank=True, null=True, max_length=32, verbose_name="课时时长")  # 仅在前端展示使用
    pub_date = models.DateTimeField(auto_now_add=True, verbose_name="发布时间")
    free_trail = models.BooleanField(default=False, verbose_name="是否可试看")
    recomment = models.BooleanField(default=False, verbose_name="是否推荐到课程列表")
    chapter = models.ForeignKey("CourseChapter", related_name='lesson_list', on_delete=models.CASCADE, db_constraint=False, verbose_name="章节")
    course = models.ForeignKey("Course", related_name="lesson_list", on_delete=models.DO_NOTHING, db_constraint=False, verbose_name="课程")

    class Meta:
        db_table = "lf_course_lesson"
        verbose_name = "课程课时"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s-第%s章-%s-第%s课时-%s" % (self.course.name,self.chapter.orders, self.chapter.name, self.orders, self.name)

    def text2(self):
        return self.__str__()

    text2.short_description = "课时名称"
    text2.allow_tags = True
    text2.admin_order_field = "orders"

提交版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 安装配置simpleUI美化admin站点并使用富文本编辑器增强多行文本框"
# git push
git push --set-upstream origin feature/course

作业:在admin站点展示用户模型相关数据。

Admin站点配置

公共配置,settings/dev.py,代码:

# admin站点公共配置
from django.contrib import admin
admin.AdminSite.site_header = 'luffycity'
admin.AdminSite.site_title = 'luffycity在线教育站点管理'

# 登录界面logo
SIMPLEUI_LOGO = '/uploads/logo.png'
# 快速操作
SIMPLEUI_HOME_QUICK = True
# 服务器信息
SIMPLEUI_HOME_INFO = True

# 关闭simpleui内置的使用分析
SIMPLEUI_ANALYSIS = False
# 离线模式
SIMPLEUI_STATIC_OFFLINE = True
# 首页图标地址
SIMPLEUI_INDEX = 'http://www.luffycity.cn:3000/'

Admin站点关联外键数据

一个学习方向下面有多个课程分类,如果我们希望在查看或编辑某个学习方向的信息时,希望Admin站点在显示学习方向的信息的同时也一同显示并编辑同属该方向下的所有课程分类信息。我们可以使用django.admin提供的 TabularInline 和 StackedInline 内嵌类来实现。这2个类的使用一样,不同的是排版效果:
	TabularInline让外键对应的数据横向排列(表格的一行),
	StackedInline让外键对应的数据竖着排(表单格式)。

image-20220927085523896

image-20220927085755929

courses.admin,代码:

# TabularInline让外键对应的数据横向排列(表格的一行),
# StackedInline让外键对应的数据竖着排(表单格式)。
class CourseCategoryInLine(admin.StackedInline):
    """课程分类的内嵌类"""
    model = CourseCategory
    fields = ["id","name","orders"]

class CourseDirectionModelAdmin(admin.ModelAdmin):
    """学习方向的模型管理器"""
    list_display = ["id","name","recomment_home_hot","recomment_home_top"]
    ordering = ["id"]
    list_filter = ["recomment_home_hot","recomment_home_top"]
    search_fields = ["name"]
    # 内嵌外键数据
    inlines = [CourseCategoryInLine, ]

给图片字段生成缩略图

在项目开发中,经常会遇到需要以图片的方式来展示商品/课程/物品/人物,但是如果每次展示的图片都是高清图片,则客户端访问页面时,下载这个图片就占据我们服务端的一定的网络资源,因为高清图片往往比较大,也可以影响用户访问页面的速度。让项目在列表中展示缩略图即可,真正的高清图直接在详情页中展示。

pip install django-stdimage

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

INSTALLED_APPS = [
    'simpleui', # admin界面美化,必须写在admin上面
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    "rest_framework",  # 注意:记得加入 rest_framework
    'corsheaders',  # cors跨域子应用
    'ckeditor',   # 富文本编辑器
    'ckeditor_uploader',  # 富文本编辑器上传文件子用用
    'stdimage',  # 生成缩略图
    
    "home",
    'users',
    'courses',
]

courses.models,代码:

from models import models, BaseModel
from ckeditor.fields import RichTextField
from ckeditor_uploader.fields import RichTextUploadingField
from stdimage import StdImageField
from django.utils.safestring import mark_safe

# Create your models here.

class CourseDirection(BaseModel):
    name = models.CharField(max_length=255, unique=True, verbose_name="方向名称")
    remark = RichTextUploadingField(default="", blank=True, null=True, verbose_name="方向描述")
    recomment_home_hot = models.BooleanField(default=False, verbose_name="是否推荐到首页新课栏目")
    recomment_home_top = models.BooleanField(default=False, verbose_name="是否推荐到首页必学栏目")

    class Meta:
        db_table = "lf_course_direction"
        verbose_name = "学习方向"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name


class CourseCategory(BaseModel):
    name = models.CharField(max_length=255, unique=True, verbose_name="分类名称")
    remark = RichTextUploadingField(default="", blank=True, null=True, verbose_name="分类描述")
    # related_name 反向引用属性名称
    # 数据库外键设置为虚拟外键:db_constraint=False
    direction = models.ForeignKey("CourseDirection", related_name="category_list", on_delete=models.DO_NOTHING, db_constraint=False, verbose_name="学习方向")

    class Meta:
        db_table = "lf_course_category"
        verbose_name = "课程分类"
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name


class Course(BaseModel):
    course_type = (
        (0, '付费购买'),
        (1, '会员专享'),
        (2, '学位课程'),
    )
    level_choices = (
        (0, '初级'),
        (1, '中级'),
        (2, '高级'),
    )
    status_choices = (
        (0, '上线'),
        (1, '下线'),
        (2, '预上线'),
    )
    # course_cover = models.ImageField(upload_to="course/cover", max_length=255, verbose_name="封面图片", blank=True, null=True)
    course_cover = StdImageField(variations={
        'thumb_1080x608': (1080, 608),   # 高清图
        'thumb_540x304': (540, 304),    # 中等比例,
        'thumb_108x61': (108, 61, True),  # 小图(第三个参数表示保持图片质量),
    }, max_length=255, delete_orphans=True, upload_to="course/cover", null=True, verbose_name="封面图片",blank=True)

    course_video = models.FileField(upload_to="course/video", max_length=255, verbose_name="封面视频", blank=True, null=True)
    course_type = models.SmallIntegerField(choices=course_type,default=0, verbose_name="付费类型")
    level = models.SmallIntegerField(choices=level_choices, default=1, verbose_name="难度等级")
    description = RichTextUploadingField(null=True, blank=True, verbose_name="详情介绍")
    pub_date = models.DateField(auto_now_add=True, verbose_name="发布日期")
    period = models.IntegerField(default=7, verbose_name="建议学习周期(day)")
    attachment_path = models.FileField(max_length=1000, blank=True, null=True, verbose_name="课件路径")
    attachment_link = models.CharField(max_length=1000, blank=True, null=True, verbose_name="课件链接")
    status = models.SmallIntegerField(choices=status_choices, default=0, verbose_name="课程状态")
    students = models.IntegerField(default=0, verbose_name="学习人数")
    lessons = models.IntegerField(default=0, verbose_name="总课时数量")
    pub_lessons = models.IntegerField(default=0, verbose_name="已更新课时数量")
    price = models.DecimalField(max_digits=10,decimal_places=2, verbose_name="课程原价",default=0)
    recomment_home_hot = models.BooleanField(default=False, verbose_name="是否推荐到首页新课栏目")
    recomment_home_top = models.BooleanField(default=False, verbose_name="是否推荐到首页必学栏目")
    direction = models.ForeignKey("CourseDirection", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="学习方向")
    category = models.ForeignKey("CourseCategory", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="课程分类")
    teacher = models.ForeignKey("Teacher", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="授课老师")

    class Meta:
        db_table = "lf_course_info"
        verbose_name = "课程信息"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s" % self.name

    def course_cover_small(self):
        if self.course_cover:
            return mark_safe(f'<img style="border-radius: 0%;" src="{self.course_cover.thumb_108x61.url}">')
        return ""

    course_cover_small.short_description = "封面图片(108x61)"
    course_cover_small.allow_tags = True
    course_cover_small.admin_order_field = "course_cover"

    def course_cover_medium(self):
        if self.course_cover:
            return mark_safe(f'<img style="border-radius: 0%;" src="{self.course_cover.thumb_540x304.url}">')
        return ""

    course_cover_medium.short_description = "封面图片(540x304)"
    course_cover_medium.allow_tags = True
    course_cover_medium.admin_order_field = "course_cover"

    def course_cover_large(self):
        if self.course_cover:
            return mark_safe(f'<img style="border-radius: 0%;" src="{self.course_cover.thumb_1080x608.url}">')
        return ""

    course_cover_large.short_description = "封面图片(1080x608)"
    course_cover_large.allow_tags = True
    course_cover_large.admin_order_field = "course_cover"

class Teacher(BaseModel):
    role_choices = (
        (0, '讲师'),
        (1, '导师'),
        (2, '班主任'),
    )

    role = models.SmallIntegerField(choices=role_choices, default=0, verbose_name="讲师身份")
    title = models.CharField(max_length=64, verbose_name="职位、职称")
    signature = models.CharField(max_length=255, blank=True, null=True, verbose_name="导师签名")
    
    # avatar = models.ImageField(upload_to="teacher", null=True, verbose_name="讲师头像")
    # 使用缩略图提供的StdImageFiled字段以后,每次客户端提交图片时,stdImage模块会自动根据字段里面的配置项生成对应尺寸的缩略图
    avatar = StdImageField(variations={
        'thumb_800x800': (800, 800),  # 'large': (800, 800),
        'thumb_400x400': (400, 400),  # 'medium': (400, 400),
        'thumb_50x50': (50, 50, True),  # 'small': (50, 50, True),
    }, delete_orphans=True, upload_to="teacher", null=True, verbose_name="讲师头像")

    brief = RichTextUploadingField(max_length=1024, verbose_name="讲师描述")

    class Meta:
        db_table = "lf_teacher"
        verbose_name = "讲师信息"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s" % self.name


    def avatar_small(self):
        if self.avatar:
            return mark_safe(f'<img style="border-radius: 100%;" src="{self.avatar.thumb_50x50.url}">')
        return ""

    avatar_small.short_description = "头像信息(50x50)"
    avatar_small.allow_tags = True
    avatar_small.admin_order_field = "avatar"

    def avatar_medium(self):
        if self.avatar:
            return mark_safe(f'<img style="border-radius: 100%;" src="{self.avatar.thumb_400x400.url}">')
        return ""

    avatar_medium.short_description = "头像信息(400x400)"
    avatar_medium.allow_tags = True
    avatar_medium.admin_order_field = "avatar"

    def avatar_large(self):
        if self.avatar:
            return mark_safe(f'<img style="border-radius: 100%;" src="{self.avatar.thumb_800x800.url}">')
        return ""

    avatar_large.short_description = "头像信息(800x800)"
    avatar_large.allow_tags = True
    avatar_large.admin_order_field = "avatar"

class CourseChapter(BaseModel):
    """课程章节"""
    orders = models.SmallIntegerField(default=1, verbose_name="第几章")
    summary = RichTextUploadingField(blank=True, null=True, verbose_name="章节介绍")
    pub_date = models.DateField(auto_now_add=True, verbose_name="发布日期")
    course = models.ForeignKey("Course", related_name='chapter_list', on_delete=models.CASCADE, db_constraint=False, verbose_name="课程名称")

    class Meta:
        db_table = "lf_course_chapter"
        verbose_name = "课程章节"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s-第%s章-%s" % (self.course.name, self.orders, self.name)

    # 自定义字段
    def text(self):
        return self.__str__()
    # admin站点配置排序规则和显示的字段文本提示
    text.short_description = "章节名称"
    text.allow_tags = True
    text.admin_order_field = "orders"

class CourseLesson(BaseModel):
    """课程课时"""
    lesson_type_choices = (
        (0, '文档'),
        (1, '练习'),
        (2, '视频'),
    )

    orders = models.SmallIntegerField(default=1, verbose_name="第几节")
    lesson_type = models.SmallIntegerField(default=2, choices=lesson_type_choices, verbose_name="课时种类")
    lesson_link = models.CharField(max_length=255, blank=True, null=True, help_text="若是video,填视频地址或者视频id,若是文档,填文档地址", verbose_name="课时链接")
    duration = models.CharField(blank=True, null=True, max_length=32, verbose_name="课时时长")  # 仅在前端展示使用
    pub_date = models.DateTimeField(auto_now_add=True, verbose_name="发布时间")
    free_trail = models.BooleanField(default=False, verbose_name="是否可试看")
    recomment = models.BooleanField(default=False, verbose_name="是否推荐到课程列表")
    chapter = models.ForeignKey("CourseChapter", related_name='lesson_list', on_delete=models.CASCADE, db_constraint=False, verbose_name="章节")
    course = models.ForeignKey("Course", related_name="lesson_list", on_delete=models.DO_NOTHING, db_constraint=False, verbose_name="课程")

    class Meta:
        db_table = "lf_course_lesson"
        verbose_name = "课程课时"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s-%s" % (self.chapter, self.name)

    def text2(self):
        return self.__str__()

    text2.short_description = "课时名称"
    text2.allow_tags = True
    text2.admin_order_field = "orders"

courses.admin,代码:

class CourseModelAdmin(admin.ModelAdmin):
    """课程信息的模型管理器"""
    list_display = ["id","name",'course_cover_small',"course_type","level","pub_date","students","lessons","price"]
    # 分页配置,一页数据量
    list_per_page = 10

admin.site.register(Course, CourseModelAdmin)


class TeacherModelAdmin(admin.ModelAdmin):
    """讲师信息的模型管理器"""
    list_display = ["id","name","avatar_small","title","role","signature"]
 
	...

完整的course.admin,代码:

from django.contrib import admin
from .models import CourseDirection, CourseCategory, Course, Teacher, CourseChapter, CourseLesson


# Register your models here.
class CourseCategoryInLine(admin.StackedInline):
    """课程分类的内嵌类"""
    model = CourseCategory
    fields = ["id","name","orders"]


class CourseDirectionModelAdmin(admin.ModelAdmin):
    """学习方向的模型管理器"""
    list_display = ["id","name","recomment_home_hot","recomment_home_top"]
    # 默认排序字段
    ordering = ["id"]
    # 字段过滤
    list_filter = ["recomment_home_hot", "recomment_home_top"]
    # 搜索字段
    search_fields = ["name"]
    # 内嵌外键数据
    inlines = [CourseCategoryInLine, ]
    # 分页配置,一页数据量
    list_per_page = 10


admin.site.register(CourseDirection, CourseDirectionModelAdmin)


class CourseCategoryModelAdmin(admin.ModelAdmin):
    """课程分类的模型管理器"""
    list_display = ["id","name","direction"]

    ordering = ["id"]
    list_filter = ["direction"]
    search_fields = ["name"]
    # 分页配置,一页数据量
    list_per_page = 10

    # 更新数据时的表单配置项
    fieldsets = (
        ("必填", {'fields': ('name','direction', 'remark')}),
        ("选填", {
            'classes': ('collapse',),
            'fields': ('is_show', 'orders'),
        }),
    )

    # 添加数据时的表单配置项
    add_fieldsets = (
        (None, {
            'classes': ('wide',),
            'fields': ('name', 'direction', 'remark'),
        }),
    )

    # 当前方法会在显示表单的时候,自动执行,返回值就是表单配置项
    def get_fieldsets(self, request, obj=None):
        """
        获取表单配置项
        :param request: 客户端的http请求对象
        :param obj:     本次修改的模型对象,如果是添加数据操作,则obj为None
        :return:
        """
        if not obj:
            return self.add_fieldsets
        return super().get_fieldsets(request, obj)


admin.site.register(CourseCategory, CourseCategoryModelAdmin)


class CourseModelAdmin(admin.ModelAdmin):
    """课程信息的模型管理器"""
    list_display = ["id","name",'course_cover_small',"course_type","level","pub_date","students","lessons","price"]
    # 分页配置,一页数据量
    list_per_page = 10


admin.site.register(Course, CourseModelAdmin)


class TeacherModelAdmin(admin.ModelAdmin):
    """讲师信息的模型管理器"""
    list_display = ["id","name","avatar_small","title","role","signature"]
    # 分页配置,一页数据量
    list_per_page = 10
    # 搜索字段
    search_fields = ["name", "title", "role", "signature"]

admin.site.register(Teacher, TeacherModelAdmin)


class CourseChapterModelAdmin(admin.ModelAdmin):
    """课程章节的模型管理器"""
    list_display = ["id","text", "pub_date",]
    # 分页配置,一页数据量
    list_per_page = 10

admin.site.register(CourseChapter, CourseChapterModelAdmin)


class CourseLessonModelAdmin(admin.ModelAdmin):
    """课程课时的模型管理器"""
    list_display = ["id","text", "text2", "lesson_type", "duration", "pub_date", "free_trail"]
    # 分页配置,一页数据量
    list_per_page = 10

    # 下面是旧版本写法,django2.0版本  ->  django3.0以后,建议在模型中声明自定义字段  ---> text2属于新版本写法
    def text(self, obj):
        return obj.__str__()
    text.admin_order_field = "orders"
    text.short_description = "课时名称"


admin.site.register(CourseLesson, CourseLessonModelAdmin)


提交版本

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: admin站点配置以及给图片字段生成缩略图"
git push

测试数据

学习方向:

truncate table lf_course_direction;
INSERT INTO luffycity.lf_course_direction (id,orders, is_show, is_deleted, created_time, updated_time, name, remark, recomment_home_hot, recomment_home_top) VALUES
(1,1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '前端开发', '', 1, 1),
(2,1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '后端开发', '', 1, 1),
(3,1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '移动开发', '', 1, 1),
(4,1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '计算机基础', '', 1, 1),
(5,1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '前沿技术', '', 1, 1),
(6,1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '云计算', '', 1, 1),
(7,1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '大数据', '', 1, 1),
(8,1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '运维', '', 1, 1),
(9,1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '测试', '', 1, 1),
(10,1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '数据库', '', 1, 1),
(11,1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', 'UI设计', '', 1, 1),
(12,1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '多媒体', '', 1, 1),
(13,1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '游戏', '', 1, 1),
(14,1, 1, 0, '2021-07-22 05:42:01.290060', '2021-07-22 05:42:01.290088', '求职面试', '', 1, 1);

课程分类:

truncate table lf_course_category;
INSERT INTO luffycity.lf_course_category (orders, is_show, is_deleted, created_time, updated_time, name, remark, direction_id) VALUES 
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Vue.js', '', 1),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Typescript', '', 1),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'React.js', '', 1),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'HTML', '', 1),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'JavaScript', '', 1),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Angular', '', 1),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Node.js', '', 1),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'WebApp', '', 1),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '小程序', '', 1),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '前端工具', '', 1),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'HTML/CSS', '', 1),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Html5', '', 1),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'CSS3', '', 1),

(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Java', '', 2),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'SpringBoot', '', 2),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Spring Cloud', '', 2),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'SSM', '', 2),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'PHP', '', 2),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '.net', '', 2),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Python', '', 2),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '爬虫', '', 2),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Django', '', 2),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Flask', '', 2),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Go', '', 2),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'C', '', 2),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'C++', '', 2),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'C#', '', 2),

(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Flutter', '', 3),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Android', '', 3),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'iOS', '', 3),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'React native', '', 3),


(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '计算机网络', '', 4),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '算法与数据结构', '', 4),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '数学', '', 4),


(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '微服务', '', 5),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '机器学习', '', 5),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '深度学习', '', 5),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '计算机视觉', '', 5),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '自然语言处理', '', 5),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '数据分析&挖掘', '', 5),


(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '大数据', '', 6),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Hadoop', '', 6),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Spark', '', 6),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Hbase', '', 6),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Flink', '', 6),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Storm', '', 6),

(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '阿里云', '', 7),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '容器', '', 7),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Docker', '', 7),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Kubernetes', '', 7),

(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '运维', '', 8),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '自动化运维', '', 8),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '中间件', '', 8),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Linux', '', 8),

(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '测试', '', 9),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '功能测试', '', 9),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '性能测试', '', 9),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '自动化测试', '', 9),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '接口测试', '', 9),

(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'MySQL', '', 10),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Redis', '', 10),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'MongoDB', '', 10),


(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '设计基础', '', 11),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '设计工具', '', 11),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'APPUI设计', '', 11),

(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'Unity 3D', '', 13),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'cocos creator', '', 13),

(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', '求职面试', '', 14),
(1, 1, 0, '2021-07-22 08:00:19.366304', '2021-07-22 08:00:19.367343', 'leetcode', '', 14);

讲师信息:

truncate table lf_teacher;
INSERT INTO luffycity.lf_teacher (id, name, orders, is_show, is_deleted, created_time, updated_time, role, title, signature, avatar, brief) VALUES
(1, '张老师', 1, 1, 0, '2021-07-22 04:31:27.741562', '2021-07-22 04:31:27.741708', 0, 'BAT中某某技术总监', 'xxxxxxxx', 'teacher/avatar.jpg', '<p>2009入行,在IT行业深耕13年,删库无数,行内同行送称号:删库小王子。</p>'),
(2, '李老师', 1, 1, 0, '2021-07-22 04:31:27.741562', '2021-07-22 04:31:27.741708', 0, 'BAT中某某技术顾问', 'xxxxxxxx', 'teacher/avatar.jpg', '<p>百变小王子,各种框架信手拈来。</p>'),
(3, '王老师', 1, 1, 0, '2021-07-22 04:31:27.741562', '2021-07-22 04:31:27.741708', 0, 'BAT中某某技术主管', 'xxxxxxxx', 'teacher/avatar.jpg', '<p>草根站长,专注运维20年。</p>'),
(4, '红老师', 1, 1, 0, '2021-07-22 04:31:27.741562', '2021-07-22 04:31:27.741708', 0, 'BAT中某某项目经理', 'xxxxxxxx', 'teacher/avatar.jpg', '<p>美女讲师,说话好听。</p>');

添加测试数据在工作中一共有三种方式:

  1. 可以根据django的manage.py指令进行[自定义终端命令]
  2. 可以采用第三方模块Faker来完成数据的模拟添加(Faker模块可以写在上面第一种方法里面)
  3. 可以使用python脚本或者shell脚本来完成

自定义终端命令

文档:https://docs.djangoproject.com/zh-hans/3.2/howto/custom-management-commands/

在子应用目录下添加一个 management/commands 包目录,然后这个commands里面处理__init__.py以外其他文件的文件名将作为django-admin或者python manage.py的命令选项了。注意:文件名不能以_开头,否则不会被识别为命令。

接着我们可以在commands包下根据自己的业务需要,编写对应的命令。例如,我们现在需要在courses子应用下添加课程对应的测试数据,所以我们在courses下创建 management/commands 包目录,然后创建命令文件example.py,代码:

import constants
from django.core.management.base import BaseCommand, CommandError
from courses.models import Teacher

# 类名必须是Command而且一个文件就是一个命令类,这个命令类必须直接或间接继承BaseCommand
class Command(BaseCommand):
    help = '添加课程相关的测试数据'

    # 如果当前命令,需要接受来自终端的参数,可以使用add_arguments
    def add_arguments(self, parser):
        pass
        # 位置参数,必填项
        # parser.add_argument('name', nargs='+', type=int)

        # 命令参数,可选项
        # parser.add_argument(
        #     '--table',
        #     action='store_true',
        #     help='Delete poll instead of closing it',
        # )

    # 命令执行的核心方法,
    def handle(self, *args, **options):
        """添加测试数据"""
        print("添加测试数据")

        Teacher.objects.create(
            name="赵小明",
            avatar="teacher/avatar.jpg",
            role=1,
            title="老师",
            signature="从业3年,管理班级无数",
            brief="从业3年,管理班级无数",
        )

Faker添加模拟数据

文档:https://faker.readthedocs.io/en/stable/locales/zh_CN.html#

github:https://github.com/joke2k/faker/

faker是一个在多个编程语言里面都比较常用的第三方工具类,它的作用就是可以提供非常有效的方式帮开发者生成一些模拟仿真的测试数据。

pip install faker

结合上面的自定义终端命令来实现,example.py,代码:

import constants,random
from django.core.management.base import BaseCommand, CommandError
from courses.models import Teacher
from faker import Faker
from django.conf import settings

# 类名必须是Command而且一个文件就是一个命令类,这个命令类必须直接或间接继承BaseCommand
class Command(BaseCommand):
    help = '添加课程相关的测试数据'

    # 如果当前命令,需要接受来自终端的参数,可以使用add_arguments
    def add_arguments(self, parser):
        # 位置参数,必填项
        # parser.add_argument('date_type', nargs='+', type=int, help="添加数据的类型")

        # 命令参数,可选项
        parser.add_argument(
            '--type',
            dest='type',
            default='teacher',
            type=str,
            help='测试数据的类型',
        )

        parser.add_argument(
            '--number',
            dest='number',
            default=10,
            type=int,
            help='添加数据的数量',
        )

    # 命令执行的核心方法,
    def handle(self, *args, **options):
        """添加课程相关的测试数据"""
        if options["type"] == "teacher":
            self.add_teacher(options)
        elif options["type"] == "direction":
            self.add_direction(options)

    def add_teacher(self,options):
        """添加授课老师的测试数据"""
        faker = Faker(["zh_CN"])
        for i in range(options["number"]):
            Teacher.objects.create(
                name=faker.unique.name(), # 唯一的姓名
                avatar="teacher/avatar.jpg",
                role=random.randint(0,2),
                title="老师",
                signature= "从业3年,管理班级无数",
                brief= f"从业3年,管理班级无数,联系电话:{faker.unique.phone_number()},邮箱地址:{faker.unique.company_email()}",
            )
        print("添加授课老师的测试数据完成....")

    def add_direction(self,options):
        """添加学习方向的测试数据"""
        print("添加学习方向的测试数据完成....")

终端下调用:

cd ~/Desktop/luffycity/luffycityapi
python manage.py example
python manage.py example --type=teacher
python manage.py example --type=direction
python manage.py example --type=teacher  --number=100

基于终端脚本来完成数据的添加

要编写一个python或者shell脚本,就要清楚一件事情,就是我们可以根据对应的语言来编写对应的终端代码,但是必须在首行的位置声明执行这些代码的解析器是谁?路径在哪里?

编写python脚本

首行指定运行当前代码的python解释器。写完整绝对路径

test1.py,代码:

#! /home/moluo/anaconda3/envs/luffycity/bin/python

# 首行以后的代码必须要符合python的语法
"""
针对通用代码的运行,可以直接使用系统内置的全局环境的python解释器,也可以使用虚拟环境的解析器
#! /usr/bin/python3
如果这个代码需要调用对应的第三方模块,那么就要写上安装该模块的python解释器
#! /home/moluo/anaconda3/envs/luffycity/bin/python
"""
import os, sys
from faker import Faker

faker = Faker(["zh_CN"])
user = faker.unique.name()
print(f"hello,{user}")

# python获取终端参数
try:
    dir = sys.argv[1]  # 0 ==> ./test1.py    1 ==> user
except Exception as e:
    dir = "2021"

# python直接执行shell命令
# ret = os.popen("ls -l")
ret = os.popen("ls -l")  # 等同于上一行
print(ret.read())

ret = os.popen(f"mkdir {dir} && cd {dir} && echo 'hello {user}' > index.html")  # 等同于上一行
print(ret.read())


编写shell脚本

首行指定运行当前代码的shell解释器。写完整路径。

test2.sh,代码:


执行脚本需要权限

不管编写的什么的脚本命令,编写完脚本以后,脚本本身因为操作系统默认会取消它的执行权限,所以我们通过以下命令来增加执行的权限。

chmod +x 对应的文件名
# chmod 755 对应的文件名

# 例如,给python脚本赋予执行的权限。文件名假设为:test1.py
chmod +x test1.py

# 例如,shell脚本赋予执行的权限,文件名假设为:test2.sh
chmod +x test2.sh

赋予了权限以后,就可以执行脚本了。

但是执行过程中, 一定使用相对路径的方式来执行这个脚本。

# 例如,上面的test1.py或者test2.sh
./test1.py
./test2.sh

# 如果不希望使用相对路径,则需要把当前文件所载的目录设置为环境变量才行。

使用shell命令来完成测试数据的添加

  1. 编写一个sql语句的文件test_data.sql

    -- 如果使用数据库本身的外键,则添加/删除/修改数据时,务必关闭原来表中的主外键约束功能
    set FOREIGN_KEY_CHECKS=0;
    
    -- 清空原有的课程信息表信息
    truncate table lf_course_info;
    
    -- 添加课程信息
    INSERT INTO luffycity.lf_course_info (id, name, orders, is_show, is_deleted, created_time, updated_time, course_cover, course_video, course_type, level, description, pub_date, period, attachment_path, attachment_link, status, students, lessons, pub_lessons, price, recomment_home_hot, recomment_home_top, category_id, direction_id, teacher_id)
    VALUES
    (1, '7天Typescript从入门到放弃', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-10.png', '', 0, 0, '<p>7天Typescript从入门到放弃</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 988, 100, 30, 998.00, 1, 1, 2, 1, 1),
    (2, '3天Typescript精修', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-9.png', '', 0, 0, '<p>3天Typescript精修</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 988, 100, 30, 998.00, 1, 1, 2, 1, 1),
    (3, '3天学会Vue基础', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-8.png', '', 0, 0, '<p>3天学会Vue基础</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 988, 100, 30, 998.00, 1, 1, 2, 1, 1);
    
    -- 如果使用数据库本身的外键,则添加/删除/修改数据以后,务必开启原来表中的主外键约束功能
    set FOREIGN_KEY_CHECKS=1;
    
    
  2. 编写一个shell脚本test_data.sh来执行上面的文件

    #! /bin/bash
    mysql -uroot -p123 luffycity < ./test_data.sql
    
    
  3. 赋予create_data.sh执行的权限

    chmod +x test_data.sh
    ./test_data.sh
    
    

提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "test: 添加测试数据的三种方式"
git push

在Admin站点中管理公共数据与用户数据

home.apps,代码:

from django.apps import AppConfig


class HomeConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'home'
    verbose_name="公共数据"
    verbose_name_plural = verbose_name

home.admin,代码:

from django.contrib import admin
from .models import Nav, Banner


# Register your models here.
class NavModelAdmin(admin.ModelAdmin):
    """导航菜单的模型管理器"""
    list_display = ["id","name","link","is_http"]


admin.site.register(Nav, NavModelAdmin)


class BannerModelAdmin(admin.ModelAdmin):
    """轮播广告的模型管理器"""
    list_display = ["id","image_html","link","is_http"]


admin.site.register(Banner, BannerModelAdmin)


home.models,代码:

from models import BaseModel, models
from django.utils.safestring import mark_safe
# Create your models here.


class Nav(BaseModel):
    """导航菜单"""
    # 字段选项
    # 模型对象.<字段名>  ---> 实际数据
    # 模型对象.get_<字段名>_display()  --> 文本提示
    POSITION_CHOICES = (
        # (实际数据, "文本提示"),
        (0, "顶部导航"),
        (1, "脚部导航"),
    )

    link = models.CharField(max_length=255, verbose_name="导航连接")
    is_http = models.BooleanField(default=False, verbose_name="是否站外连接地址")
    position = models.SmallIntegerField(default=0, choices=POSITION_CHOICES, verbose_name="导航位置")

    class Meta:
        db_table = "lf_nav"
        verbose_name = "导航菜单"
        verbose_name_plural = verbose_name


class Banner(BaseModel):
    # models.ImageField 表示该字段的内容,按图片格式进行处理,通过upload_to进行指定保存的目录
    # 图片的最终路径 = settings.MEDIA_ROOT / upload_to / 文件名
    # upload_to 支持格式化符号,%Y 表示年份 %m 表示月份,%d 表示日
    image = models.ImageField(upload_to="banner/%Y/", verbose_name="图片地址")
    link = models.CharField(max_length=500, verbose_name="链接地址")
    note = models.CharField(max_length=150, verbose_name='备注信息')
    is_http = models.BooleanField(default=False, verbose_name="是否外链地址",
                                  help_text="站点链接地址:http://www.baidu.com/book<br>站点链接地址:/book/")

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

    def image_html(self):
        if self.image:
            return mark_safe(f'<img style="border-radius: 0%;max-height: 100px; max-width: 400px;" src="{self.image.url}">')
        return ""

    image_html.short_description = "广告图片"
    image_html.allow_tags = True
    image_html.admin_order_field = "image"

users/apps,代码:

from django.apps import AppConfig


class UsersConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'users'
    verbose_name="用户管理"
    verbose_name_plural = verbose_name


users.admin,代码:

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin, _
from .models import User
# Register your models here.


class UserModelAdmin(UserAdmin):
    list_display = ["id", "username", "avatar_small", "money", "credit", "mobile"]
    # fieldsets 和 add_fieldsets 都在从UserAdmin中复制粘贴过来,重写加上自己需要的字段的。
    fieldsets = (
        (None, {'fields': ('username', 'password', 'avatar')}),
        (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}),
        (_('Permissions'), {
            'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions'),
        }),
        (_('Important dates'), {'fields': ('last_login', 'date_joined')}),
    )
    add_fieldsets = (
        (None, {
            'classes': ('wide',),
            'fields': ('username', 'password1', 'password2'),
        }),
    )
    ordering = ('id',)


admin.site.register(User, UserModelAdmin)


users.models,代码:

# Create your models here.
from django.contrib.auth.models import AbstractUser,models
from stdimage import StdImageField
from django.utils.safestring import mark_safe

class User(AbstractUser):
    mobile = models.CharField(max_length=15, unique=True, verbose_name='手机号')
    money = models.DecimalField(max_digits=9, default=0.0, decimal_places=2, verbose_name="钱包余额")
    credit = models.IntegerField(default=0, verbose_name="积分")
    # avatar = models.ImageField(upload_to="avatar/%Y", null=True, default="", verbose_name="个人头像")
    avatar = StdImageField(variations={
            'thumb_400x400': (400, 400),   # 'medium': (400, 400),
            'thumb_50x50': (50, 50, True), # 'small': (50, 50, True),
    }, delete_orphans=True, upload_to="avatar/%Y", blank=True, null=True, verbose_name="个人头像")

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

    def avatar_small(self):
        if self.avatar:
            return mark_safe( f'<img style="border-radius: 100%;" src="{self.avatar.thumb_50x50.url}">' )
        return ""

    avatar_small.short_description = "个人头像(50x50)"
    avatar_small.allow_tags = True
    avatar_small.admin_order_field = "avatar"

    def avatar_medium(self):
        if self.avatar:
            return mark_safe( f'<img style="border-radius: 100%;" src="{self.avatar.thumb_400x400.url}">' )
        return ""

    avatar_medium.short_description = "个人头像(400x400)"
    avatar_medium.allow_tags = True
    avatar_medium.admin_order_field = "avatar"

提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 在Admin站点中管理公共数据与用户数据"
git push

实现图片上传到阿里云OSS对象存储

image-20220927091106858

部分公司:基于fastDFS构建静态资源服务器

部分公司:申请第三方云存储:阿里云OSS,腾讯云云存储,百度云云存储,亚马逊S3

创建阿里云OSS对象存储

开发文档:https://promotion.aliyun.com/ntms/act/ossdoclist.html?spm=5176.8465980.entries.1.4e701450wyVJSM

Bucket存储库:https://oss.console.aliyun.com/bucket

image-20220927091149166

image-20220927091348367

image-20220927094427196

image-20220927095045840

bucket     luffycityoline	# luffycity-fumi
endpoint   oss-cn-beijing.aliyuncs.com	# oss-cn-beijing.aliyuncs.com

查询获取接口访问key和秘钥

地址:https://ram.console.aliyun.com/manage/ak

image-20220927095121479

image-20220927095526212

ACCESS_KEY_ID       LTAI5t991uBJjk8TunKooM7M	# LTAI5tQL4ffsnrmiFz3uT3zz
ACCESS_KEY_SECRET   oEDvV9RaoCf6rHIZXlJCJAmk0phub2	# tMhcsfTnHwFN5NUnkpviiAwHUGUbv9

安装阿里云的SDK集成到项目中使用

终端下安装:

pip install oss2
pip install django-oss-storage

python直接操作oss2

适用于一些没有oss集成模块的web框架中,ossdemo.py,代码:

import oss2,uuid
if __name__ == '__main__':
    OSS_ACCESS_KEY_ID = "LTAI5t991uBJjk8TunKooM7M"
    OSS_ACCESS_KEY_SECRET = "oEDvV9RaoCf6rHIZXlJCJAmk0phub2"
    OSS_ENDPOINT = "oss-cn-beijing.aliyuncs.com"  # 访问域名, 根据服务器上的实际配置修改
    OSS_BUCKET_NAME = "luffycityoline"  # oss 创建的 BUCKET 名称

    OSS_SERVER_URL = f"https://{OSS_BUCKET_NAME}.{OSS_ENDPOINT}"

    # 创建命名空间操作实例对象
    auth = oss2.Auth(OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET)
    bucket = oss2.Bucket(auth, OSS_ENDPOINT, OSS_BUCKET_NAME)

    # 上传文件
    image = f"demo/{str(uuid.uuid4())}.jpg"
    with open('/home/moluo/Desktop/luffycity/luffycityapi/luffycityapi/uploads/avatar/2021/avatar.jpg', "rb") as f:
        result = bucket.put_object(image, f.read() )
        print(result)
        print(result.status)
        print(f"{OSS_SERVER_URL}/{image}")

django配置自定义文件存储上传文件到oss

settings.dev,代码:

# 阿里云OSS云存储
OSS_ACCESS_KEY_ID = "LTAI5t991uBJjk8TunKooM7M"
OSS_ACCESS_KEY_SECRET = "oEDvV9RaoCf6rHIZXlJCJAmk0phub2"
OSS_ENDPOINT = "oss-cn-beijing.aliyuncs.com"    # 访问域名, 根据服务器上的实际配置修改
OSS_BUCKET_NAME = "luffycityoline"    # oss 创建的 BUCKET 名称

# 添加下面配置后 Django admin 后台上传的 ImageField, FileField 类型的字段都会被自动上传到 oss 的服务器中, 访问路径也会自动替换
# 如果注释掉的话 oss 的配置会失效, 上传文件会存储到本地, 且访问路径也会变成本地
DEFAULT_FILE_STORAGE = 'django_oss_storage.backends.OssMediaStorage'

注意:上面的配置完成以后,将来django中所有上传下载的文件都会默认从OSS对象存储中操作。所以本地原来保存的图片等静态资源再访问就无效了。所以我们需要把uploads这个目录下的所有文件信息,手动上传到当前项目配置的OSS Bucket存储库中。

image-20220927095955669

提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 实现图片上传到阿里云OSS对象存储"
git push

后端实现学习方向列表接口

序列化器

courses/serializers.py,代码:

from rest_framework import serializers
from .models import CourseDirection


class CourseDirectionModelSerializer(serializers.ModelSerializer):
    """学习方向的序列化器"""
    class Meta:
        model = CourseDirection
        fields = ["id", "name"]
 

视图

courses.views,代码:

from rest_framework.generics import ListAPIView
from .models import CourseDirection
from .serializers import CourseDirectionModelSerializer


# Create your views here.
class CourseDirectionListAPIView(ListAPIView):
    """学习方向"""
    queryset = CourseDirection.objects.filter(is_show=True, is_deleted=False).order_by("orders","-id")
    serializer_class = CourseDirectionModelSerializer



路由

courses.urls,代码:

from django.urls import path,re_path
from . import views

urlpatterns = [
    path("directions/", views.CourseDirectionListAPIView.as_view()),
]

提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 服务端提供学习方向列表的api接口"
git push

后端实现课程分类列表接口

创建序列化器

courses/serializers.py

from rest_framework import serializers
from .models import CourseDirection, CourseCategory

class CourseDirectionModelSerializer(serializers.ModelSerializer):
    """学习方向的序列化器"""

    class Meta:
        model = CourseDirection
        fields = ["id", "name"]


class CourseCategoryModelSerializer(serializers.ModelSerializer):
    """课程分类的序列化器"""
    class Meta:
        model = CourseCategory
        fields = ["id", "name"]

视图

courses/views.py

from rest_framework.generics import ListAPIView
from .models import CourseDirection, CourseCategory
from .serializers import CourseDirectionModelSerializer, CourseCategoryModelSerializer


# Create your views here.
class CourseDirectionListAPIView(ListAPIView):
    """学习方向"""
    queryset = CourseDirection.objects.filter(is_show=True, is_deleted=False).order_by("orders", "-id")
    serializer_class = CourseDirectionModelSerializer
    pagination_class = None


class CourseCategoryListAPIView(ListAPIView):
    """学习分类"""
    queryset = CourseCategory.objects.filter(is_show=True, is_deleted=False).order_by("orders","-id")
    serializer_class = CourseCategoryModelSerializer
    pagination_class = None


路由

from django.urls import path,re_path
from . import views

urlpatterns = [
    path("directions/", views.CourseDirectionListAPIView.as_view()),
    path("categories/", views.CourseCategoryListAPIView.as_view()),
]

提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 服务端提供课程分类列表的api接口"
git push

客户端发送请求获取学习方向和课程分类信息

src/api/course.js,代码:

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


const course = reactive({
    current_direction: 0,  // 当前选中的学习方向,0表示所有方向
    current_category: 0,  // 当前选中的课程分类,0表示不限分类
    direction_list: [],    // 学习方向列表
    category_list: [],    // 课程分类列表
    get_course_direction(){
        // 获取学习方向信息
        return http.get("/courses/directions/")
    },
    get_course_category () {
        // 获取课程分类信息
        return http.get('/courses/categories/')
    }
})

export default course;

src/views/Courses.vue,代码:

<template>
  <div class="course">
    <Header></Header>
    <div class="top-wrap">
        <div class="actual-header">
            <div class="actual-header-wrap">
                <div class="banner">
                    <router-link class="title" to="/course"><img class="h100" src="../assets/coding-title.png" alt=""></router-link>
                    <div>真实项目实战演练</div>
                </div>
                <div class="actual-header-search">
                    <div class="search-inner">
                        <input class="actual-search-input" placeholder="搜索感兴趣的实战课程内容" type="text" autocomplete="off">
                        <img class="actual-search-button" src="../assets/search.svg" />
                    </div>
                    <div class="actual-searchtags">
                    </div>
                    <div class="search-hot">
                        <span>热搜:</span>
                        <a href="">Java工程师</a>
                        <a href="">Vue</a>
                    </div>
                </div>
            </div>
        </div>
        <div class="type">
            <div class="type-wrap">
                <div class="one warp">
                    <span class="name">方向:</span>
                    <ul class="items">
                         <!-- click.prevent.stop阻止事件冒泡,避免点击刷新页面-->
                        <li :class="{cur:course.current_direction===0}" @click.prevent.stop="course.current_direction=0"><a href="">全部</a></li>
                        <li :class="{cur:course.current_direction===direction.id}" @click.prevent.stop="course.current_direction=direction.id" v-for="direction in course.direction_list"><a href="">{{direction.name}}</a></li>
                    </ul>
                </div>
                <div class="two warp">
                    <span class="name">分类:</span>
                    <ul class="items">
                        <li :class="{cur:course.current_category===0}"><a href="" @click.prevent.stop="course.current_category=0">不限</a></li>
                        <li :class="{cur:course.current_category===category.id}" v-for="category in course.category_list"><a href="" @click.prevent.stop="course.current_category=category.id">{{category.name}}</a></li>
                    </ul>
                </div>
            </div>
        </div>
    </div>
    <div class="main">
        <div class="main-wrap">
            <div class="filter clearfix">
                <div class="sort l">
                  <a href="" class="on">最新</a>
                  <a href="">销量</a>
                  <a href="">升级</a>
                </div>
                <div class="other r clearfix"><a class="course-line l" href="" target="_blank">学习路线</a></div>
            </div>
            <ul class="course-list clearfix">
              <li class="course-card">
                <a target="_blank" href="">
                    <div class="img"><img src="../assets/course-1.png" alt=""></div>
                    <p class="title ellipsis2">全面的Docker 系统性入门+进阶实践(2021最新版)</p>
                    <p class="one">
                        <span>进阶 · 611人报名</span>
                        <span class="discount r"><i class="name">优惠价</i></span>
                    </p>
                    <p class="two clearfix">
                        <span class="price l red bold">¥428.00</span>
                        <span class="origin-price l delete-line">¥488.00</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </a>
              </li>
              <li class="course-card">
                <a target="_blank" href="">
                    <div class="img"><img src="../assets/course-2.png" alt=""></div>
                    <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>
                    <p class="one">
                        <span>进阶 · 246人报名</span>
                        <span class="discount r"><i class="name">限时优惠</i><i class="countdown">6<span class="day">天</span>01:39:21</i></span>
                    </p>
                    <p class="two clearfix">
                        <span class="price l red bold">¥328.00</span>
                        <span class="origin-price l delete-line">¥368.00</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </a>
              </li>
              <li class="course-card">
                <a target="_blank" href="">
                    <div class="img"><img src="../assets/course-3.png" alt=""></div>
                    <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>
                    <p class="one">
                        <span>进阶 · 246人报名</span>
                        <span class="discount r"><i class="name">限时优惠</i><i class="countdown">16<span class="day">天</span>01:39:21</i></span>
                    </p>
                    <p class="two clearfix">
                        <span class="price l red bold">¥328.00</span>
                        <span class="origin-price l delete-line">¥368.00</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </a>
              </li>
              <li class="course-card">
                <a target="_blank" href="">
                    <div class="img"><img src="../assets/course-4.png" alt=""></div>
                    <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>
                    <p class="one"><span>进阶 · 246人报名</span></p>
                    <p class="two clearfix">
                        <span class="price l red bold">¥399.00</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </a>
              </li>
              <li class="course-card">
                <a target="_blank" href="">
                    <div class="img"><img src="../assets/course-5.png" alt=""></div>
                    <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>
                    <p class="one"><span>进阶 · 246人报名</span></p>
                    <p class="two clearfix">
                        <span class="price l red bold">¥399.00</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </a>
              </li>

            </ul>
            <div class="page">
                <span class="disabled_page">首页</span>
                <span class="disabled_page">上一页</span>
                <a href="" class="active">1</a>
                <a href="">2</a>
                <a href="">3</a>
                <a href="">4</a>
                <a href="">下一页</a>
                <a href="">尾页</a>
            </div>
        </div>
    </div>
    <Footer></Footer>
  </div>
</template>

<script setup>
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import course from "../api/course";
// 获取课程学习方向
course.get_course_direction().then(response=>{
  course.direction_list = response.data;
})
// 获取课程分类
course.get_course_category().then(response=>{
  course.category_list = response.data;
})
</script>

<style scoped>
.type .type-wrap .warp:hover{
  height: auto;
}
.type .type-wrap .warp:hover .items{
  background-color: #fff;
}
</style>

提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 客户端请求学习方向与课程分类列表的api接口并展示数据"
git push

当用户点击不同的学习方向时,显示不同方向下的课程分类信息

服务端调整url路由,在获取课程分类时允许地址栏传递学习方向的ID进来。

from django.urls import path,re_path
from . import views

urlpatterns = [
    path("direction/", views.CourseDirectionListAPIView.as_view()),
    re_path("category/(?P<direction>\d+)/", views.CourseCategoryListAPIView.as_view()),
]

视图中获取数据时,提取路由参数作为查询的条件,courses.views,视图代码:

from rest_framework.generics import ListAPIView
from .models import CourseDirection, CourseCategory
from .serializers import CourseDirectionModelSerializer, CourseCategoryModelSerializer


# Create your views here.
class CourseDirectionListAPIView(ListAPIView):
    """学习方向"""
    queryset = CourseDirection.objects.filter(is_show=True, is_deleted=False).order_by("orders","id")
    serializer_class = CourseDirectionModelSerializer
    pagination_class = None

class CourseCategoryListAPIView(ListAPIView):
    """学习分类"""

    serializer_class = CourseCategoryModelSerializer
    pagination_class = None

    def get_queryset(self):
        # 类视图中,获取路由参数
        queryset = CourseCategory.objects.filter(is_show=True, is_deleted=False)
        # 如果direction为0,则表示查询所有的课程分类,如果大于0,则表示按学习方向来查找课程分类
        direction = int(self.kwargs.get("direction", 0))
        if direction > 0:
            queryset = queryset.filter(direction=direction)
        return queryset.order_by("orders","id").all()

客户端在获取课程分类时,添加当前选择的学习方向的ID作为路由参数。

src/api/course.js,代码:

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

const course = reactive({
    current_direction: 0,  // 当前选中的学习方向,0表示所有方向
    current_category: 0,  // 当前选中的课程分类,0表示不限分类
    direction_list: [],    // 学习方向列表
    category_list: [],    // 课程分类列表
    // 获取学习方向信息
    get_course_direction(){
        return http.get("/courses/directions/")
    },

    // 获取课程分类信息
    get_course_category () {
      return http.get(`/courses/categories/${this.current_direction}/`)
    }
});

export default course;

src/views/Course.vue,代码:

<script setup>
import {watch} from "vue";
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import course from "../api/course";

course.get_course_direction().then(response=>{
  course.direction_list = response.data;
})


const get_category = ()=>{
  // 重置当前选中的课程分类
  course.current_category=0;
  // 获取课程分类
  course.get_course_category().then(response=>{
    course.category_list = response.data;
  })
}

get_category();


watch(
    // 监听当前学习方向,在改变时,更新对应方向下的课程分类
    ()=> course.current_direction,
    ()=>{
      get_category();
    }
)

</script>

提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 当用户点击不同的学习方向时,显示不同方向下的课程分类信息"
git push

课程信息列表展示

服务端添加测试数据,luffycityapi/scripts/test_data.sql,代码:

# 如果当前数据库使用了物理外键,需要先关闭原来表中的主外键约束功能
# set FOREIGN_KEY_CHECKS=0;

# 清空原有的课程信息表信息
truncate table lf_course_info;
# 添加课程信息
INSERT INTO luffycity.lf_course_info (id, name, orders, is_show, is_deleted, created_time, updated_time, course_cover, course_video, course_type, level, description, pub_date, period, attachment_path, attachment_link, status, students, lessons, pub_lessons, price, recomment_home_hot, recomment_home_top, category_id, direction_id, teacher_id) VALUES
(1, '7天Typescript从入门到放弃', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-10.png', '', 0, 0, '<p>7天Typescript从入门到放弃</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 1100, 70, 15, 800.00, 0, 0, 2, 1, 1),
(2, '3天Typescript精修', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-9.png', '', 0, 0, '<p>3天Typescript精修</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 9704, 100, 100, 998.00, 1, 0, 2, 1, 2),
(3, '3天学会Vue基础', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-8.png', '', 0, 0, '<p>3天学会Vue基础</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 988, 130, 54, 500.00, 1, 0, 1, 1, 2),
(4, '算法与数据结构体系课', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-7.png', '', 0, 0, '<p>算法与数据结构体系课</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 1303, 150, 50, 998.00, 0, 1, 33, 4, 4),
(5, 'python基础入门', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-6.png', '', 0, 0, '<p>python基础入门</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 4302, 140, 30, 100.00, 0, 1, 20, 2, 4),
(6, 'javascript进阶', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-5.png', '', 0, 0, '<p>javascript进阶</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 1125, 180, 100, 1750.00, 1, 0, 5, 1, 3),
(7, '爬虫进阶之逆向工程', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-4.png', '', 0, 0, '<p>爬虫进阶之逆向工程</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 223, 145, 55, 400.00, 0, 0, 21, 2, 3),
(8, 'Kubernetes 入门到进阶实战', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-3.png', '', 0, 0, '<p>Kubernetes 入门到进阶实战</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 6074, 70, 20, 500.00, 1, 0, 50, 7, 3),
(9, 'Android 应用程序构建实战', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-2.png', '', 0, 0, '<p>Android 应用程序构建实战</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 1059, 110, 50, 550.00, 0, 0, 29, 3, 1),
(10, 'Kotlin从入门到精通', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-1.png', '', 0, 0, '<p>Kotlin从入门到精通</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 870, 120, 0, 500.00, 1, 0, 29, 3, 1),
(11, '深度学习之神经网络', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-11.png', '', 0, 0, '<p>深度学习之神经网络</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 6002, 115, 70, 80.00, 1, 0, 37, 5, 1),
(12, 'OpenCV入门到进阶', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-12.png', '', 0, 0, '<p>OpenCV入门到进阶</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 1029, 100, 70, 390.00, 0, 1, 38, 5, 2),
(13, 'Go容器化微服务系统实战', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-13.png', '', 0, 0, '<p>Go容器化微服务系统实战</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 24202, 65, 65, 399.00, 0, 0, 35, 5, 1),
(14, 'RabbitMQ精讲', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-14.png', '', 0, 0, '<p>RabbitMQ精讲</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 980, 100, 100, 710.00, 0, 0, 53, 8, 4),
(15, 'TensorFlow基础', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-15.png', '', 0, 0, '<p>RabbitMQ精讲</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 670, 220, 100, 1590.00, 0, 1, 36, 5, 2),
(16, 'ZooKeeper分布式架构搭建', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-16.png', '', 0, 0, '<p>ZooKeeper分布式架构搭建</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 90, 88, 35, 40.00, 1, 0, 35, 5, 3),
(17, '高性能MySQL调优', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-17.png', '', 0, 0, '<p>高性能MySQL调优</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 40, 300, 60, 998.00, 1, 1, 60, 10, 3),
(18, 'MySQL事务处理精选', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-18.png', '', 0, 0, '<p>MySQL事务处理精选</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 640, 65, 30, 1000.00, 1, 0, 60, 10, 1),
(19, 'MongoDB入门到进阶', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-19.png', '', 0, 0, '<p>MongoDB入门到进阶</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 11205, 86, 40, 1100.00, 0, 1, 62, 10, 3),
(20, 'Redis入门课程', 1, 1, 0, '2021-07-22 04:35:05.696823', '2021-07-22 04:35:05.696871', 'course/cover/course-20.png', '', 0, 0, '<p>Redis入门课程</p>', '2021-07-22', 7, 'luffycity-celery用法1.zip', null, 0, 120, 100, 40, 1199.00, 1, 1, 61, 10, 2);

# 如果当前数据库使用了物理外键,开启原来表中的主外键约束功能
# set FOREIGN_KEY_CHECKS=1;

终端下执行shell文件

cd /home/moluo/Desktop/luffycity/luffycityapi/scripts
./test_data.sh

服务端提供课程信息列表的api接口

序列化器,courses.serializers,代码:

from .models import Course


class CourseInfoModelSerializer(serializers.ModelSerializer):
    """课程信息的序列化器"""
    class Meta:
        model = Course
        fields = [
            "id", "name", "course_cover", "level", "get_level_display",
            "students", "status", "get_status_display",
            "lessons", "pub_lessons", "price", "discount"
        ]

模型,courses/models.py,代码:

class Course(BaseModel):
    course_type = (
        (0, '付费购买'),
        (1, '会员专享'),
        (2, '学位课程'),
    )
    level_choices = (
        (0, '初级'),
        (1, '中级'),
        (2, '高级'),
    )
    status_choices = (
        (0, '上线'),
        (1, '下线'),
        (2, '预上线'),
    )
    # course_cover = models.ImageField(upload_to="course/cover", max_length=255, verbose_name="封面图片", blank=True, null=True)
    course_cover = StdImageField(variations={
        'thumb_1080x608': (1080, 608),   # 高清图
        'thumb_540x304': (540, 304),    # 中等比例,
        'thumb_108x61': (108, 61, True),  # 小图(第三个参数表示保持图片质量),
    }, max_length=255, delete_orphans=True, upload_to="course/cover", null=True, verbose_name="封面图片",blank=True)

    course_video = models.FileField(upload_to="course/video", max_length=255, verbose_name="封面视频", blank=True, null=True)
    course_type = models.SmallIntegerField(choices=course_type,default=0, verbose_name="付费类型")
    level = models.SmallIntegerField(choices=level_choices, default=1, verbose_name="难度等级")
    description = RichTextUploadingField(null=True, blank=True, verbose_name="详情介绍")
    pub_date = models.DateField(auto_now_add=True, verbose_name="发布日期")
    period = models.IntegerField(default=7, verbose_name="建议学习周期(day)")
    attachment_path = models.FileField(max_length=1000, blank=True, null=True, verbose_name="课件路径")
    attachment_link = models.CharField(max_length=1000, blank=True, null=True, verbose_name="课件链接")
    status = models.SmallIntegerField(choices=status_choices, default=0, verbose_name="课程状态")
    students = models.IntegerField(default=0, verbose_name="学习人数")
    lessons = models.IntegerField(default=0, verbose_name="总课时数量")
    pub_lessons = models.IntegerField(default=0, verbose_name="已更新课时数量")
    price = models.DecimalField(max_digits=10,decimal_places=2, verbose_name="课程原价",default=0)
    recomment_home_hot = models.BooleanField(default=False, verbose_name="是否推荐到首页新课栏目")
    recomment_home_top = models.BooleanField(default=False, verbose_name="是否推荐到首页必学栏目")
    direction = models.ForeignKey("CourseDirection", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="学习方向")
    category = models.ForeignKey("CourseCategory", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="课程分类")
    teacher = models.ForeignKey("Teacher", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="授课老师")

    class Meta:
        db_table = "lf_course_info"
        verbose_name = "课程信息"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s" % self.name

    def course_cover_small(self):
        if self.course_cover:
            return mark_safe(f'<img style="border-radius: 0%;" src="{self.course_cover.thumb_108x61.url}">')
        return ""

    course_cover_small.short_description = "封面图片(108x61)"
    course_cover_small.allow_tags = True
    course_cover_small.admin_order_field = "course_cover"

    def course_cover_medium(self):
        if self.course_cover:
            return mark_safe(f'<img style="border-radius: 0%;" src="{self.course_cover.thumb_540x304.url}">')
        return ""

    course_cover_medium.short_description = "封面图片(540x304)"
    course_cover_medium.allow_tags = True
    course_cover_medium.admin_order_field = "course_cover"

    def course_cover_large(self):
        if self.course_cover:
            return mark_safe(f'<img style="border-radius: 0%;" src="{self.course_cover.thumb_1080x608.url}">')
        return ""

    course_cover_large.short_description = "封面图片(1080x608)"
    course_cover_large.allow_tags = True
    course_cover_large.admin_order_field = "course_cover"

    @property
    def discount(self):
        # todo 将来通过计算获取当前课程的折扣优惠相关的信息
        import random
        return {
            "type": ["限时优惠","限时减免"].pop(random.randint(0,1)), # 优惠类型
            "expire": random.randint(100000, 1200000),  #  优惠倒计时
            "price": self.price - random.randint(1,10) * 10,  # 优惠价格
        }


视图,courses/views.py,代码:

from rest_framework.generics import ListAPIView
from .models import CourseDirection, CourseCategory, Course
from .serializers import CourseDirectionModelSerializer, CourseCategoryModelSerializer, CourseInfoModelSerializer


# Create your views here.
class CourseDirectionListAPIView(ListAPIView):
    """学习方向"""
    queryset = CourseDirection.objects.filter(is_show=True, is_deleted=False).order_by("orders", "-id")
    serializer_class = CourseDirectionModelSerializer
    pagination_class = None


class CourseCategoryListAPIView(ListAPIView):
    """学习分类"""
    # queryset = CourseCategory.objects.filter(is_show=True, is_deleted=False).order_by("orders","-id")
    serializer_class = CourseCategoryModelSerializer
    pagination_class = None

    def get_queryset(self):
        # 类视图中,获取路由参数
        queryset = CourseCategory.objects.filter(is_show=True, is_deleted=False)
        # 如果direction为0,则表示查询所有的课程分类,如果大于0,则表示按学习方向来查找课程分类
        direction = int(self.kwargs.get("direction", 0))
        if direction > 0:
            queryset = queryset.filter(direction=direction)

        return queryset.order_by("orders", "id").all()


# url: /course/学习方向ID/课程分类
# url: /course/P<direction>\d+)/(?P<category>\d+)$/
# url: /course/0/0  # 展示所有的课程列表信息,不区分学习方向和课程分类
# url: /course/1/0  # 展示前端开发学习方向的课程列表信息,不区分课程分类
# url: /course/1/5  # 展示前端开发学习方向下javascript课程分类的课程列表信息
class CourseListAPIView(ListAPIView):
    """课程列表接口"""
    serializer_class = CourseInfoModelSerializer

    def get_queryset(self):
        queryset = Course.objects.filter(is_deleted=False, is_show=True).order_by("-orders", "-id")
        direction = int(self.kwargs.get("direction", 0))
        category = int(self.kwargs.get("category", 0))
        # 只有在学习方向大于0的情况下才进行学习方向的过滤
        if direction > 0:
            queryset = queryset.filter(direction=direction)

        # 只有在课程分类大于0的情况下才进行课程分类的过滤
        if category > 0:
            queryset = queryset.filter(category=category)

        return queryset.all()

courses.urls,路由:

from django.urls import path, re_path
from . import views

urlpatterns = [
    path("directions/", views.CourseDirectionListAPIView.as_view()),
    re_path(r"categories/(?P<direction>\d+)/", views.CourseCategoryListAPIView.as_view()),
    re_path(r"^(?P<direction>\d+)/(?P<category>\d+)/$", views.CourseListAPIView.as_view()),
]

客户端发送请求获取课程列表信息

src/api/course.js,代码:

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


const course = reactive({
    current_direction: 0,  // 当前选中的学习方向,0表示所有方向
    current_category: 0,  // 当前选中的课程分类,0表示不限分类
    direction_list: [],    // 学习方向列表
    category_list: [],    // 课程分类列表
    course_list: [],       // 课程列表数据
    get_course_direction(){
        // 获取学习方向信息
        return http.get("/courses/directions/")
    },
    get_course_category () {
        // 获取课程分类信息
        return http.get(`/courses/categories/${this.current_direction}/`)
    },
    get_course_list () {
        // 获取课程列表信息
      return http.get(`/courses/${this.current_direction}/${this.current_category}/`)
    }
})

export default course;

Course.vue,代码:

<template>
  <div class="course">
    <Header></Header>
    <div class="top-wrap">
        <div class="actual-header">
            <div class="actual-header-wrap">
                <div class="banner">
                    <router-link class="title" to="/course"><img class="h100" src="../assets/coding-title.png" alt=""></router-link>
                    <div>真实项目实战演练</div>
                </div>
                <div class="actual-header-search">
                    <div class="search-inner">
                        <input class="actual-search-input" placeholder="搜索感兴趣的实战课程内容" type="text" autocomplete="off">
                        <img class="actual-search-button" src="../assets/search.svg" />
                    </div>
                    <div class="actual-searchtags">
                    </div>
                    <div class="search-hot">
                        <span>热搜:</span>
                        <a href="">Java工程师</a>
                        <a href="">Vue</a>
                    </div>
                </div>
            </div>
        </div>
        <div class="type">
            <div class="type-wrap">
                <div class="one warp">
                    <span class="name">方向:</span>
                    <ul class="items">
                        <li :class="{cur:course.current_direction===0}" @click.prevent.stop="course.current_direction=0"><a href="">全部</a></li>
                        <li :class="{cur:course.current_direction===direction.id}" @click.prevent.stop="course.current_direction=direction.id" v-for="direction in course.direction_list"><a href="">{{direction.name}}</a></li>
                    </ul>
                </div>
                <div class="two warp">
                    <span class="name">分类:</span>
                    <ul class="items">
                        <li :class="{cur:course.current_category===0}"><a href="" @click.prevent.stop="course.current_category=0">不限</a></li>
                        <li :class="{cur:course.current_category===category.id}" v-for="category in course.category_list"><a href="" @click.prevent.stop="course.current_category=category.id">{{category.name}}</a></li>
                    </ul>
                </div>
            </div>
        </div>
    </div>
    <div class="main">
        <div class="main-wrap">
            <div class="filter clearfix">
                <div class="sort l">
                  <a href="" class="on">最新</a>
                  <a href="">销量</a>
                  <a href="">升级</a>
                </div>
                <div class="other r clearfix"><a class="course-line l" href="" target="_blank">学习路线</a></div>
            </div>
            <ul class="course-list clearfix">
              <li class="course-card" v-for="course_info in course.course_list">
                <a target="_blank" href="">
                    <div class="img"><img :src="course_info.course_cover" alt=""></div>
                    <p class="title ellipsis2">{{course_info.name}}</p>
                    <p class="one">
                        <span>{{course_info.get_level_display}} · {{course_info.students}}人报名</span>
                        <span class="discount r">
                          <i class="name" v-if="course_info.discount.type">{{course_info.discount.type}}</i>
                          <i class="countdown" v-if="course_info.discount.expire">{{parseInt(course_info.discount.expire/86400)}}<span class="day">天</span>{{fill0(parseInt(course_info.discount.expire/3600%24))}}:{{fill0(parseInt(course_info.discount.expire/60%60))}}:{{fill0(parseInt(course_info.discount.expire%60))}}</i>
                        </span>
                    </p>
                    <p class="two clearfix">
                        <span class="price l red bold" v-if="course_info.discount.price">¥{{parseFloat(course_info.discount.price).toFixed(2)}}</span>
                        <span class="price l red bold" v-else>¥{{parseFloat(course_info.price).toFixed(2)}}</span>
                        <span class="origin-price l delete-line" v-if="course_info.discount.price">¥{{parseFloat(course_info.price).toFixed(2)}}</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </a>
              </li>
<!--              <li class="course-card">-->
<!--                <a target="_blank" href="">-->
<!--                    <div class="img"><img src="../assets/course-2.png" alt=""></div>-->
<!--                    <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>-->
<!--                    <p class="one">-->
<!--                        <span>进阶 · 246人报名</span>-->
<!--                        <span class="discount r"><i class="name">限时优惠</i><i class="countdown">6<span class="day">天</span>01:39:21</i></span>-->
<!--                    </p>-->
<!--                    <p class="two clearfix">-->
<!--                        <span class="price l red bold">¥328.00</span>-->
<!--                        <span class="origin-price l delete-line">¥368.00</span>-->
<!--                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>-->
<!--                    </p>-->
<!--                </a>-->
<!--              </li>-->
<!--              <li class="course-card">-->
<!--                <a target="_blank" href="">-->
<!--                    <div class="img"><img src="../assets/course-3.png" alt=""></div>-->
<!--                    <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>-->
<!--                    <p class="one">-->
<!--                        <span>进阶 · 246人报名</span>-->
<!--                        <span class="discount r"><i class="name">限时优惠</i><i class="countdown">16<span class="day">天</span>01:39:21</i></span>-->
<!--                    </p>-->
<!--                    <p class="two clearfix">-->
<!--                        <span class="price l red bold">¥328.00</span>-->
<!--                        <span class="origin-price l delete-line">¥368.00</span>-->
<!--                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>-->
<!--                    </p>-->
<!--                </a>-->
<!--              </li>-->
<!--              <li class="course-card">-->
<!--                <a target="_blank" href="">-->
<!--                    <div class="img"><img src="../assets/course-4.png" alt=""></div>-->
<!--                    <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>-->
<!--                    <p class="one"><span>进阶 · 246人报名</span></p>-->
<!--                    <p class="two clearfix">-->
<!--                        <span class="price l red bold">¥399.00</span>-->
<!--                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>-->
<!--                    </p>-->
<!--                </a>-->
<!--              </li>-->
<!--              <li class="course-card">-->
<!--                <a target="_blank" href="">-->
<!--                    <div class="img"><img src="../assets/course-5.png" alt=""></div>-->
<!--                    <p class="title ellipsis2">Flink+ClickHouse 玩转企业级实时大数据开发,助你实现弯道超车</p>-->
<!--                    <p class="one"><span>进阶 · 246人报名</span></p>-->
<!--                    <p class="two clearfix">-->
<!--                        <span class="price l red bold">¥399.00</span>-->
<!--                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>-->
<!--                    </p>-->
<!--                </a>-->
<!--              </li>-->
            </ul>
            <div class="page">
                <span class="disabled_page">首页</span>
                <span class="disabled_page">上一页</span>
                <a href="" class="active">1</a>
                <a href="">2</a>
                <a href="">3</a>
                <a href="">4</a>
                <a href="">下一页</a>
                <a href="">尾页</a>
            </div>
        </div>
    </div>
    <Footer></Footer>
  </div>
</template>

<script setup>
import {reactive,ref, watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import course from "../api/course";
import {fill0} from "../utils/func";


// 获取学习方向的列表数据
course.get_course_direction().then(response=>{
  course.direction_list = response.data;
})


// 获取课程分类的列表数据
const get_category = ()=>{
  // 获取课程分类
  course.get_course_category().then(response=>{
    course.category_list = response.data;
  })
}

get_category();



const get_course_list = ()=>{
  // 获取课程列表
  course.get_course_list().then(response=>{
    course.course_list = response.data;
  })
}

get_course_list();


watch(
    // 监听当前学习方向,在改变时,更新对应方向下的课程分类与课程信息
    ()=> course.current_direction,
    ()=>{
        // 重置当前选中的课程分类
        course.current_category=0;
        get_category();
        get_course_list();
    }
)

watch(
    // 监听切换不同的课程分类,在改变时,更新对应分类下的课程信息
    ()=> course.current_category,
    ()=>{
        get_course_list();
    }
)

</script>

src/utils/func.js,代码:

// 给小于10的数字左边补0
export function fill0(num){
    return num<10?"0"+num: num;
}

提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 根据不同的学习方向与课程分类,展示课程列表信息"
git push

排序展示课程信息

后端提供排序课程的接口,只需要在courses/views.py把原来views.py中的CoursesAPIView新增两句代码:

from rest_framework.generics import ListAPIView
from .models import CourseDirection, CourseCategory, Course
from .serializers import CourseDirectionModelSerializer, CourseCategoryModelSerializer, CourseInfoModelSerializer
from rest_framework.filters import OrderingFilter

# 中间代码省略...

# url: /course/学习方向ID/课程分类
# url: /course/P<direction>\d+)/(?P<category>\d+)$/
# url: /course/0/0  # 展示所有的课程列表信息,不区分学习方向和课程分类
# url: /course/1/0  # 展示前端开发学习方向的课程列表信息,不区分课程分类
# url: /course/1/5  # 展示前端开发学习方向下javascript课程分类的课程列表信息
class CourseListAPIView(ListAPIView):
    """课程列表接口"""
    serializer_class = CourseInfoModelSerializer
    filter_backends = [OrderingFilter, ]
    ordering_fields = ['id', 'students', 'orders']

    def get_queryset(self):
        queryset = Course.objects.filter(is_deleted=False, is_show=True).order_by("-orders", "-id")
        direction = int(self.kwargs.get("direction", 0))
        category = int(self.kwargs.get("category", 0))
        # 只有在学习方向大于0的情况下才进行学习方向的过滤
        if direction > 0:
            queryset = queryset.filter(direction=direction)

        # 只有在课程分类大于0的情况下才进行课程分类的过滤
        if category > 0:
            queryset = queryset.filter(category=category)

        return queryset.all()


客户端根据排序字段对应的课程顺序

src/api/course.js,代码:

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


const course = reactive({
    current_direction: 0,  // 当前选中的学习方向,0表示所有方向
    current_category: 0,   // 当前选中的课程分类,0表示不限分类
    direction_list: [],    // 学习方向列表
    category_list: [],     // 课程分类列表
    course_list: [],       // 课程列表数据
    ordering: "-id",       // 课程排序条件
    get_course_direction(){
        // 获取学习方向信息
        return http.get("/courses/directions/")
    },
    get_course_category () {
        // 获取课程分类信息
        return http.get(`/courses/categories/${this.current_direction}/`)
    },
    get_course_list () {
        // 获取课程列表信息
        let params = {}
        if(this.ordering){
            params.ordering = this.ordering;
        }
      return http.get(`/courses/${this.current_direction}/${this.current_category}/`, {
            params, // params: params 的简写
        })
    }
})

export default course;

views/Course.vue,代码

            <div class="filter clearfix">
                <div class="sort l">
                  <a href="" :class="{on:course.ordering==='-id'}" @click.prevent.stop="course.ordering=(course.ordering==='-id'?'':'-id')">最新</a>
                  <a href="" :class="{on:course.ordering==='-students'}" @click.prevent.stop="course.ordering=(course.ordering==='-students'?'':'-students')">销量</a>
                  <a href="" :class="{on:course.ordering==='-orders'}" @click.prevent.stop="course.ordering=(course.ordering==='-orders'?'':'-orders')">推荐</a>
                </div>
                <div class="other r clearfix"><a class="course-line l" href="" target="_blank">学习路线</a></div>
            </div>

<script setup>
import {reactive,ref, watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import course from "../api/course";
import {fill0} from "../utils/func";


// 获取学习方向的列表数据
course.get_course_direction().then(response=>{
  course.direction_list = response.data;
})


// 获取课程分类的列表数据
const get_category = ()=>{
  // 获取课程分类
  course.get_course_category().then(response=>{
    course.category_list = response.data;
  })
}

get_category();



const get_course_list = ()=>{
  // 获取课程列表
  course.get_course_list().then(response=>{
    course.course_list = response.data;
  })
}

get_course_list();


watch(
    // 监听当前学习方向,在改变时,更新对应方向下的课程分类与课程信息
    ()=> course.current_direction,
    ()=>{
        // 重置排序条件
        course.ordering = "-id";
        // 重置当前选中的课程分类
        course.current_category=0;
        get_category();
        get_course_list();
    }
)

watch(
    // 监听切换不同的课程分类,在改变时,更新对应分类下的课程信息
    ()=> course.current_category,
    ()=>{
        // 重置排序条件
        course.ordering = "-id";
        get_course_list();
    }
)


watch(
    // 监听课程切换不同的排序条件
    ()=>course.ordering,
    ()=>{
        get_course_list();
    }
)


</script>

分页展示课程信息

服务端调整课程信息的api接口,实现分页查询。

courses.paginations,代码:

from rest_framework.pagination import PageNumberPagination


class CourseListPageNumberPagination(PageNumberPagination):
    """课程信息列表分页器"""
    page_size = 5
    max_page_size = 20
    page_size_query_param = "size"
    page_query_param = "page"


courses.views,代码:

from .paginations import CourseListPageNumberPagination

# url: /course/学习方向ID/课程分类
# url: /course/P<direction>\d+)/(?P<category>\d+)$/
# url: /course/0/0  # 展示所有的课程列表信息,不区分学习方向和课程分类
# url: /course/1/0  # 展示前端开发学习方向的课程列表信息,不区分课程分类
# url: /course/1/5  # 展示前端开发学习方向下javascript课程分类的课程列表信息
class CourseListAPIView(ListAPIView):
    """课程列表接口"""
    serializer_class = CourseInfoModelSerializer
    filter_backends = [OrderingFilter, ]
    ordering_fields = ['id', 'students', 'orders']
    pagination_class = CourseListPageNumberPagination

    ...

因为服务端改成分页展示数据,所以返回的数据的结构发生了改变,而且需要根据数据量来决定是否展示分页或者展示分页页码

api/course.js,代码:

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

const course = reactive({
	...
    page: 1,               // 当前页码,默认为1
    size: 5,               // 当前页数据量
    count: 0,         // 课程信息列表的数量
    has_perv: false,  // 是否有上一页
    has_next: false,  // 是否有下一页
    timer: null,      // 课程相关数据的定时器
 ....
      if(this.ordering){
        params.ordering = this.ordering;
      }
      return http.get(`/courses/${this.current_direction}/${this.current_category}/`,{
        params,
      })
    }
})

export default course;

views/Course.vue,代码:

<div class="page" v-if="course.count > course.size">
    <a href="" v-if="course.has_perv" @click.prevent.stop="course.page=1">首页</a>
    <span v-else>首页</span>
    <a href="" v-if="course.has_perv" @click.prevent.stop="course.page--">上一页</a>
    <span v-else>上一页</span>
    <a href="" v-if="course.has_perv" @click.prevent.stop="course.page--">{{course.page-1}}</a>
    <a class="active">{{course.page}}</a>
    <a href="" v-if="course.has_next" @click.prevent.stop="course.page++">{{course.page+1}}</a>
    <a href="" v-if="course.has_next" @click.prevent.stop="course.page++">下一页</a>
    <span v-else>下一页</span>
    <a href="" v-if="course.has_next" @click.prevent.stop="course.page=Math.ceil(course.count/course.size)">尾页</a>
    <span v-else>尾页</span>
</div>

<script setup>
import {reactive,ref, watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import course from "../api/course";
import {fill0} from "../utils/func";


....



const get_course_list = ()=>{
  // 获取课程列表
  course.get_course_list().then(response=>{
    course.course_list = response.data.results;	// 后端返回数据结构变化,课程信息包含在results里面
    // 总数据量
    course.count = response.data.count;
    course.has_perv = !!response.data.previous; // !!2个非表示把数据转换成布尔值
    course.has_next = !!response.data.next;
  })
}

get_course_list();


...

// 监听页码
watch(
    ()=>course.page,
    ()=>{
        // 重新获取课程信息
        get_course_list();
    }
)

</script>

使用计时器让活动时间不断减少

api/courses.js,代码:

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


const course = reactive({
    ....
    timer: 0,         // 课程相关数据的定时器
    .....
    start_timer () {
        // 课程相关的优惠活动倒计时
        clearInterval(this.timer); // 保证整个页面只有一个倒计时对优惠活动的倒计时进行时间
        this.timer = setInterval(() => {
            this.course_list.forEach((course) => {
                // js的对象和python里面的字典/列表一样, 是属于引用类型的。所以修改了成员的值也会影响自身的。
                if (course.discount.expire && course.discount.expire > 0) {
                    // 时间不断自减
                    course.discount.expire--
                }
            })
        }, 1000)
    }
})

export default course;

views/Course.vue,代码:

<script setup>
...

const get_course_list = ()=>{
  // 获取课程列表
  course.get_course_list().then(response=>{
    course.course_list = response.data.results;
    // 总数据量
    course.count = response.data.count;
    course.has_perv = !!response.data.previous; // !!2个非表示把数据转换成布尔值
    course.has_next = !!response.data.next;
    // 优惠活动的倒计时
    course.start_timer();
  })
}

get_course_list();


...

</script>

提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 课程列表实现排序展示、分页展示和活动时间倒计时"
git push

课程信息全文搜索功能实现

虚拟化技术

把真实物理机子中剩余的资源重新整合,基于虚拟机软件创建出来一台新的虚拟的计算机提供给开发者使用。
优点:
   虚拟化使用软件/硬件的方法重新定义划分计算机资源,可以实现计算机资源的动态分配、灵活调度、跨域共享,提高IT资源利用率,降低成本,加快部署,极大增强系统整体安全性和可靠性。使IT资源能够真正成为社会基础设施,服务于各行各业中灵活多变的应用需求。

虚拟化技术有5种不同的实现方案:
1. 硬件虚拟化
   需要购买虚拟化设备
2. 分区虚拟化
   一台电脑下可以实际安装多个操作系统。开机的时候,就固定分配好了内存。 
3. 虚拟机技术[应用虚拟化]
   通过在操作系统中安装软件来实现,例如:VMware,virtualbox,在vm中创建虚拟机,搭建操作系统,在虚拟机运行的时候,由vm动态向真实电脑申请分配硬件资源[cpu,内存,显卡网络等等]。
4. 准虚拟机技术
   是上面第2和第3中的混合产物,不需要安装vm也不需要分区,而是由操作系统本身提供出来了一个虚拟层来实现的。
   例如;xven或者window10的HyperV都是这种实现。

因为上面不管哪一种虚拟化技术,都会出现一个问题就是为了让虚拟出来的操作系统能正常运作起来都需要实实在在向真实物理机申请固定的对应计算机资源。而且为了能让虚拟出来的操作系统能正常运作,物理机要分配各种的硬件资源,这样很大程度上存在资源的消耗,降低物理机的性能。而这个过程用户完全有可能仅仅只是了为运行某几个软件而已。所以为了更好的提升用户的体验,提升系统的性能,减低不必要的计算机资源消耗,所以出现了一种新的虚拟化技术。

容器化技术!!!
比较流行的容器化技术有:docker和podman
目前在外界使用过程中,比较常用的还是docker

docker提供给开发者使用的方式提供了3种:
1. 终端命令[通过终端命令逐步操作docker]
2. dockerFile[通过脚本对docker进行封装和操作单个镜像]
3. docker-compose [通过脚本对docker的多个镜像/容器进行组合编排的技术, 是python实现的一种技术]

更新ubuntu的apt源索引

sudo apt-get update

安装包允许apt通过HTTPS使用仓库

sudo dpkg --configure -a
sudo apt-get install apt-transport-https ca-certificates curl software-properties-common

添加Docker官方GPG key【这个是国外服务器地址,所以网路不好的时候,会失败!在网路好的情况下,多执行几次就没问题了】

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

设置Docker最新稳定版仓库

sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"

添加仓库后,更新apt源索引

sudo apt-get update

前面的准备工作完成以后,接下来安装最新版Docker CE(社区版)

sudo apt-get install docker-ce

通过下载一个叫hello-world的镜像并运行起来,以此来检查Docker CE是否安装正确并能正常使用。

sudo docker run hello-world

出现了helo from Docker则表示上面的安装成功!

1563502563172

1563503374720

我们获取镜像文件,可以直接去官方网站上获取: https://hub.docker.com/

Docker/podman 命令

docker在ubuntu使用过程中,需要左边加上sudo,而podman不需要。同时下面所有的命令在docker和podman里面是通用的。

所以这里全部写成docker了,在使用podman操作以下以下命令时,直接把docker换成podman即可。

通用命令

查看docker 当前版本

sudo docker version
docker -v
# podman version
# podman -v


管理docker运行

# 启动docker
sudo service docker start
# sudo service podman start

# 停止docker
sudo service docker stop
# sudo service podman stop

# 重启docker
sudo service docker restart
# sudo service podman restart

# 查看docker状态
sudo service docker status
# sudo service podman status


镜像操作[image]

列出本地所有镜像
sudo docker image ls --all
# 简写
# sudo docker image ls
# 简写
# sudo docker images

# podman
# podman images


REPOSITORY TAG IMAGE ID CREATED SIZE
当前镜像的作者以及镜像名 镜像版本号 镜像唯一标记符 镜像创建/析出时间 镜像文件的大小

拉取镜像

docker/podman支持通过网络拉去镜像源站的所有镜像。默认使用的工具就是git工具。

官方镜像源:https://hub.docker.com/

拉取镜像时,如果不指定版本号,默认拉取最新版本的镜像

sudo docker image pull <镜像名称:版本号>
# 简写
# sudo docker pull <镜像名称:版本号>

# podman
# podman pull <镜像名称:版本号>


删除镜像

删除的时候,必须注意是否有容器在运行当前镜像文件,如果在使用,则需要先删除容器,才能删除镜像

sudo docker image rmi <镜像名称/镜像ID:版本号>
# 简写 
# sudo docker rmi <镜像名称/镜像ID:版本号>

# podman
# podman image rmi <镜像名称/镜像ID:版本号>
# podman rmi <镜像名称/镜像ID:版本号>


删除的镜像如果被容器提前使用了,则错误如下:

1563504236734

解决方案:先删除当前镜像对应的容器,接着才能删除镜像。

把docker中的镜像打包成文件

用于分享发送给他人,或备份

sudo docker save -o <文件名.tar.gz>  <镜像名:版本号>

# podman 
# podman save -o <文件名.tar.gz>  <镜像名:版本号>


把镜像文件加载到docker中
sudo docker load -i <文件名.tar.gz>

# podman
# podman load -i  <文件名.tar.gz>


上传镜像

使用之前,必须先到阿里云/dockerhub官方注册账号并创建对应的仓库。

sudo docker login -u <账号名>
# podman login -u 2521532473

sudo docker push <镜像名称/镜像ID>:<版本号>
# podman push <镜像名称/镜像ID>:<版本号>

[root@iZbp1csuqi4s800i20p148Z myself]# docker push elasticsearch:7.13.4
Error response from daemon: You cannot push a "root" repository. Please rename your repository to docker.io/<user>/<repo> (ex: docker.io/2521532473/elasticsearch)

[root@iZbp1csuqi4s800i20p148Z myself]# docker push docker.io/2521532473/elasticsearch
The push refers to a repository [docker.io/2521532473/elasticsearch]
An image does not exist locally with the tag: docker.io/2521532473/elasticsearch

[root@iZbp1csuqi4s800i20p148Z myself]# docker tag 1c9757417a29 2521532473/luffycity:elasticsearch

[root@iZbp1csuqi4s800i20p148Z myself]# docker push 2521532473/luffycity:elasticsearch
The push refers to a repository [docker.io/2521532473/luffycity]
35cc3eb7039a: Pushed 
fec82e6dba62: Pushed 


容器操作[container]

创建容器

必须先有镜像,才能运行创建容器,需要指定使用的镜像名,并且设置创建容器以后,执行对应的第一条命令

sudo docker run <参数选项>  <镜像名称:镜像版本> <容器启动后的第一个命令>
# podman run <参数选项>  <镜像名称:镜像版本> <容器启动后的第一个命令>


例如:使用"hello-world"镜像,创建一个hello-world容器。(注意:如果运行容器时,本地没有对应的镜像或对应镜像的版本,则docker/podman会自动往线上的源服务器中搜索是否有对应的镜像并自动下载的,执行pull镜像操作)。

sudo docker run hello-world
# podman run hello-world


例如:docker使用ubuntu:20.04镜像,创建一个名为ubuntu1的容器并进入

sudo docker pull ubuntu:20.04
sudo docker run -it --name=ubuntu1 ubuntu:20.04 bash

# podman pull ubuntu:20.04
# podman run -it --name=ubuntu1 ubuntu:20.04 bash


注意:启动容器时,如果设置了-it选项参数表示让容器启动以后运行bash解析器,我们可以通过bash终端输入命令操作该容器,但是如果使用了exit关闭bash以后,容器会自动关闭。那如果设置了-itd选项参数,那么run命令执行以后,docker会自动以守护进程的方式创建一个容器,容器会一直运行着。

docker run的参数选项

-t 表示容器启动后会进入其命令行终端

-i 表示以“交互模式”运行容器

--name 表示设置容器的名称,注意容器名是唯一的,尽量遵循python变量名的规范。

-v 目录影射,相当于把容器外部的物理机的目录与容器内部的目录实现共享,改了里面相当于改了外面

-p 端口影射,把物理机的一个端口和容器内部的端口进行绑定。访问了物理机的端口相当于访问了容器的对应端口

-e 设置环境变量,在部分容器中,需要设置环境变量时使用到

--restart=always 设置容器随着docker开机自启,docker/podman中创建的容器默认是不会开机自启,同时podman是没有这个选项的。

--network=host 设置网络模式,与-p冲突,一般设置-p以后不要设置--network

例如,使用ubuntu镜像,创建一个名为ubuntu2,并且在后台运行的容器像

sudo docker run -itd --name=ubuntu2 ubuntu<:版本> ubuntu
# podman run -itd --name=ubuntu2 ubuntu<:版本> ubuntu
docker exec -it ubuntu2 bash


-d 创建一个守护式容器在后台运行(这样创建容器后不会自动登录容器内部的,需要使用docker exec -it 命令才能进入容器内部)

列出所有容器
sudo docker container ls                      # 所有正在启动运行的容器
# 简写 sudo docker ps
# podman ps

sudo docker container ls --all                # 所有容器[不管是否在启动运行中]
# 简写 sudo docker ps --all
# podman ps --all


CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
容器的唯一标记ID 容器的镜像名 容器运行以后默认执行的第一个命令 容器创建时间 容器的运行状态,Up表示容器正在正在启动,Exitd表示容器已经关闭了。 容器与操作系统的端口映射 容器名
启动容器

可以同时启动多个容器,容器之间使用空格隔开

# 启动一个容器[被开启的容器默认会以守护式容器在后台持续运行]
sudo docker container start <容器名称/容器ID>
# 简写 sudo docker start  <容器名称/容器ID>
# podman start  <容器名称/容器ID>

# 启动多个容器
sudo docker container start <容器名称/容器ID>  <容器名称/容器ID> <容器名称/容器ID>
# 简写 sudo docker start <容器名称/容器ID>  <容器名称/容器ID> <容器名称/容器ID>
# podman start <容器名称/容器ID>  <容器名称/容器ID> <容器名称/容器ID>

停止容器
sudo docker container stop <容器名称/容器ID>
# 简写 sudo docker stop  <容器名称/容器ID>
# podman stop  <容器名称/容器ID>

# 停止多个容器
sudo docker container stop <容器名称/容器ID>  <容器名称/容器ID>
# 简写 sudo docker stop <容器名称/容器ID>  <容器名称/容器ID>
# podman stop <容器名称/容器ID>  <容器名称/容器ID>

杀死容器

该命令在容器无法停止的时使用,注意不能滥用,这种操作有可能被导致容器里面运行的文件丢失!!!

sudo docker container kill <容器名称/容器ID>
# 简写 sudo docker kill <容器名称/容器ID>
# podman kill <容器名称/容器ID>

# 杀死多个容器
sudo docker container kill <容器名称/容器ID>  <容器名称/容器ID>
# 简写 sudo docker kill <容器名称/容器ID>  <容器名称/容器ID>
# podman kill <容器名称/容器ID>  <容器名称/容器ID>


进入容器

要进入容器,必须当前容器是启动状态的,exec命令不需要加上-d选项,但需要指定指定容器启动后的第一个命令。

sudo docker container exec -it <容器名称/容器ID>  <第一个命令>
# sudo docker exec -it <容器名称/容器ID>  <第一个命令>
# podman exec -it <容器名称/容器ID>  <第一个命令>


第一个命令一般都是bash,也可以是其他允许开发者输入信息的其他软件命令

删除容器

注意:docker/podman只能删除关闭的容器,无法删除一个正在运行的容器。

sudo docker  container rm <容器名称/容器ID>
# 简写 sudo docker rm <容器名称/容器ID>
# podman rm <容器名称/容器ID>

# 删除多个容器
sudo docker  container rm <容器名称/容器ID>  <容器名称/容器ID>
# 简写 sudo docker rm <容器名称/容器ID>  <容器名称/容器ID>
# podman rm <容器名称/容器ID>  <容器名称/容器ID>


复制文件
# 命令基本格式:
sudo docker container cp <源文件地址> <保存文件地址>


# 从物理机中复制一个文件到指定容器的内部指定路径中
sudo docker container cp <物理机路径> <容器名称/容器ID>:<容器路径>
# 简写 sudo docker cp <物理机路径> <容器名称/容器ID>:<容器路径>
# podman cp  <物理机路径> <容器名称/容器ID>:<容器路径>


# 指定容器的内部指定路径中复制一个文件到物理机指定路径中保存
sudo docker container cp <容器名称/容器ID>:<容器路径> <物理机路径>
# 简写 sudo docker cp <容器名称/容器ID>:<容器路径> <物理机路径>
# podman cp <容器名称/容器ID>:<容器路径> <物理机路径>


把容器保存成镜像
sudo docker container commit <容器名称/容器ID>  <新镜像名:镜像自定义版本>
# 简写 sudo docker commit <容器名称/容器ID>  <新镜像名:镜像自定义版本>
# podman commit <容器名称/容器ID>  <新镜像名:镜像自定义版本>


elasticsearch搜索引擎接入

Elasticsearch是一个基于Lucene的搜索服务器,也是属于NoSQL阵营的数据库。它提供了一个分布式多用户能力的全文搜索引擎,基于RESTful web接口提供给我们操作的。Elasticsearch是用Java语言开发的,并作为Apache许可条款下的开放源码发布,是一种流行的企业级搜索引擎。除了es以外, Sphinx 、迅搜、Zebra、Solr 、Whoosh。

官网:https://www.elastic.co/cn/elasticsearch/

中文文档:https://www.elastic.co/guide/cn/index.html

最新版本:8.0 版本。目前在市面上常用的版本是6.x和7.x,甚至是5.x。

安装elasticsearch

支持单点部署和集群部署。

# 从课件的素材中找到es的镜像压缩包,复制到ubuntu桌面下,执行以下命令
# sudo docker load -i ~/Desktop/elasticsearch.7.13.4.tar.gz
podman load -i ~/Desktop/elasticsearch.7.13.4.tar.gz
# 也可以不执行上面的操作,直接run可以让docker从官网拉取es镜像。

# sudo docker run --name elasticsearch --restart=always -d -p 9200:9200 -p 9300:9300 -e ES_JAVA_OPTS="-Xms256m -Xmx256m" -e "discovery.type=single-node" elasticsearch:7.13.4

podman run --name elasticsearch -d -p 9200:9200 -p 9300:9300 -e ES_JAVA_OPTS="-Xms512m -Xmx512m" -e "discovery.type=single-node" elasticsearch:7.13.4


参数说明

--name elasticsearch  
       设置当前容器的容器名称为elasticsearch
--restart=always
       设置容器开机自启,即便物理机关机重启了,docker在启动以后也会自动帮我们把当前容器启动起来。
-d     设置当前容器为守护式容器,在后台运行
-p 9200:9200
       设置端口影射,<物理机端口>:<容器端口>
       表示访问了当前物理机的9200,相当于访问了当前容器的9200端口
-p 9300:9300
       设置端口影射,<物理机端口>:<容器端口>
       表示访问了当前物理机的9300,相当于访问了当前容器的9300端口
-e ES_JAVA_OPTS="-Xms256m -Xmx256m"
       设置环境变量,变量名为ES_JAVA_OPTS,这个变量是启动elasticsearch的关键。
       表示设置java环境的最小和最大使用内存,内存不足,elasticsearch是无法启动的,所以此处设置为最小内存必须在256M以上
-e "discovery.type=single-node"
       设置环境变量,变量名discovery.type
       discovery.type 表示当前elasticsearch的运作模式为single-node,表示单机部署/单点部署
elasticsearch:7.13.4
       设置当前容器的镜像名和版本号


浏览器访问:http://127.0.0.1:9200

1662632783010

要基于es实现全文搜索,可以参考以下文档了解关于全文搜索的内容。

https://www.elastic.co/guide/cn/elasticsearch/guide/current/full-text-search.html

全文搜索的实现,必须依靠es内部调用分词器对语句进行词性分析,拆词,给每一个单词构建一个索引。

所以默认情况下,es只提供了标准分析器,和简单分析器,这几块分词器都是只能针对英文进行分词。

IK中文分词器

默认情况下,elasticsearch是外国开发的,所以本身对于中文分词构建分词索引的支持是不行的。所以我们需要在elasticsearch软件中新增一个支持中文索引和中文分词的插件,叫ik分词器。

注意:IK分词器插件的版本必须与elasticsearch的版本号同步。否则安装失败!

文档:https://github.com/medcl/elasticsearch-analysis-ik/releases

把IK分词器解压并复制到elasticsearch容器的/usr/share/elasticsearch/plugins目录下

unzip ~/Desktop/elasticsearch-analysis-ik-7.13.4.zip -d ~/Desktop/ik-7.13.4
podman cp ~/Desktop/ik-7.13.4 elasticsearch:/usr/share/elasticsearch/plugins
podman stop elasticsearch
podman start elasticsearch


注意:elasticsearch内部极其复杂,所以启动容器以后需要等待1分钟左右才对外提供搜索服务。

接下来,我们就可以通过postman测试。

注意:es提供的9200是restful api接口的端口,以http形式访问,9300端口是Api对服务器的管理端口。

post http://127.0.0.1:9200/_analyze?pretty

基于智能分词模式来查询分析词性,json数据

{
   "analyzer":"ik_smart",
   "text":"我是中国人"    
}


基于最大分词模式来查询分析词性,json数据

{
   "analyzer":"ik_max_word",
   "text":"我是中国人"  
}


elasticsearch-head

elasticsearch-head 是用于监控 Elasticsearch 状态的客户端插件,包括数据可视化、执行增删改查操作等。不过开发中,我们一般使用elasticsearch-head来查看elasticsearch的数据而已,真正对elasticsearch进行增删查改操作一般我们使用kibana或者postman或者编程语言实现的客户端来完成。

我们可以通过docker安装elasticsearch-head来对Elasticsearch 进行界面化管理。

# 拉取镜像
# sudo docker pull mobz/elasticsearch-head:5
podman pull mobz/elasticsearch-head:5

# 创建容器
# sudo docker create --name elasticsearch-head -p 9100:9100 mobz/elasticsearch-head:5
podman create --name elasticsearch-head -p 9100:9100 mobz/elasticsearch-head:5

# 启动容器
# sudo docker start elasticsearch-head
podman start elasticsearch-head


访问elasticsearch-head:http://127.0.0.1:9100/,会发现无法连接elasticsearch,原因是因为跨域问题导致。

1662637038109

解决方案就是修改容器elasticsearch中的elasticsearch.yml文件增加跨域支持即可。

# sudo docker cp elasticsearch:/usr/share/elasticsearch/config/elasticsearch.yml ~/Desktop/elasticsearch.yml
podman cp elasticsearch:/usr/share/elasticsearch/config/elasticsearch.yml ~/Desktop/elasticsearch.yml


修改elasticsearch.yml内容,增加跨域支持,如下:

cluster.name: "docker-cluster"
network.host: 0.0.0.0
http.cors.enabled: true 
http.cors.allow-origin: "*"


把elasticsearch.yml文件再次复制到容器elasticsearch中,并重启容器elasticsearch。

# sudo docker cp ~/Desktop/elasticsearch.yml elasticsearch:/usr/share/elasticsearch/config/elasticsearch.yml
podman cp ~/Desktop/elasticsearch.yml elasticsearch:/usr/share/elasticsearch/config/elasticsearch.yml



修改容器elasticsearch-head的vendor.js让elasticsearch-head界面可以操作elasticsearch

# sudo docker cp elasticsearch-head:/usr/src/app/_site/vendor.js ~/Desktop/vendor.js
podman cp elasticsearch-head:/usr/src/app/_site/vendor.js ~/Desktop/vendor.js



修改vendor.js内容,把6886行与7573行所在的"application/x-www-form-urlencoded"替换成"application/json;charset=UTF-8",并保存文件,复制回容器elasticsearch-head中。

# sudo docker cp ~/Desktop/vendor.js  elasticsearch-head:/usr/src/app/_site/vendor.js
podman cp ~/Desktop/vendor.js elasticsearch-head:/usr/src/app/_site/vendor.js



重启elasticsearch和elasticsearch-head容器

# sudo docker stop elasticsearch
# sudo docker start elasticsearch
podman stop elasticsearch
podman start elasticsearch

# sudo docker stop elasticsearch-head
# sudo docker start elasticsearch-head
podman stop elasticsearch-head
podman start elasticsearch-head



kibana

Kibana是一个针对Elasticsearch的开源分析及可视化平台,用来搜索、查看交互存储在Elasticsearch索引中的数据。使用Kibana,可以通过各种图表进行高级数据分析及展示。

kibana的版本必须与Elasticsearch一致,所以我们安装的kibana也是7.13.4版本。

# sudo docker pull kibana:7.13.4
# sudo docker run -d --name kibana -e ELASTICSEARCH_URL=http://127.0.0.1:9200 -p 5601:5601 --restart=always kibana:7.13.4

podman pull kibana:7.13.4
podman run -d --name kibana -p 5601:5601 kibana:7.13.4



修改让kibana能访问到Elasticsearch并完成汉化操作

# sudo docker exec -it kibana bash
podman  exec -it kibana bash
vi config/kibana.yml



kibana.yml中的内容需改如下:

#
# ** THIS IS AN AUTO-GENERATED FILE **
#

# Default Kibana configuration for docker target
server.host: "0.0.0.0"
# 注意:此处的IP地址替换为网卡地址,不能使用127.0.0.1或localhost,否则无法访问,可以通过ip a来查看
elasticsearch.hosts: [ "http://IP地址:9200" ]
monitoring.ui.container.elasticsearch.enabled: true
i18n.locale: "zh-CN"



修改完成以后,退出当前kibana容器,并重启kibana容器即可。

# sudo docker stop kibana
# sudo docker start kibana
podman stop kibana
podman start kibana



等待1分钟左右,打开浏览器直接访问http://127.0.0.1:5601,即可。

1662683849387

快速入门

核心概念

Elasticsearch是面向文档的,以json格式存储数据的NoSQL数据库,也是一个全文搜索引擎。

Elasticsearch 关系型数据库(如Mysql)
索引(Index) 数据库(Database)
类型(Type) 表(Table)
文档(Document) 行,记录(Row,Record)
字段(Field) 列,字段(Column,Field)
映射(Mapping) 约束(Schema)

注意:es中的类型在es7.x版本中已经没有,在后面的8.x版本中被彻底删除掉。

倒排索引

倒排索引(Inverted Index),是Elasticsearch中的索引工作机制。倒排索引是区别于正排索引的概念:

  • 正排索引:是以文档对象的唯一ID作为索引,以文档内容作为记录。
  • 倒排索引:指的是将文档内容中的单词作为索引,将包含该词的文档ID作为记录。

正排索引与倒排索引

Elasticsearch的工作流程如下,因为使用倒排索引产生的文档记录要比mysql数据行少多了,所以会比较快。

在这里插入图片描述

基本使用

es提供了retfulAPI风格操作接口给开发者对索引、类型、文档、字段、映射等进行增删查改操作。

索引操作

创建索引

请求格式:

PUT /索引名称



类型映射[了解]

相当于在mysql中创建数据表时的字段类型

# 索引名称必须已经存在!
POST /索引名称/类型名称
{
    "类型名称": { // 映射的类型名称
        "properties": {  // 索引中文档的属性
            "字段名": {           // 属性名或字段名
                "type": "text"   // 属性值类型或字段类型,text表示文本,如商品标题
                "index": "analyzed",    // 索引类型
                "analyzer": "ik_smart"  // 设置使用的分词器[标准分词]
            },
            "字段名": {           // 属性名或字段名
                "type": "text"   // 属性值类型或字段类型,text表示文本,如商品标题
                "index": "analyzed",    // 索引类型
                "analyzer": "standard"  // 设置使用的分词器
            },
            "字段名": {           // 属性名或字段名
                "type": "text"   // 属性值类型或字段类型,text表示文本,如商品标题
                "index": "analyzed",    // 索引类型
                "analyzer": "standard"  // 设置使用的分词器
            }
        }
    }
}



kinana操作:

POST /indexes1/goods
{
  "mapping": {
    "properties": {
      "id": {
        "type": "long"
      },
      "name": {
        "type": "text",
        "index": "analyzed",
        "analyzer": "ik_max_word"
      },
      "price":{
        "type": "float"
      },
      "created_time":{
          "type": "date",
          "format":"yyyy-MM-dd HH:mm:ss"
      }
    }
  }
}



数据类型[了解]

elasticsearch中支持的常见字段数据类型:

类型 描述
text 字符串类型,可以模糊查询, 可以分词查询,不能聚合、排序
keyword 字符串类型,只能精准查询, 不能分词查询,可以聚合、排序
long 有符号的64位整数, 范围:[−263 ~ 263-1]
Integer 有符号的32位整数, 范围: [−231 ~ 231-1]
short 有符号的16位整数, 范围: [-32768 ~ 32767]
byte 有符号的8位整数, 范围: [-128 ~ 127]
float 32位单精度浮点数
double 64位双精度浮点数
boolean 布尔类型,支持使用字符串,数字等零值表示true/false
date 日期类型,
date_nanos 日期纳秒类型,
binary 二进制类型,Base64编码字符串的二进制值
Range 范围类型,有integer_range, float_range, long_range, double_range, date_range等
array 数组类型,ES中没有专门的数组类型, 直接使用[ ]定义即可,所有的成员的值必须是同一种数据类型
object 对象类型,以json对象为结构

使用默认类型_doc代替映射的创建,es会内部自动推断字段类型。

PUT /indexes2/_doc/文档ID
{
    "name": "商品的标题",
    "price": 18,
    "created_time": "2022-01-10 22:00:31"
}



_doc 就是默认类型(default type),type在8.x版本会被彻底删除,以后使用默认类型_doc替代即可。

查看索引
GET /_cat/indices      # 查看所有的索引信息
GET /索引名称           # 查看指定名称的索引信息


删除索引
DELETE /索引名称


文档操作

请求方法/method uri地址 描述
PUT(创建,修改) /索引名称/_doc/文档id 创建文档(指定文档id)
POST(创建) /索引名称/_doc/文档id 创建文档,如果uri地址只是以_doc结尾,文档id是随机生成的)
POST(修改) /索引名称/_doc/文档id/_update 修改文档
DELETE(删除) /索引名称/_doc/文档id 删除文档
GET(查询) /索引名称/_doc/文档id 查询文档通过文档ID
POST(查询) /索引名称/_doc/文档id/_search 查询所有数据
添加文档
POST /索引名称/_doc
{
	"id": 1,
    "name": "华为手机",
    "category": "华为",
    "cover": "1.png",
    "price": "3999.00"
}



修改文档
POST /索引名称/_doc/1
{
    "name": "华为手机mate40",
    "price": "4999.00"
}



1662684581569

删除文档
DELETE /索引名称/_doc/1


查询文档

查询文档有三种方式:

  • 主键查询:根据文档id查询
  • 精确查询:根据关键词查询,也叫term查询 浪潮之巅 -> 浪潮之巅
  • 匹配查询:根据输入的内容先对内容进行分词,再进行分词匹配查询 浪潮 -> 浪潮之巅
准备数据
POST /indexes4/_doc/1
{
  "created_time":"2022-04-01",
  "title":"浪潮之巅",
  "content":"一部IT人非读不可,而非IT人也应该阅读的作品,讲故事的经典作品",
  "author_id": 119
}

POST /indexes4/_doc/2
{
  "post_date":"2022-03-12",
  "title":"人月神话",
  "content":"一部IT人非读不可,而非IT人也应该阅读的作品,讲人与团队关系作品",
  "author_id": 120
}

POST /indexes4/_doc/3
{
  "post_date":"2021-12-16",
  "title":"代码之髓",
  "content":"小日子过得不错的人写的作品,对代码中各种语言结构的实现进行揭秘",
  "author_id": 110
}



主键查询
# GET /索引名称/_doc/1
GET /indexes4/_doc/3


精确查询
POST /索引名称/_search

{
	"query": {
		"term": {
			"字段名": {
			    "value": "字段值"
			},
			"字段名": {
			    "value": "字段值"
			}
		}
	}
}

# 如果是text格式,无法精确查询的,只能匹配查询



kibana操作:

POST /indexes4/_search
{
	"query": {
		"term": {
			"author_id": {
			    "value": 119
			}
		}
	}
}



匹配查询

查询所有

POST /索引名称/_search
{
    "query": {
        "match_all": {}
     },
     "sort": [  # 排序,注意:text无法使用排序,keyword才支持
          # {"字段名":"排序规则,asc正序, desc倒序"}
          {"title": "asc" }
     ],
      "from": 0,                       # 分页,查询起始下标
      "size": 2,                       # 指定返回结果数量
      "_source": ["title", "content"]  # 指定只返回部分字段
}



kibana操作:

POST /indexes4/_search
{
  "query": {
    "match_all": {}
  },
  "sort": {
    "author_id": "desc"
  },
  "from": 0,
  "size": 4,
  "_source": ["title"]
}




条件查询

格式:

POST /索引名称/_search
{
    "query": {
        "match": {
          "字段名": "查询条件值"
        }
    }
}



POST /indexes4/_search
{
    "query": {
        "match": {
          "content": "非读不可"
        }
    }
}



集成ES到服务端项目

安装haystack

django-> mysql --> ORM

dango-> haystack --> ORM

haystack是django的开源搜索框架,能够结合目前市面上大部分的搜索引擎用于实现自定义搜索功能,特别是全文搜索。

haystack支持多种搜索引擎,不仅仅是 jieba ,whoosh,使用solr、elasticsearch等搜索,也可通过haystack,而且直接切换引擎即可,甚至无需修改搜索代码。中文分词最好的就是jieba和elasticsearch+ik。

github: https://github.com/rhblind/drf-haystack

# python操作elasticsearch的模块,注意对应版本,类似pymysql
pip install -U elasticsearch==7.13.4
# django开发的haystack的模块,务必先安装drf-haystack,接着才安装django-haystack。因为drf-haystack不支持es7
pip install -U drf-haystack
pip install -U django-haystack


基本使用

安装配置

文档:https://drf-haystack.readthedocs.io/en/latest/01_intro.html#examples

INSTALLED_APPS = [
	# 必须在自己创建的子应用前面
	'haystack',

	# 自己创建的子应用
]

# haystack连接elasticsearch的配置信息
HAYSTACK_CONNECTIONS = {
    'default': {
        # haystack操作es的核心模块
        'ENGINE': 'haystack.backends.elasticsearch7_backend.Elasticsearch7SearchEngine',
        # es服务端地址
        'URL': 'http://127.0.0.1:9200/',
        # es索引仓库
        'INDEX_NAME': 'haystack',
    },
}

# 当mysqlORM操作数据库改变时,自动更新es的索引,否则es的索引会找不到新增的数据
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'



索引模型

在courses子应用下创建search_indexes.py,用于设置es的索引模型。注意,索引模型的文件名必须是search_indexes。

from haystack import indexes
from .models import Course

class CourseIndex(indexes.SearchIndex, indexes.Indexable):
    # 全文索引[可以根据配置,可以包括多个字段索引]
    # document=True 表示当前字段为全文索引
    # use_template=True 表示接下来haystack需要加载一个固定路径的html模板文件,让text与其他索引字段绑定映射关系
    text = indexes.CharField(document=True, use_template=True)
    # 普通索引[单字段,只能提供单个字段值的搜索,所以此处的声明更主要是为了提供给上面的text全文索引使用的]
    # es索引名 = indexes.索引数据类型(model_attr="ORM中的字段名")
    id = indexes.IntegerField(model_attr="id")
    name = indexes.CharField(model_attr="name")
    description = indexes.CharField(model_attr="description")
    teacher = indexes.CharField(model_attr="teacher__name")
    course_cover = indexes.CharField(model_attr="course_cover")
    get_level_display=indexes.CharField(model_attr="get_level_display")
    students=indexes.IntegerField(model_attr="students")
    get_status_display=indexes.CharField(model_attr="get_status_display")
    lessons=indexes.IntegerField(model_attr="lessons")
    pub_lessons=indexes.IntegerField(model_attr="pub_lessons")
    price=indexes.DecimalField(model_attr="price")
    discount=indexes.CharField(model_attr="discount_json")
    orders=indexes.IntegerField(model_attr="orders")

    # 指定与当前es索引模型对接的mysql的ORM模型
    def get_model(self):
        return Course

    # 当用户搜索es索引时,对应的提供的mysql数据集有哪些?
    def index_queryset(self, using=None):
        return self.get_model().objects.filter(is_deleted=False,is_show=True)





ORM模型中新增discount_json字段方法

courses.models,代码:

import json



class Course(BaseModel):
    course_type = (
        (0, '付费购买'),
        (1, '会员专享'),
        (2, '学位课程'),
    )
    level_choices = (
        (0, '初级'),
        (1, '中级'),
        (2, '高级'),
    )
    status_choices = (
        (0, '上线'),
        (1, '下线'),
        (2, '预上线'),
    )
    # course_cover = models.ImageField(upload_to="course/cover", max_length=255, verbose_name="封面图片", blank=True, null=True)
    course_cover = StdImageField(variations={
        'thumb_1080x608': (1080, 608),   # 高清图
        'thumb_540x304': (540, 304),    # 中等比例,
        'thumb_108x61': (108, 61, True),  # 小图(第三个参数表示保持图片质量),
    }, max_length=255, delete_orphans=True, upload_to="course/cover", null=True, verbose_name="封面图片",blank=True)

    course_video = models.FileField(upload_to="course/video", max_length=255, verbose_name="封面视频", blank=True, null=True)
    course_type = models.SmallIntegerField(choices=course_type,default=0, verbose_name="付费类型")
    level = models.SmallIntegerField(choices=level_choices, default=1, verbose_name="难度等级")
    description = RichTextUploadingField(null=True, blank=True, verbose_name="详情介绍")
    pub_date = models.DateField(auto_now_add=True, verbose_name="发布日期")
    period = models.IntegerField(default=7, verbose_name="建议学习周期(day)")
    attachment_path = models.FileField(max_length=1000, blank=True, null=True, verbose_name="课件路径")
    attachment_link = models.CharField(max_length=1000, blank=True, null=True, verbose_name="课件链接")
    status = models.SmallIntegerField(choices=status_choices, default=0, verbose_name="课程状态")
    students = models.IntegerField(default=0, verbose_name="学习人数")
    lessons = models.IntegerField(default=0, verbose_name="总课时数量")
    pub_lessons = models.IntegerField(default=0, verbose_name="已更新课时数量")
    price = models.DecimalField(max_digits=10,decimal_places=2, verbose_name="课程原价",default=0)
    recomment_home_hot = models.BooleanField(default=False, verbose_name="是否推荐到首页新课栏目")
    recomment_home_top = models.BooleanField(default=False, verbose_name="是否推荐到首页必学栏目")
    direction = models.ForeignKey("CourseDirection", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="学习方向")
    category = models.ForeignKey("CourseCategory", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="课程分类")
    teacher = models.ForeignKey("Teacher", related_name="course_list", on_delete=models.DO_NOTHING, null=True, blank=True, db_constraint=False, verbose_name="授课老师")

    class Meta:
        db_table = "lf_course_info"
        verbose_name = "课程信息"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s" % self.name

    def course_cover_small(self):
        if self.course_cover:
            return mark_safe(f'<img style="border-radius: 0%;" src="{self.course_cover.thumb_108x61.url}">')
        return ""

    course_cover_small.short_description = "封面图片(108x61)"
    course_cover_small.allow_tags = True
    course_cover_small.admin_order_field = "course_cover"

    def course_cover_medium(self):
        if self.course_cover:
            return mark_safe(f'<img style="border-radius: 0%;" src="{self.course_cover.thumb_540x304.url}">')
        return ""

    course_cover_medium.short_description = "封面图片(540x304)"
    course_cover_medium.allow_tags = True
    course_cover_medium.admin_order_field = "course_cover"

    def course_cover_large(self):
        if self.course_cover:
            return mark_safe(f'<img style="border-radius: 0%;" src="{self.course_cover.thumb_1080x608.url}">')
        return ""

    course_cover_large.short_description = "封面图片(1080x608)"
    course_cover_large.allow_tags = True
    course_cover_large.admin_order_field = "course_cover"

    @property
    def discount(self):
        # todo 将来通过计算获取当前课程的折扣优惠相关的信息
        import random
        return {
            "type": ["限时优惠","限时减免"].pop(random.randint(0,1)), # 优惠类型
            "expire": random.randint(100000, 1200000),  #  优惠倒计时
            "price": float(self.price - random.randint(1,10) * 10),  # 优惠价格
        }

    def discount_json(self):
        # 必须转成字符串才能保存到es中。所以该方法提供给es使用的。
        return json.dumps(self.discount)




全文索引字段模板

全文索引模板必须先配置django项目中的TEMPLATES模板引擎,而且全文索引模板的路径必须是模板目录下的search/indexes/子应用目录名/模型类名_text.txt。否则报错。settings.dev,代码:

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [
            BASE_DIR / "templates",  # BASE_DIR 是apps的父级目录,是主应用目录,templates需要手动创建
        ],
       ...
    }
]



创建全文索引字段的html模板,在HTML模板中采用django的模板语法,绑定text与其他es单字段索引的映射关系。

image-20210725080912802

注意:course_text.txt 中course就是ORM模型类名小写,text就是es索引模型类中的全文索引字段名。

templates/search/indexes/courses/course_text.txt。代码:

{{ object.name }}
{{ object.description }}
{{ object.teacher.name }}
{{ object.category.name }}
{{ object.diretion.name }}



object表示当前orm的模型对应。

索引序列化器

courses.serializers,代码:

from drf_haystack.serializers import HaystackSerializer
from .search_indexes import CourseIndex
from django.conf import settings

class  CourseIndexHaystackSerializer(HaystackSerializer):
    """课程搜索的序列化器"""
    class Meta:
        index_classes = [CourseIndex]
        fields = ["text", "id", "name", "course_cover", "get_level_display", "students", "get_status_display", "pub_lessons", "price", "discount", "orders"]

    def to_representation(self, instance):
        """用于指定返回数据的字段的"""
        # 课程的图片,在这里通过elasticsearch提供的,所以不会提供图片地址左边的域名的。因此在这里手动拼接
        instance.course_cover = f'//{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}/uploads/{instance.course_cover}'
        return super().to_representation(instance)



全文搜索的索引视图

from drf_haystack.viewsets import HaystackViewSet
from drf_haystack.filters import HaystackFilter
from .serializers import CourseIndexHaystackSerializer
from .models import Course

class CourseSearchViewSet(HaystackViewSet):
    """课程信息全文搜索视图类"""
    # 指定本次搜索的最终真实数据的保存模型
    index_models = [Course]
    serializer_class = CourseIndexHaystackSerializer
    filter_backends = [OrderingFilter, HaystackFilter]
    ordering_fields = ('id', 'students', 'orders')
    pagination_class = CourseListPageNumberPagination



路由

from django.urls import path,re_path
from . import views

from rest_framework import routers
router = routers.DefaultRouter()
# 注册全文搜索到视图集中生成url路由信息
router.register("search", views.CourseSearchViewSet, basename="course-search")

urlpatterns = [
    path("directions/", views.CourseDirectionListAPIView.as_view()),
    re_path("^categories/(?P<direction>\d+)/$", views.CourseCategoryListAPIView.as_view()),
    re_path("^(?P<direction>\d+)/(?P<category>\d+)/$", views.CourseListAPIView.as_view()),
] + router.urls



手动构建es索引

因为此前mysql中已经有了部分的数据,而这部分数据在es中是没有创建索引。所以需要先把之前的数据同步生成全文索引。在终端下执行以下命令

# 重建索引
python manage.py rebuild_index

# 更新索引
# python manage.py update_index --age=<num_hours>

# 删除索引
# python manage.py clear_index



访问

http://127.0.0.1:8000/courses/search/?text=入门

1662697007805

http://api.fuguang.cn:8000/courses/search/?text=李老师

提交代码版本:

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 集成elasticsearch到项目中提供全文搜索"
git push



客户端实现课程全文搜索

api/course.js,代码:

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

const course = reactive({
    // 其他代码省略....
    text: "",         // 搜索文本框内容
    // 中间代码省略....
    search_course() {
        // 课程搜索
        let params = {
            page: this.page,
            size: this.size,
            text: this.text,
        }
        if (this.ordering) {
            params['ordering'] = this.ordering
        }
        return http.get(`/courses/search`, {
            params,
        })
    }
});

export default course;



views/Course.vue,代码:

                <div class="actual-header-search">
                    <div class="search-inner">
                        <input class="actual-search-input" v-model="course.text" placeholder="搜索感兴趣的实战课程内容" type="text" autocomplete="off">
                        <img class="actual-search-button" src="../assets/search.svg" @click.prevent.stop="get_course_list" />
                    </div>
                    <div class="actual-searchtags">
                    </div>
                    <div class="search-hot">
                        <span>热搜:</span>
                        <a href="">Java工程师</a>
                        <a href="">Vue</a>
                    </div>
                </div>



<script setup>
import {watch} from "vue";
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import course from "../api/course";
import {fill0} from "../utils/func";

    ...

const get_course_list = ()=>{
  // 获取课程列表
  let ret  = null // 预设一个用于保存服务端返回的数据
  if(course.text) {
    ret = course.search_course()
  }else{
    ret = course.get_course_list()
  }
  ret.then(response=>{
    course.course_list = response.data.results;
    // 总数据量
    course.count = response.data.count;
    course.has_perv = !!response.data.previous; // !!2个非表示把数据转换成布尔值
    course.has_next = !!response.data.next;

    // 优惠活动的倒计时
    course.start_timer();
  })
}

get_course_list();

...

</script>



提交代码版本:

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 客户端实现课程信息全文搜搜"
git push


热门搜索关键字

服务端实现搜索关键字的api接口

在用户搜索课程内容时,提供一个单独的redis仓库使用zset有序集合进行存储热门关键字.

settings/dev.py,代码:

# redis configration
# 设置redis缓存
CACHES = {
    # 默认缓存
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        # 项目上线时,需要调整这里的路径
        # "LOCATION": "redis://:密码@IP地址:端口/库编号",
        "LOCATION": "redis://:@127.0.0.1:6379/0",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "CONNECTION_POOL_KWARGS": {"max_connections": 10},  # 连接池
        }
    },
    # 提供给admin运营站点的session存储
    "session": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://:@127.0.0.1:6379/1",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "CONNECTION_POOL_KWARGS": {"max_connections": 10},
        }
    },
    # 提供存储短信验证码
    "sms_code": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://:@127.0.0.1:6379/2",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "CONNECTION_POOL_KWARGS": {"max_connections": 10},
        }
    },
    # 提供存储搜索热门关键字
    "hot_word": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://:@127.0.0.1:6379/3",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
        }
    },
}



constants.py中,配置有序集合中代表了课程搜索的key值。settings/constants.py,代码:

# 设置热门搜索关键字在redis中的key前缀名称
DEFAULT_HOT_WORD = "hot_word"
# 设置返回的热门搜索关键字的数量
HOT_WORD_LENGTH = 5
# 设置热门搜索关键字的有效期时间[单位:天]
HOT_WORD_EXPIRE = 7



视图代码中,在用户每次搜索内容时给对应的搜索关键字增加搜索权重[score],courses/views.py,代码:

import constants
from datetime import datetime
from drf_haystack.viewsets import HaystackViewSet
from drf_haystack.filters import HaystackFilter
from django_redis import get_redis_connection
from .serializers import CourseIndexHaystackSerializer
from .models import Course


class CourseSearchViewSet(HaystackViewSet):
    """课程信息全文搜索视图类"""
    # 指定本次搜索的最终真实数据的保存模型
    index_models = [Course]
    serializer_class = CourseIndexHaystackSerializer
    filter_backends = [OrderingFilter, HaystackFilter]
    ordering_fields = ('id', 'students', 'orders')
    pagination_class = CourseListPageNumberPagination

    def list(self, request, *args, **kwargs):
        # 保存本次搜索的关键字
        redis = get_redis_connection("hot_word")
        text = request.query_params.get("text")
        if text:
            key = f"{constants.DEFAULT_HOT_WORD}:{datetime.now().strftime('%Y:%m:%d')}"
            is_exists = redis.exists(key)
            redis.zincrby(key, 1, text)  # 让有序集合中的text搜索关键字次数+1,如果该关键字第一次出现,则为1
            if not is_exists:
                redis.expire(key, constants.HOT_WORD_EXPIRE * 24 * 3600)

        return super().list(request, *args, **kwargs)




服务端编写视图提供热门搜索关键字列表的api接口,courses/views.py,代码:

from rest_framework.views import APIView
from rest_framework.response import Response
from datetime import datetime, timedelta


class HotWordAPIView(APIView):
    """搜索热词"""
    def get(self, request):
        redis = get_redis_connection("hot_word")
        # 获取最近指定天数的热词的key
        date_list = []
        for i in range(0, constants.HOT_WORD_EXPIRE):
            day = datetime.now() - timedelta(days=i)
            fullmonth = day.month if day.month >= 10 else f"0{day.month}"
            fullday = day.day if day.day >= 10 else f"0{day.day}"
            key = f"{constants.DEFAULT_HOT_WORD}:{day.year}:{fullmonth}:{fullday}"
            date_list.append(key)

        # 先删除原有的统计最近几天的热搜词的有序统计集合
        redis.delete(constants.DEFAULT_HOT_WORD)
        # ZUNIONSTORE hot_word 7 "hot_word:2021:11:22" "hot_word:2021:11:21"  "hot_word:2021:11:20" "hot_word:2021:11:19" "hot_word:2021:11:18" "hot_word:2021:11:17" "hot_word:2021:11:16"
        # 根据date_list找到最近指定天数的所有集合,并完成并集计算,产生新的有序统计集合constants.DEFAULT_HOT_WORD
        redis.zunionstore(constants.DEFAULT_HOT_WORD, date_list, aggregate="sum")
        # 按分数store进行倒序显示排名靠前的指定数量的热词
        word_list = redis.zrevrange(constants.DEFAULT_HOT_WORD, 0, constants.HOT_WORD_LENGTH-1)
        return Response(word_list)




路由,course.views,代码:

from django.urls import path,re_path
from . import views

from rest_framework import routers
router = routers.DefaultRouter()
# 注册全文搜索到视图集中生成url路由信息
router.register("search", views.CourseSearchViewSet, basename="course-search")

urlpatterns = [
    path("directions/", views.CourseDirectionListAPIView.as_view()),
    re_path("^categories/(?P<direction>\d+)/$", views.CourseCategoryListAPIView.as_view()),
    re_path("^(?P<direction>\d+)/(?P<category>\d+)/$", views.CourseListAPIView.as_view()),
    path("hot_word/", views.HotWordAPIView.as_view()),
] + router.urls




提交代码版本:

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 服务端实现搜索关键字的记录以及提供搜索关键字列表的API接口"
git push



客户端显示热门搜索关键字

api/course.js,代码:

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

const course = reactive({
    // .... 中间代码省略
    hot_word_list: [],// 热搜词列表
    // .... 中间代码省略
    get_hot_word(){
        // 课程热搜关键字
        return http.get("/courses/hot_word")
    }
});

export default course;



views/Course.vue,代码:

                <div class="actual-header-search">
                    <div class="search-inner">
                        <input class="actual-search-input" v-model="course.text" placeholder="搜索感兴趣的实战课程内容" type="text" autocomplete="off">
                        <img class="actual-search-button" src="../assets/search.svg" @click.prevent.stop="get_course_list"/>
                    </div>
                    <div class="actual-searchtags">
                    </div>
                    <div class="search-hot">
                        <span>热搜:</span>
                        <a href="" @click.stop.prevent="search_by_hotword(hot_word)" v-for="hot_word in course.hot_word_list">{{hot_word}}</a>
                    </div>
                </div>



<script setup>
import {reactive,ref, watch} from "vue"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import course from "../api/course";
import {fill0} from "../utils/func";


// 获取学习方向的列表数据
course.get_course_direction().then(response=>{
  course.direction_list = response.data;
})


// 获取课程分类的列表数据
const get_category = ()=>{
  // 获取课程分类
  course.get_course_category().then(response=>{
    course.category_list = response.data;
  })
}

get_category();


const get_hot_word = ()=>{
  // 搜索热门关键字列表
  course.get_hot_word().then(response=>{
    course.hot_word_list = response.data
  })
}


const get_course_list = ()=>{
  // 获取课程列表
  let ret  = null // 预设一个用于保存服务端返回的数据
  if(course.text) {
    ret = course.search_course()
  }else{
    ret = course.get_course_list()
  }

  ret.then(response=>{
    course.course_list = response.data.results;
    // 总数据量
    course.count = response.data.count;
    course.has_perv = !!response.data.previous; // !!2个非表示把数据转换成布尔值
    course.has_next = !!response.data.next;
    // 优惠活动的倒计时
    course.start_timer();
  })

  // 每次获取课程都同事获取一次热搜词列表
  get_hot_word();

}

get_course_list();


// 当热搜词被点击,进行搜索
const search_by_hotword = (hot_word)=>{
  course.text = hot_word
  get_course_list()
}


watch(
    // 监听当前学习方向,在改变时,更新对应方向下的课程分类与课程信息
    ()=> course.current_direction,
    ()=>{
        // 重置排序条件
        course.ordering = "-id";
        // 重置当前选中的课程分类
        course.current_category=0;
        get_category();
        get_course_list();
    }
)

watch(
    // 监听切换不同的课程分类,在改变时,更新对应分类下的课程信息
    ()=> course.current_category,
    ()=>{
        // 重置排序条件
        course.ordering = "-id";
        get_course_list();
    }
)


watch(
    // 监听课程切换不同的排序条件
    ()=>course.ordering,
    ()=>{
        get_course_list();
    }
)

// 监听页码
watch(
    ()=>course.page,
    ()=>{
        // 重新获取课程信息
        get_course_list();
    }
)



</script>


提交代码版本:

cd /home/moluo/Desktop/luffycity
git add .
git commit -m "feature: 客户端展示最近7天的热搜关键字列表并实现点击关键字自动搜索内容"
git push


课程详情页显示

课程详情页 views/Info.vue,代码:

<template>
    <div class="detail">
      <Header/>
      <div class="main">
        <div class="course-info">
          <div class="wrap-left">
            <!-- 课程封面或封面商品 -->
          </div>
          <div class="wrap-right">
            <h3 class="course-name">Linux系统基础5周入门精讲</h3>
            <p class="data">23475人在学&nbsp;&nbsp;&nbsp;&nbsp;课程总时长:148课时/180小时&nbsp;&nbsp;&nbsp;&nbsp;难度:初级</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">¥29.00</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="../assets/cart-yellow.svg" alt="">加入购物车</div>
            </div>
          </div>
        </div>
        <div class="course-tab">
          <ul class="tab-list">
            <li :class="state.tabIndex===1?'active':''" @click="state.tabIndex=1">详情介绍</li>
            <li :class="state.tabIndex===2?'active':''" @click="state.tabIndex=2">课程章节 <span :class="state.tabIndex!==2?'free':''">(试学)</span></li>
            <li :class="state.tabIndex===3?'active':''" @click="state.tabIndex=3">用户评论 (42)</li>
            <li :class="state.tabIndex===4?'active':''" @click="state.tabIndex=4">常见问题</li>
          </ul>
        </div>
        <div class="course-content">
          <div class="course-tab-list">
            <div class="tab-item" v-if="state.tabIndex===1">
              <p><img alt="" src="//hcdn2.luffycity.com/media/frontend/activity/详情页_01.png"></p>
            </div>
            <div class="tab-item" v-if="state.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="../assets/1.svg" 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="../assets/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="../assets/chapter-player.svg"></p>
                    <button class="try">立即试学</button>
                  </li>
                </ul>
              </div>
              <div class="chapter-item">
                <p class="chapter-title"><img src="../assets/1.svg" 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="../assets/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="../assets/chapter-player.svg"></p>
                    <button class="try">立即购买</button>
                  </li>
                </ul>
              </div>
            </div>
            <div class="tab-item" v-if="state.tabIndex===3">
              用户评论
            </div>
            <div class="tab-item" v-if="state.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="../assets/avatar.jpg">
                   <div class="name">
                     <p class="teacher-name">Avrion</p>
                     <p class="teacher-title">老男孩LInux学科带头人</p>
                   </div>
                 </div>
                 <p class="narrative" >路飞学城高级讲师,曾参与新加坡南洋理工大学大数据医疗相关项目,就职过多家互联网企业,有着多年开发经验,精通java,python,go等编程语言</p>
               </div>
             </div>
          </div>
        </div>
      </div>
      <Footer/>
    </div>
</template>

<script setup>
import {reactive,ref,watch} from "vue"
import {useRoute} from "vue-router"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"

let route = useRoute()

const state = reactive({
  course_id: route.params.id,
  tabIndex: 2,
})

</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 {createRouter, createWebHistory, createWebHashHistory} from 'vue-router'
import store from "../store";

// 路由列表
const routes = [
  ...
  {
    meta:{
        title: "luffy2.0-课程详情",
        keepAlive: true
    },
    path: '/project/:id',     // :id vue的路径参数,代表了课程的ID
    name: "Info",
    component: ()=> import("../views/Info.vue"),
  }
]


...


// 暴露路由对象
export default router


课程列表的组件中, 打通点击前往详情页的链接地址,views/Course.vue,代码:

<ul class="course-list clearfix">
              <li class="course-card" v-for="course_info in course.course_list">
                <router-link :to="`/project/${course_info.id}`">
                    <div class="img"><img :src="course_info.course_cover" alt=""></div>
                    <p class="title ellipsis2">{{course_info.name}}</p>
                    <p class="one">
                        <span>{{course_info.get_level_display}} · {{course_info.students}}人报名</span>
                        <span class="discount r">
                          <i class="name" v-if="course_info.discount.type">{{course_info.discount.type}}</i>
                          <i class="countdown" v-if="course_info.discount.expire">{{parseInt(course_info.discount.expire/86400)}}<span class="day">天</span>{{fill0(parseInt(course_info.discount.expire/3600%24))}}:{{fill0(parseInt(course_info.discount.expire/60%60))}}:{{fill0(parseInt(course_info.discount.expire%60))}}</i>
                        </span>
                    </p>
                    <p class="two clearfix">
                        <span class="price l red bold" v-if="course_info.discount.price">¥{{parseFloat(course_info.discount.price).toFixed(2)}}</span>
                        <span class="price l red bold" v-else>¥{{parseFloat(course_info.price).toFixed(2)}}</span>
                        <span class="origin-price l delete-line" v-if="course_info.discount.price">¥{{parseFloat(course_info.price).toFixed(2)}}</span>
                        <span class="add-shop-cart r"><img class="icon imv2-shopping-cart" src="../assets/cart2.svg">加购物车</span>
                    </p>
                </router-link>
              </li>
            </ul>


提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 客户端初步展示课程详情页"
git push

视频播放器

针对客户端的课程详情页的左上角内容,我们可以显示课程的详情图片,如果有课程的介绍视频,也可以优先显示视频,

当然播放视频,肯定需要对应的播放器插件。市面上很多:百度云、腾讯云、网易云、阿里云、又拍云、七牛云或者其他第三方。

OK,接下来使用的播放器组件,选择使用了阿里云播放器(vue-alipayer视频播放组件),所以我们需要先预安装。

vue-alipayer地址:https://github.com/liho98/vue-aliplayer-v2

演示效果:https://player.alicdn.com/aliplayer/index.html

安装依赖

cd /home/moluo/Desktop/luffycity/luffycityweb/
yarn add vue-aliplayer-v3

Info.vue页面组件中调用播放器组件,代码:

          <div class="wrap-left">
            <!-- 课程封面或封面商品 -->
            <AliPlayerV3
              ref="player"
              class="h-64 md:h-96 w-full rounded-lg"
              style="height: 100%; width: 100%;"
              :options="options"
              @play="onPlay($event)"
              @pause="onPause($event)"
              @playing="onPlaying($event)"
            />
          </div>

<script setup>
import {reactive,ref,watch} from "vue"
import {useRoute} from "vue-router"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import { AliPlayerV3 } from "vue-aliplayer-v3"

let route = useRoute()
let player = ref(null)

const state = reactive({
  course_id: route.params.id,
  tabIndex: 2,
})

const options = reactive({
  source: "/src/assets/1.mp4",
  cover: "/src/assets/course-1.png",
  autoplay: false,
  preload: true,
  isLive: false, //切换为直播流的时候必填true
  // format: 'm3u8'  //切换为直播流的时候必填
})

const onPlay = (event)=>{
  console.log("播放视频");
  console.log(player.value.getCurrentTime());
}

const onPause = (event)=>{
  console.log("暂停播放");
  console.log(player.value.getCurrentTime());
}

const onPlaying = (event)=>{
  console.log("播放中");
  console.log(player.value.getCurrentTime());
}

</script>

提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 客户端基于aliplayer播放器插件展示课程封面图片与视频"
git push

后端提供课程详情页数据接口

courses/serializers.py,序列化器代码:

from .models import Teacher


class CourseTearchModelSerializer(serializers.ModelSerializer):
    """课程老师信息"""

    class Meta:
        model = Teacher
        fields = ["id", "name", "avatar", "role", "get_role_display", "title", "signature", "brief"]


class CourseRetrieveModelSerializer(serializers.ModelSerializer):
    """课程详情的序列化器"""
    direction_name = serializers.CharField(source="direction.name")
    # direction = serializers.SlugRelatedField(read_only=True, slug_field='name')
    category_name = serializers.CharField(source="category.name")
    # 序列化器嵌套
    teacher = CourseTearchModelSerializer()

    class Meta:
        model = Course
        fields = [
            "name", "course_cover", "course_video", "level", "get_level_display",
            "description", "pub_date", "status", "get_status_display", "students", "discount",
            "lessons", "pub_lessons", "price", "direction", "direction_name", "category", "category_name", "teacher"
        ]

视图代码:

from rest_framework.generics import RetrieveAPIView
from .models import Course
from .serializers import CourseRetrieveModelSerializer


class CourseRetrieveAPIView(RetrieveAPIView):
    """课程详情信息"""
    queryset = Course.objects.filter(is_show=True, is_deleted=False).all()
    serializer_class = CourseRetrieveModelSerializer


路由代码:

urlpatterns = [
    # 。。。。
    re_path("^(?P<pk>\d+)/$", views.CourseRetrieveAPIView.as_view()),
]


提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 服务端提供课程详情的api接口"
git push

客户端请求api接口并展示课程详情信息

src/api/course.js,代码:

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


const course = reactive({
    // 中间代码省略...
    course_id: null,  // 课程ID
    info: {           // 课程详情信息
        teacher:{},   // 课程相关的老师信息
        discount:{    // 课程相关的折扣信息
          type: ""
        }
    },
    tabIndex: 1,      // 课程详情页中默认展示的课程信息的选项卡
    // 中间代码省略...
    // 获取课程详情
    get_course(){
        return http.get(`/courses/${this.course_id}`)
    },
})

export default course;


views/Info.vue,代码:

<template>
    <div class="detail">
      <Header/>
      <div class="main">
        <div class="course-info">
          <div class="wrap-left">
            <!-- 课程封面或封面商品 -->
            <AliPlayerV3
              ref="player"
              class="h-64 md:h-96 w-full rounded-lg"
              style="height: 100%; width: 100%;"
              :source="course.info.course_video"
              :cover="course.info.course_cover"
              :options="options"
              @play="onPlay($event)"
              @pause="onPause($event)"
              @playing="onPlaying($event)"
              v-if="course.info.course_video"
            />
            <img :src="course.info.course_cover" style="width: 100%;" alt="" v-else>
          </div>
          <div class="wrap-right">
            <h3 class="course-name">{{course.info.name}}</h3>
            <p class="data">{{course.info.students}}人在学&nbsp;&nbsp;&nbsp;&nbsp;课程总时长:{{course.info.pub_lessons}}课时/{{course.info.lessons}}课时&nbsp;&nbsp;&nbsp;&nbsp;难度:{{course.info.get_level_display}}</p>
            <div class="sale-time" v-if="course.info.discount.type">
              <p class="sale-type">{{course.info.discount.type}}</p>
              <p class="expire" v-if="course.info.discount.expire>0">距离结束:仅剩 {{parseInt(course.info.discount.expire/86400)}}天 {{fill0(parseInt(course.info.discount.expire/3600%24))}}小时 {{fill0(parseInt(course.info.discount.expire/60%60))}}分 <span class="second">{{fill0(parseInt(course.info.discount.expire%60))}}</span> 秒</p>
            </div>
            <div class="sale-time" v-if="!course.info.discount.type">
              <p class="sale-type">课程价格 ¥{{parseFloat(course.info.price).toFixed(2)}}</p>
            </div>
            <p class="course-price" v-if="course.info.discount.price">
              <span>活动价</span>
              <span class="discount">¥{{parseFloat(course.info.discount.price).toFixed(2)}}</span>
              <span class="original">¥{{parseFloat(course.info.price).toFixed(2)}}</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="../assets/cart-yellow.svg" alt="">加入购物车</div>
            </div>
          </div>
        </div>
        <div class="course-tab">
          <ul class="tab-list">
            <li :class="course.tabIndex===1?'active':''" @click="course.tabIndex=1">详情介绍</li>
            <li :class="course.tabIndex===2?'active':''" @click="course.tabIndex=2">课程章节 <span :class="course.tabIndex!==2?'free':''">(试学)</span></li>
            <li :class="course.tabIndex===3?'active':''" @click="course.tabIndex=3">用户评论 (42)</li>
            <li :class="course.tabIndex===4?'active':''" @click="course.tabIndex=4">常见问题</li>
          </ul>
        </div>
        <div class="course-content">
          <div class="course-tab-list">
            <div class="tab-item" v-if="course.tabIndex===1" v-html="course.info.description">

            </div>
            <div class="tab-item" v-if="course.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="../assets/1.svg" 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="../assets/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="../assets/chapter-player.svg"></p>
                    <button class="try">立即试学</button>
                  </li>
                </ul>
              </div>
              <div class="chapter-item">
                <p class="chapter-title"><img src="../assets/1.svg" 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="../assets/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="../assets/chapter-player.svg"></p>
                    <button class="try">立即购买</button>
                  </li>
                </ul>
              </div>
            </div>
            <div class="tab-item" v-if="course.tabIndex===3">
              用户评论
            </div>
            <div class="tab-item" v-if="course.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="course.info.teacher.avatar">
                   <div class="name">
                     <p class="teacher-name">{{course.info.teacher.name}}</p>
                     <p class="teacher-title">{{course.info.teacher.get_role_display}},{{course.info.teacher.title}}</p>
                   </div>
                 </div>
                 <div class="narrative" v-html="course.info.teacher.brief"></div>
               </div>
             </div>
          </div>
        </div>
      </div>
      <Footer/>
    </div>
</template>


<script setup>
import {reactive,ref, watch} from "vue"
import {useRoute, useRouter} from "vue-router"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import { AliPlayerV3 } from "vue-aliplayer-v3"
import course from "../api/course"
import { ElMessage } from 'element-plus'
import {fill0} from "../utils/func";

let route = useRoute()
let router= useRouter()
let player = ref(null)

// 获取url地址栏上的课程ID
course.course_id = route.params.id;

// 简单判断课程ID是否合法
if(course.course_id > 0){
  // 根据课程ID到服务端获取课程详情数据
  course.get_course().then(response=>{
    course.info = response.data;
    clearInterval(course.timer);
    course.timer = setInterval(()=>{
      if(course.info.discount.expire && course.info.discount.expire>0){
            course.info.discount.expire--
        }
    },1000);

  }).catch(error=>{
    ElMessage.error({
      message: "非法的URL地址,无法获取课程信息!",
      duration: 1000,
      onClose(){
        router.go(-1)
      }
    })
  })
}else{
    ElMessage.error({
      message: "非法的URL地址,无法获取课程信息!",
      duration: 1000,
      onClose(){
        router.go(-1)
      }
    })
}

// 阿里云播放器的选项参数
const options = reactive({
  // source: "/src/assets/1.mp4",
  // cover: "/src/assets/course-1.png",
  autoplay: false,   // 是否自动播放
  preload: true,     // 是否自动预加载
  isLive: false,     // 切换为直播流的时候必填true
  // format: 'm3u8'  // 切换为直播流的时候必填
})

const onPlay = (event)=>{
  console.log("播放视频");
  console.log(player.value.getCurrentTime());  // 当前视频播放时间
}

const onPause = (event)=>{
  console.log("暂停播放");
  console.log(player.value.getCurrentTime());
}

const onPlaying = (event)=>{
  console.log("播放中");
  console.log(player.value.getCurrentTime());
  console.log(player.value.getDuration());  // 获取视频长度
}

</script>


提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 客户端展示课程详情信息"
git push

服务端提供课程对应的章节列表和课时列表信息

courses/models.py,给章节模型新增返回课时列表字段,代码:

class CourseChapter(BaseModel):
    """课程章节"""
    orders = models.SmallIntegerField(default=1, verbose_name="第几章")
    summary = RichTextUploadingField(blank=True, null=True, verbose_name="章节介绍")
    pub_date = models.DateField(auto_now_add=True, verbose_name="发布日期")
    course = models.ForeignKey("Course", related_name='chapter_list', on_delete=models.CASCADE, db_constraint=False, verbose_name="课程名称")

    class Meta:
        db_table = "lf_course_chapter"
        verbose_name = "课程章节"
        verbose_name_plural = verbose_name

    def __str__(self):
        return "%s-第%s章-%s" % (self.course.name, self.orders, self.name)

    # 自定义字段
    def text(self):
        return self.__str__()
    # admin站点配置排序规则和显示的字段文本提示
    text.short_description = "章节名称"
    text.allow_tags = True
    text.admin_order_field = "orders"


    def get_lesson_list(self):
        """返回当前章节的课时列表"""
        lesson_list = self.lesson_list.filter(is_deleted=False, is_show=True).order_by("orders").all()
        return [{
            "id":lesson.id,
            "name":lesson.name,
            "orders":lesson.orders,
            "duration":lesson.duration,
            "lesson_type":lesson.lesson_type,
            "lesson_link":lesson.lesson_link,
            "free_trail":lesson.free_trail
        } for lesson in lesson_list]




courses.serializers,序列化器,代码:

from .models import CourseChapter
class CourseChapterModelSerializer(serializers.ModelSerializer):
    """课程章节序列化器"""
    class Meta:
        model = CourseChapter
        fields = ["id", "orders", "name", "summary", "get_lesson_list"]


courses/views.py视图,代码:

from .models import CourseChapter
from .serializers import CourseChapterModelSerializer


class CourseChapterListAPIView(ListAPIView):
    """课程章节列表"""
    serializer_class = CourseChapterModelSerializer
    def get_queryset(self):
        """列表页数据"""
        course = int(self.kwargs.get("course", 0))
        try:
            ret = Course.objects.filter(pk=course).all()
        except:
            return []
        queryset = CourseChapter.objects.filter(course=course,is_show=True, is_deleted=False).order_by("orders", "id")
        return queryset.all()



courses.urls,路由,代码:

urlpatterns = [
	# 。。。。
    re_path("^(?P<course>\d+)/chapters/$", views.CourseChapterListAPIView.as_view()),
]

1662712197380

提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 服务端提供课程对应的章节列表和课时列表信息"
git push

客户端请求章节信息展示到页面中

src/api/course.js,代码:

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


const course = reactive({
    // 中间代码省略....
    chapter_list: [], // 课程章节列表
    // 中间代码省略....
    get_course_chapters(){
        // 获取指定课程的章节列表
        return http.get(`/courses/${this.course_id}/chapters`)
    }
})

export default course;


views/Info.vue,代码:

<div class="tab-item" v-if="course.tabIndex===2">
              <div class="tab-item-title">
                <p class="chapter">课程章节</p>
                <p class="chapter-length">共{{course.chapter_list.length}}章 {{course.info.lessons}}个课时</p>
              </div>
              <div class="chapter-item" v-for="chapter in course.chapter_list">
                <p class="chapter-title"><img src="../assets/1.svg" alt="">第{{chapter.orders}}章·{{chapter.name}}</p>
                <div class="chapter-title" style="padding-left: 2.4rem;" v-if="chapter.summary" v-html="chapter.summary"></div>
                <ul class="lesson-list">
                  <li class="lesson-item" v-for="lesson in chapter.get_lesson_list">
                    <p class="name">
                      <span class="index">{{chapter.orders}}-{{lesson.orders}}</span>
                      {{lesson.name}}
                      <span class="free" v-if="lesson.free_trail">免费</span>
                    </p>
                    <p class="time">{{lesson.duration}} <img src="../assets/chapter-player.svg"></p>
                    <button class="try"  v-if="lesson.free_trail">立即试学</button>
                    <button class="try" v-else>购买课程</button>
                  </li>
                </ul>
              </div>
            </div>


<script setup>
import {reactive,ref, watch} from "vue"
import {useRoute, useRouter} from "vue-router"
import Header from "../components/Header.vue"
import Footer from "../components/Footer.vue"
import { AliPlayerV3 } from "vue-aliplayer-v3"
import course from "../api/course"
import { ElMessage } from 'element-plus'
import {fill0} from "../utils/func";

let route = useRoute()
let router= useRouter()
let player = ref(null)

// 获取url地址栏上的课程ID
course.course_id = route.params.id;


// 简单判断课程ID是否合法
if(course.course_id > 0){
  // 根据课程ID到服务端获取课程详情数据
  course.get_course().then(response=> {
    course.info = response.data;
    clearInterval(course.timer);
    course.timer = setInterval(() => {
      if (course.info.discount.expire && course.info.discount.expire > 0) {
        course.info.discount.expire--
      }
    }, 1000);
  }).catch(error=>{
    ElMessage.error({
      message: "非法的URL地址,无法获取课程信息!",
      duration: 1000,
      onClose(){
        router.go(-1);
      }
    })
  })

  // 获取课程章节信息
  course.get_course_chapters().then(response=>{
    course.chapter_list = response.data
  })

}else{
    ElMessage.error({
      message: "非法的URL地址,无法获取课程信息!",
      duration: 1000,
      onClose(){
        router.go(-1)
      }
    })
}


// 阿里云播放器的选项参数
const options = reactive({
  // source: "/src/assets/1.mp4",
  // cover: "/src/assets/course-1.png",
  autoplay: false,   // 是否自动播放
  preload: true,     // 是否自动预加载
  isLive: false,     // 切换为直播流的时候必填true
  // format: 'm3u8'  // 切换为直播流的时候必填
})

const onPlay = (event)=>{
  console.log("播放视频");
  console.log(player.value.getCurrentTime());  // 当前视频播放时间
}

const onPause = (event)=>{
  console.log("暂停播放");
  console.log(player.value.getCurrentTime());
}

const onPlaying = (event)=>{
  console.log("播放中");
  console.log(player.value.getCurrentTime());
  console.log(player.value.getDuration());  // 获取视频长度
}

</script>



服务端课程详情信息接口新增返回试学的判断状态。

courses.models,代码:

class Course(BaseModel):
   
 ...

    @property
    def can_free_study(self):
        """是否允许试学"""
        lesson_list = self.lesson_list.filter(is_deleted=False, is_show=True).order_by("orders").all()
        return len(lesson_list) > 0

courses/serializers.py,代码:

class CourseRetrieveModelSerializer(serializers.ModelSerializer):
    """课程详情的序列化器"""
    diretion_name = serializers.CharField(source="diretion.name")
    # diretion = serializers.SlugRelatedField(read_only=True, slug_field='name')
    category_name = serializers.CharField(source="category.name")
    # 序列化器嵌套
    teacher = CourseTearchModelSerializer()

    class Meta:
        model = Course
        fields = [
            "name", "course_cover", "course_video", "level", "get_level_display",
            "description", "pub_date", "status", "get_status_display", "students","discount",
            "lessons", "pub_lessons", "price", "diretion", "diretion_name", "category", "category_name", "teacher","can_free_study"
        ]

客户端直接根据con_free_study来判断是否显示试学。

api/course.js

    can_free_study: false, // 是否展示试学

views/Info.vue,代码:

          <ul class="tab-list">
            <li :class="state.tabIndex===1?'active':''" @click="state.tabIndex=1">详情介绍</li>
            <li :class="state.tabIndex===2?'active':''" @click="state.tabIndex=2">课程章节 <span :class="state.tabIndex!==2?'free':''" v-if="course.can_free_study">(试学)</span></li>
            <li :class="state.tabIndex===3?'active':''" @click="state.tabIndex=3">用户评论 (42)</li>
            <li :class="state.tabIndex===4?'active':''" @click="state.tabIndex=4">常见问题</li>
          </ul>

  // 简单判断课程ID是否合法
    if(course.course_id > 0){
        // 根据课程ID到服务端获取课程详情数据
        course.get_course().then(response=>{
            course.can_free_study = response.data.can_free_study;
            .....

后台添加模拟数据。

INSERT INTO luffycity.lf_course_chapter (id, name, is_deleted, is_show, created_time, updated_time, orders, summary, pub_date, course_id) VALUES (1, 'Typescript快速入门', 0, 1, '2022-03-21 05:39:39.925451', '2022-03-21 05:39:39.925775', 1, '<p>Typescript快速入门的相关概念以及基本安装和基本使用</p>', '2022-03-21', 1);
INSERT INTO luffycity.lf_course_chapter (id, name, is_deleted, is_show, created_time, updated_time, orders, summary, pub_date, course_id) VALUES (2, 'Typescript的基本语法', 0, 1, '2022-03-21 05:40:38.672697', '2022-03-21 05:40:38.672749', 1, '<p>注释、数据类型、类型注解、函数、面向对象语法、泛型等</p>', '2022-03-21', 1);

INSERT INTO luffycity.lf_course_lesson (id, name, is_deleted, is_show, created_time, updated_time, orders, lesson_type, lesson_link, duration, pub_date, free_trail, recomment, chapter_id, course_id) VALUES (1, 'Typescript基本介绍', 0, 1, '2022-03-21 05:41:47.975350', '2022-03-21 05:41:47.975495', 1, 2, 'https://luffycityoline.oss-cn-beijing.aliyuncs.com/uploads/course/video/1.mp4', '5:00', '2022-03-21 05:41:47.975554', 1, 1, 1, 1);
INSERT INTO luffycity.lf_course_lesson (id, name, is_deleted, is_show, created_time, updated_time, orders, lesson_type, lesson_link, duration, pub_date, free_trail, recomment, chapter_id, course_id) VALUES (2, 'Typescript与javascript的关系', 0, 1, '2022-03-21 05:42:13.059002', '2022-03-21 05:42:13.059077', 2, 2, 'https://luffycityoline.oss-cn-beijing.aliyuncs.com/uploads/course/video/1.mp4', '3:00', '2022-03-21 05:42:13.059128', 1, 1, 1, 1);
INSERT INTO luffycity.lf_course_lesson (id, name, is_deleted, is_show, created_time, updated_time, orders, lesson_type, lesson_link, duration, pub_date, free_trail, recomment, chapter_id, course_id) VALUES (3, 'Typescript基本安装', 0, 1, '2022-03-21 05:42:29.797695', '2022-03-21 05:42:29.797750', 3, 2, 'https://luffycityoline.oss-cn-beijing.aliyuncs.com/uploads/course/video/1.mp4', '10:00', '2022-03-21 05:42:29.797796', 1, 1, 1, 1);
INSERT INTO luffycity.lf_course_lesson (id, name, is_deleted, is_show, created_time, updated_time, orders, lesson_type, lesson_link, duration, pub_date, free_trail, recomment, chapter_id, course_id) VALUES (4, 'Typescript快速使用', 0, 1, '2022-03-21 05:42:43.776543', '2022-03-21 05:42:43.776618', 4, 2, 'https://luffycityoline.oss-cn-beijing.aliyuncs.com/uploads/course/video/1.mp4', '10:00', '2022-03-21 05:42:43.776672', 1, 1, 1, 1);
INSERT INTO luffycity.lf_course_lesson (id, name, is_deleted, is_show, created_time, updated_time, orders, lesson_type, lesson_link, duration, pub_date, free_trail, recomment, chapter_id, course_id) VALUES (5, 'Typescript的解释器基本使用', 0, 1, '2022-03-21 05:43:07.315028', '2022-03-21 05:43:07.315092', 5, 2, 'https://luffycityoline.oss-cn-beijing.aliyuncs.com/uploads/course/video/1.mp4', '10:00', '2022-03-21 05:43:07.315150', 1, 1, 1, 1);
INSERT INTO luffycity.lf_course_lesson (id, name, is_deleted, is_show, created_time, updated_time, orders, lesson_type, lesson_link, duration, pub_date, free_trail, recomment, chapter_id, course_id) VALUES (6, 'Typescript的注释写法', 0, 1, '2022-03-21 05:43:43.696556', '2022-03-21 05:43:43.696611', 1, 2, 'https://luffycityoline.oss-cn-beijing.aliyuncs.com/uploads/course/video/1.mp4', '4:00', '2022-03-21 05:43:43.696656', 1, 0, 2, 1);
INSERT INTO luffycity.lf_course_lesson (id, name, is_deleted, is_show, created_time, updated_time, orders, lesson_type, lesson_link, duration, pub_date, free_trail, recomment, chapter_id, course_id) VALUES (7, 'Typescript的变量声明', 0, 1, '2022-03-21 05:44:06.271049', '2022-03-21 05:44:06.271109', 2, 2, 'https://luffycityoline.oss-cn-beijing.aliyuncs.com/uploads/course/video/1.mp4', '4:00', '2022-03-21 05:44:06.271160', 1, 0, 2, 1);
INSERT INTO luffycity.lf_course_lesson (id, name, is_deleted, is_show, created_time, updated_time, orders, lesson_type, lesson_link, duration, pub_date, free_trail, recomment, chapter_id, course_id) VALUES (8, 'Typescript的类型注解', 0, 1, '2022-03-21 05:44:17.103618', '2022-03-21 05:44:17.103717', 3, 2, 'https://luffycityoline.oss-cn-beijing.aliyuncs.com/uploads/course/video/1.mp4', '4:00', '2022-03-21 05:44:17.103765', 1, 0, 2, 1);
INSERT INTO luffycity.lf_course_lesson (id, name, is_deleted, is_show, created_time, updated_time, orders, lesson_type, lesson_link, duration, pub_date, free_trail, recomment, chapter_id, course_id) VALUES (9, 'Typescript的函数声明', 0, 1, '2022-03-21 05:44:44.347650', '2022-03-21 05:44:44.347716', 4, 2, 'https://luffycityoline.oss-cn-beijing.aliyuncs.com/uploads/course/video/1.mp4', '4:00', '2022-03-21 05:44:44.347764', 1, 0, 2, 1);



提交代码版本

cd /home/moluo/Desktop/luffycity/
git add .
git commit -m "feature: 客户端请求章节信息展示到页面中"
git push

posted @ 2022-09-27 20:16  凫弥  阅读(74)  评论(0编辑  收藏  举报