day82:luffy:课程详情页面显示&章节和课时显示&视频播放组件&CKEditor富文本编辑器
目录
1.初始课程详情页面
1.Detail.vue
<!-- 课程详情页面初始页面 --> <template> <div class="detail"> <Vheader/> <div class="main"> <div class="course-info"> <div class="wrap-left"> </div> <div class="wrap-right"> <h3 class="course-name">flask</h3> <p class="data">111人在学 课程总时长:111课时/12小时 难度:</p> <div class="sale-time"> <p class="sale-type">限时免费</p> <p class="expire">距离结束:仅剩 01天 04小时 33分 <span class="second">08</span> 秒</p> </div> <p class="course-price"> <span>活动价</span> <span class="discount">¥0.00</span> <span class="original">¥1111</span> </p> <div class="buy"> <div class="buy-btn"> <button class="buy-now">立即购买</button> <button class="free">免费试学</button> </div> <div class="add-cart"><img src="/static/img/cart-yellow.svg" alt="">加入购物车</div> </div> </div> </div> <div class="course-tab"> <ul class="tab-list"> <li :class="tabIndex==1?'active':''" @click="tabIndex=1">详情介绍</li> <li :class="tabIndex==2?'active':''" @click="tabIndex=2">课程章节 <span :class="tabIndex!=2?'free':''">(试学)</span></li> <li :class="tabIndex==3?'active':''" @click="tabIndex=3">用户评论 (42)</li> <li :class="tabIndex==4?'active':''" @click="tabIndex=4">常见问题</li> </ul> </div> <div class="course-content"> <div class="course-tab-list"> <div class="tab-item" v-if="tabIndex==1"> <div class="course-brief" v-html=""></div> </div> <div class="tab-item" v-if="tabIndex==2"> <div class="tab-item-title"> <p class="chapter">课程章节</p> <p class="chapter-length">共11章 147个课时</p> </div> <div class="chapter-item"> <p class="chapter-title"><img src="/static/img/1.png" alt="">第1章·Linux硬件基础</p> <ul class="lesson-list"> <li class="lesson-item"> <p class="name"><span class="index">1-1</span> 课程介绍-学习流程<span class="free">免费</span></p> <p class="time">07:30 <img src="/static/img/chapter-player.svg"></p> <button class="try">立即试学</button> </li> <li class="lesson-item"> <p class="name"><span class="index">1-2</span> 服务器硬件-详解<span class="free">免费</span></p> <p class="time">07:30 <img src="/static/img/chapter-player.svg"></p> <button class="try">立即试学</button> </li> </ul> </div> <div class="chapter-item"> <p class="chapter-title"><img src="/static/img/1.png" alt="">第2章·Linux发展过程</p> <ul class="lesson-list"> <li class="lesson-item"> <p class="name"><span class="index">2-1</span> 操作系统组成-Linux发展过程</p> <p class="time">07:30 <img src="/static/img/chapter-player.svg"></p> <button class="try">立即购买</button> </li> <li class="lesson-item"> <p class="name"><span class="index">2-2</span> 自由软件-GNU-GPL核心讲解</p> <p class="time">07:30 <img src="/static/img/chapter-player.svg"></p> <button class="try">立即购买</button> </li> </ul> </div> </div> <div class="tab-item" v-if="tabIndex==3"> 用户评论 </div> <div class="tab-item" v-if="tabIndex==4"> 常见问题 </div> </div> <div class="course-side"> <div class="teacher-info"> <h4 class="side-title"><span>授课老师</span></h4> <div class="teacher-content"> <div class="cont1"> <img src=""> <div class="name"> <p class="teacher-name">xxx</p> <p class="teacher-title">ssss</p> </div> </div> <p class="narrative" >kkkk</p> </div> </div> </div> </div> </div> <Footer/> </div> </template> <script> import Vheader from "./common/Vheader" import Footer from "./common/Footer" export default { name: "Detail", data(){ return { tabIndex:1, } }, created(){ }, methods: { }, components:{ Vheader, Footer, } } </script> <style scoped> .main{ background: #fff; padding-top: 30px; } .course-info{ width: 1200px; margin: 0 auto; overflow: hidden; } .wrap-left{ float: left; width: 690px; height: 388px; background-color: #000; } .wrap-right{ float: left; position: relative; height: 388px; } .course-name{ font-size: 20px; color: #333; padding: 10px 23px; letter-spacing: .45px; } .data{ padding-left: 23px; padding-right: 23px; padding-bottom: 16px; font-size: 14px; color: #9b9b9b; } .sale-time{ width: 464px; background: #fa6240; font-size: 14px; color: #4a4a4a; padding: 10px 23px; overflow: hidden; } .sale-type { font-size: 16px; color: #fff; letter-spacing: .36px; float: left; } .sale-time .expire{ font-size: 14px; color: #fff; float: right; } .sale-time .expire .second{ width: 24px; display: inline-block; background: #fafafa; color: #5e5e5e; padding: 6px 0; text-align: center; } .course-price{ background: #fff; font-size: 14px; color: #4a4a4a; padding: 5px 23px; } .discount{ font-size: 26px; color: #fa6240; margin-left: 10px; display: inline-block; margin-bottom: -5px; } .original{ font-size: 14px; color: #9b9b9b; margin-left: 10px; text-decoration: line-through; } .buy{ width: 464px; padding: 0px 23px; position: absolute; left: 0; bottom: 20px; overflow: hidden; } .buy .buy-btn{ float: left; } .buy .buy-now{ width: 125px; height: 40px; border: 0; background: #ffc210; border-radius: 4px; color: #fff; cursor: pointer; margin-right: 15px; outline: none; } .buy .free{ width: 125px; height: 40px; border-radius: 4px; cursor: pointer; margin-right: 15px; background: #fff; color: #ffc210; border: 1px solid #ffc210; } .add-cart{ float: right; font-size: 14px; color: #ffc210; text-align: center; cursor: pointer; margin-top: 10px; } .add-cart img{ width: 20px; height: 18px; margin-right: 7px; vertical-align: middle; } .course-tab{ width: 100%; background: #fff; margin-bottom: 30px; box-shadow: 0 2px 4px 0 #f0f0f0; } .course-tab .tab-list{ width: 1200px; margin: auto; color: #4a4a4a; overflow: hidden; } .tab-list li{ float: left; margin-right: 15px; padding: 26px 20px 16px; font-size: 17px; cursor: pointer; } .tab-list .active{ color: #ffc210; border-bottom: 2px solid #ffc210; } .tab-list .free{ color: #fb7c55; } .course-content{ width: 1200px; margin: 0 auto; background: #FAFAFA; overflow: hidden; padding-bottom: 40px; } .course-tab-list{ width: 880px; height: auto; padding: 20px; background: #fff; float: left; box-sizing: border-box; overflow: hidden; position: relative; box-shadow: 0 2px 4px 0 #f0f0f0; } .tab-item{ width: 880px; background: #fff; padding-bottom: 20px; box-shadow: 0 2px 4px 0 #f0f0f0; } .tab-item-title{ justify-content: space-between; padding: 25px 20px 11px; border-radius: 4px; margin-bottom: 20px; border-bottom: 1px solid #333; border-bottom-color: rgba(51,51,51,.05); overflow: hidden; } .chapter{ font-size: 17px; color: #4a4a4a; float: left; } .chapter-length{ float: right; font-size: 14px; color: #9b9b9b; letter-spacing: .19px; } .chapter-title{ font-size: 16px; color: #4a4a4a; letter-spacing: .26px; padding: 12px; background: #eee; border-radius: 2px; display: -ms-flexbox; display: flex; -ms-flex-align: center; align-items: center; } .chapter-title img{ width: 18px; height: 18px; margin-right: 7px; vertical-align: middle; } .lesson-list{ padding:0 20px; } .lesson-list .lesson-item{ padding: 15px 20px 15px 36px; cursor: pointer; justify-content: space-between; position: relative; overflow: hidden; } .lesson-item .name{ font-size: 14px; color: #666; float: left; } .lesson-item .index{ margin-right: 5px; } .lesson-item .free{ font-size: 12px; color: #fff; letter-spacing: .19px; background: #ffc210; border-radius: 100px; padding: 1px 9px; margin-left: 10px; } .lesson-item .time{ font-size: 14px; color: #666; letter-spacing: .23px; opacity: 1; transition: all .15s ease-in-out; float: right; } .lesson-item .time img{ width: 18px; height: 18px; margin-left: 15px; vertical-align: text-bottom; } .lesson-item .try{ width: 86px; height: 28px; background: #ffc210; border-radius: 4px; font-size: 14px; color: #fff; position: absolute; right: 20px; top: 10px; opacity: 0; transition: all .2s ease-in-out; cursor: pointer; outline: none; border: none; } .lesson-item:hover{ background: #fcf7ef; box-shadow: 0 0 0 0 #f3f3f3; } .lesson-item:hover .name{ color: #333; } .lesson-item:hover .try{ opacity: 1; } .course-side{ width: 300px; height: auto; margin-left: 20px; float: right; } .teacher-info{ background: #fff; margin-bottom: 20px; box-shadow: 0 2px 4px 0 #f0f0f0; } .side-title{ font-weight: normal; font-size: 17px; color: #4a4a4a; padding: 18px 14px; border-bottom: 1px solid #333; border-bottom-color: rgba(51,51,51,.05); } .side-title span{ display: inline-block; border-left: 2px solid #ffc210; padding-left: 12px; } .teacher-content{ padding: 30px 20px; box-sizing: border-box; } .teacher-content .cont1{ margin-bottom: 12px; overflow: hidden; } .teacher-content .cont1 img{ width: 54px; height: 54px; margin-right: 12px; float: left; } .teacher-content .cont1 .name{ float: right; } .teacher-content .cont1 .teacher-name{ width: 188px; font-size: 16px; color: #4a4a4a; padding-bottom: 4px; } .teacher-content .cont1 .teacher-title{ width: 188px; font-size: 13px; color: #9b9b9b; white-space: nowrap; } .teacher-content .narrative{ font-size: 14px; color: #666; line-height: 24px; } </style>
2.index.js注册组件
import Detail from "@/components/Detail" { path:'/course/detail/:id', // 前端页面动态路由匹配 component:Detail } // :id ===> this.$route.params.id // course/detail/1
3.course.vue
实现:在课程列表页面点击不同的课程可以进入到不同的课程详情页面
<h3><router-link :to="'/course/detail/'+course.id+'/'">django基础知识</router-link> <span><img src="/static/img/avatar1.svg" alt="">5000人已加入学习</span></h3>
此时 点击可进入课程详情页面
2.视频播放组件
1.安装
npm install vue-video-player --save
2.main.js注册组件
// main.js require('video.js/dist/video-js.css'); require('vue-video-player/src/custom-theme.css'); import VideoPlayer from 'vue-video-player' Vue.use(VideoPlayer);
3.Detail.vue引入
HTML部分
<!-- html --> <div class="wrap-left"> <videoPlayer class="video-player vjs-custom-skin" ref="videoPlayer" :playsinline="true" :options="playerOptions" @play="onPlayerPlay($event)" @pause="onPlayerPause($event)"> </videoPlayer> </div>
JS部分
// js import {VideoPlayer} from 'vue-video-player' data(){ return{ ... playerOptions: { playbackRates: [0.7, 1.0, 1.5, 2.0], // 播放速度 autoplay: false, // 如果true,则自动播放 muted: false, // 默认情况下将会消除任何音频。 loop: false, // 循环播放 preload: 'auto', // 建议浏览器在<video>加载元素后是否应该开始下载视频数据。auto浏览器选择最佳行为,立即开始加载视频(如果浏览器支持) language: 'zh-CN', aspectRatio: '16:9', // 将播放器置于流畅模式,并在计算播放器的动态大小时使用该值。值应该代表一个比例 - 用冒号分隔的两个数字(例如"16:9"或"4:3") fluid: true, // 当true时,Video.js player将拥有流体大小。换句话说,它将按比例缩放以适应其容器。 sources: [{ // 播放资源和资源格式 type: "video/mp4", src: "" // 你的视频地址(必填) }], poster: "", // 视频封面图 width: document.documentElement.clientWidth, // 默认视频全屏时的最大宽度 notSupportedMessage: '此视频暂无法播放,请稍后再试', // 允许覆盖Video.js无法播放媒体源时显示的默认信息。 } } } method:{ ... // 视频播放时触发此函数 onPlayerPlay:{ ... } // 视频暂停时触发此函数 onPlayerPause:{ ... } } components:{ ... videoplayer // 挂载一下视频播放组件 }
4.在Xadmin上传视频
所以需要在course表中添加一个course_video字段
# 将上传的视频保存在本地的video文件夹中 course_video = models.FileField(upload_to='video',verbose_name='封面视频',blank=True,null=True,max_length=255)
执行数据库迁移指令
python3 manage.py makemigrations
python3 manage.py migrate
在xadmin上传视频,即可在前端页面看到自己上传的视频
3.课程详情页面后端接口实现
urlpatterns = [ ...... re_path(r'detail/(?P<pk>\d+)/', views.CourseDetailView.as_view(),), ]
views.py
class CourseDetailView(RetrieveAPIView): queryset = models.Course.objects.filter(is_deleted=False,is_show=True) serializer_class = CourseDetailModelSerializer
models.py
class Course(BaseModel): ...... level_choices = ( (0, '初级'), (1, '中级'), (2, '高级'), ) ...... ...... level = models.SmallIntegerField(choices=level_choices, default=1, verbose_name="难度等级") ...... def level_name(self): '''level字段默认显示的是数字,通过返回get_字段_display可以返回数字对应的名字''' return self.get_level_display()
serializers.py
class CourseDetailModelSerializer(serializers.ModelSerializer): # 序列化器嵌套 teacher = TeacherModelSerializer() # 将外键关联的属性指定为关联表的序列化器对象,就能拿到关联表序列化出来的所有数据,还需要在fields中指定一下,注意,名称必须和外键属性名称相同 class Meta: model = models.Course fields = ["id", "name", "course_img", "students", "lessons", "pub_lessons", "price", "teacher", "level_name", "course_video"]
后端接口测试
drf后端接口测试 /course/detail/1 可得到course=1所需要的所有数据
4.课程详情页面-前端
1.注意点
现在我们是需要所有章节和所有课时信息、老师信息和课程信息。
如果将所有的信息都定义到一个序列化器的字段中,数据量有些太大。
我们可以利用axios可以发送异步请求的这个特点,分成两次请求来获取数据
将章节信息和课时信息放在一个序列化器中
其它的放在另一个序列化器中
我们先去请求除了章节信息和课时信息的其他信息
现在后端数据已经准备好了,接下来就是前端发送axios请求获取数据了
2.Detail.vue
<!-- html --> <div class="wrap-right"> <h3 class="course-name">{{ course_data.name }}</h3> <p class="data">{{course_data.students}}人在学 课程总时长:{{course_data.lessons}} 难度:{{course_data.level_name}}</p> <div class="sale-time"> <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">¥{{course_data.price}}</span> </p> <div class="buy"> <div class="buy-btn"> <button class="buy-now">立即购买</button> <button class="free">免费试学</button> </div> <div class="add-cart"><img src="/static/img/cart-yellow.svg" alt="">加入购物车</div> </div> </div> <!-- 老师部分 --> <div class="course-side"> <div class="teacher-info"> <h4 class="side-title"><span>授课老师</span></h4> <div class="teacher-content"> <div class="cont1"> <img src=""> <div class="name"> <p class="teacher-name">{{course_data.teacher.name}}</p> <p class="teacher-title">{{course_data.teacher.title}}</p> </div> </div> <p class="narrative" >{{course_data.teacher.signature}}</p> </div> </div> </div> <!-- 视频播放 --> <div class="wrap-left"> <videoPlayer class="video-player vjs-custom-skin" ref="videoPlayer" :playsinline="true" :options="playerOptions" @play="onPlayerPlay($event)" @pause="onPlayerPause($event)"> </videoPlayer> </div>
// js <script> export default { name: "Detail", data(){ return { ...... course_id:0, course_data:{ teacher:{} }, playerOptions: { ...... sources: [{ // 播放资源和资源格式 type: "video/mp4", src: "" // 你的视频地址(必填) }], poster: "", // 视频封面图 ...... }, created(){ this.get_course_id(); this.get_course_data(); }, methods: { // 获取课程id,用处是请求不同的课程详情页面数据时带上不同的url参数来请求不同的课程详情数据 get_course_id(){ this.course_id = this.$route.params.id; // 可以判断course_id的合法性 todo }, get_course_data(){ this.$axios.get(`${this.$settings.Host}/course/detail/${this.course_id}/`) .then((res)=>{ this.course_data = res.data; // 获取课程详情页数据 this.playerOptions.sources[0].src = res.data.course_video; // 获取视频数据 this.playerOptions.poster = res.data.course_img // 获取视频封面数据 }) }, }, } </script>
5.CKEditor富文本编辑器
1.安装
pip install django-ckeditor
2.settings/dev.py INSTALLAPP配置
INSTALLED_APPS = [ ... 'ckeditor', # 富文本编辑器 'ckeditor_uploader', # 富文本编辑器上传图片模块 ... ]
3.setting/dev.py 配置
# 富文本编辑器ckeditor配置 CKEDITOR_CONFIGS = { 'default': { 'toolbar': 'full', # 工具条功能,full表示全部,Basic表示基本功能,功能少很多,还有个Custom自定义功能选项 'height': 300, # 编辑器高度 # 'width': 300, # 编辑器宽 }, } CKEDITOR_UPLOAD_PATH = '' # 上传图片保存路径,留空则调用django的文件上传功能 # 也可以自定义配置 CKEDITOR_CONFIGS = { 'default': { 'toolbar': 'Custom', 'toolbar_Custom': [ ['Bold', 'Italic', 'Underline','Image'], # 通过浏览器f12来查看每个功能的标签,就看到了类值cke_button_工具名称[注意使用驼峰式来写] ['NumberedList', 'BulletedList', '-', 'Outdent', 'Indent', '-', 'JustifyLeft', 'JustifyCenter', 'JustifyRight', 'JustifyBlock'], ['Link', 'Unlink'], ['RemoveFormat', 'Source'] ] } }
4.在总路由lyapi/urls.py添加路由
path(r'^ckeditor/', include('ckeditor_uploader.urls')),
5.将brief字段升级
# course/models.py from ckeditor_uploader.fields import RichTextUploadingField class Course(models.Model): # 课程概述变为富文本编辑器显示 brief = RichTextUploadingField(max_length=2048, verbose_name="课程概述", null=True, blank=True)
6.brief图片路径转化问题
在brief中,存放的都是一些各种标签组成的字符串,而用户在使用富文本编辑器时,有可能会使用上传图片的功能。而图片上传后,默认都存在了后端的media文件夹中。但是前端并不会将我们的后端地址识别出来。它会默认被存放到前端:www.lycity.com/media中,所以需要我们手动更改一下上传图片存储的路径。这样用户上传的图片才能显示出来。
models.py
# course/models.py class Course: ... def new_brief(self): data = self.brief server_addr = contains.SERVER_ADDR data = data.replace('/media',f'{server_addr}/media') return data
settings/constants.py
SERVER_ADDR = 'http://www.lyapi.com:8001'
serializers.py
class CourseDetailModelSerializer: ... teacher = TeacherModelSerializer() class Meta: model = model.Course fields = [.........,teacher,level_name,new_brief] # 将new_brief添加到字段中
7.表情和图片应用不同的CSS样式
class Course: ... def new_brief(self): data = self.brief server_addr = contains.SERVER_ADDR ''' 做了两件事: 1.将用户上传图片的相对路径改成了绝对路径 2.让图片和表情应用不同的CSS样式 ''' data = data.replace('src="/media',f'class="img_xx" src="{server_addr}/media') return data
6.课程章节和课时显示-后端接口
urls.py
re_path(r'chapter/', views.ChapterView.as_view(),),
views.py
from django_filters.rest_framework import DjangoFilterBackend class ChapterView(ListAPIView): queryset = models.CourseChapter.objects.filter(is_deleted=False,is_show=True) serializer_class = CourseChapterModelSerializer filter_backends = [DjangoFilterBackend,] filter_fields = ('course',)
serializers.py
class CourseLessonModelSerializer: class Meta: model = models.CourseLesson fields = ['name','section_link','duration','free_trail','lesson'] class CourseChapterModelSerialzer: '''在一的序列化器嵌套多的序列化器,切记要加参数many=True''' coursesection = CourseLessonModelSerializer(many=True) # 1201 class Meta: model = models.CourseChapter fields = ['chapter','name']
drf测试接口:course/chapter/?course=1
7.课程章节和课时显示-前端
<div class="tab-item" v-if="tabIndex==2"> <div class="tab-item-title"> <p class="chapter">课程章节</p> <p class="chapter-length">共{{chapter_data.length}}章 </p> </div> <div class="chapter-item" v-for="(chapter,chapterindex) in chapter_data"> <p class="chapter-title"><img src="/static/img/1.png" alt="">第{{chapter.chapter}}章·{{chapter.name}}</p> <ul class="lesson-list"> <li class="lesson-item" v-for="(lesson,lesson_index) in chapter.coursesections"> <p class="name"><span class="index">{{chapter.chapter}}-{{lesson.lesson}}</span> 课程介绍-{{lesson.name}}<span v-show="lesson.free_trail" class="free">免费</span></p> <p class="time">{{lesson.duration}} <img src="/static/img/chapter-player.svg"></p> <button class="try" v-if="lesson.free_trail">立即试学</button> <button class="try" v-else>立即buy</button> </li> </ul> </div> </div>
// js export default { name: "Detail", data(){ return { chapter_data:{}, } }, created(){ this.get_chapter_data(); }, methods: { get_chapter_data(){ this.$axios.get(`${this.$settings.Host}/course/chapter/`,{ params:{ course:this.course_id, } }).then((res)=>{ console.log(res.data); this.chapter_data = res.data }) }, }