课程内容管理
课程内容管理指的是前后台课程详情中课程目录的内容管理,内容中包含章节和课时部分(对应了课程视频)
后台通过 课程管理->指定课程->内容管理操作
创建组件并配置路由,同时设置跳转功能
// course/section.vue (新建)
<template>
<div class="course-section">课程内容</div>
</template>
<script>
export default {
name: 'CourseSection',
// 设置路由后,通过 props 接收动态路由参数
props: {
courseId: {
type: [String, Number],
required: true
}
}
}
</script>
<style lang="scss" scoped></style>
// router/index.js
...
{
path: '/course/:courseId/section',
name: 'course-section',
component: () => import(/* webpackChunkName: 'course-section' */ '@/views/course/section.vue'),
props: true
}
]
...
// course/components/list.vue
...
<el-button
@click="$router.push({
name: 'course-section',
params: {
courseId: scope.row.id
}
})"
>内容管理</el-button>
...
展示课程内容
设置基本布局结构,底部列表使用Element的Tree组件,后续通过属性配置可以直接设置拖拽功能
- 设置draggable实现列表拖拽
<template>
<div class="course-section">
<el-card>
<div slot="header">
课程名称
</div>
<el-tree
:data="data"
:props="defaultProps"
draggable
></el-tree>
</el-card>
</div>
</template>
<script>
export default {
name: 'CourseSection',
props: {
courseId: {
type: [String, Number],
required: true
}
},
data () {
return {
data: [{
label: '一级 1',
children: [{
label: '二级 1-1',
children: [{
label: '三级 1-1-1'
}]
}]
}, {
label: '一级 2',
children: [{
label: '二级 2-1',
children: [{
label: '三级 2-1-1'
}]
}, {
label: '二级 2-2',
children: [{
label: '三级 2-2-1'
}]
}]
}, {
label: '一级 3',
children: [{
label: '二级 3-1',
children: [{
label: '三级 3-1-1'
}]
}, {
label: '二级 3-2',
children: [{
label: '三级 3-2-1'
}]
}]
}],
defaultProps: {
children: 'children',
label: 'label'
}
}
}
}
</script>
<style lang="scss" scoped></style>
请求数据创建列表内容,接口为章节内容:getSessionAndLesson接口
// services/course-section.js (新建)
import request from '@/utils/request'
// 获取章节和课时
export const getSectionAndLesson = courseId => {
return request({
method: 'GET',
url: '/boss/course/section/getSectionAndLesson',
params: {
courseId
}
})
}
引入并使用
- 响应数据的data代表章节信息,内部的LessonDTOS代表章节内的课时数据
// section.vue
...
import { getSectionAndLesson } from '@/services/course-section.js'
...
created () {
this.loadSection()
},
methods: {
async loadSection () {
const { data } = await getSectionAndLesson(this.courseId)
if (data.code === '000000') {
console.log(data)
}
}
}
...
将数据绑定到视图
- 设置sections属性保存课程内容数据
- 修改el-tree组件中使用的数据
- 将请求数据绑定到sections
- 由于sections中的属性和tree需要的默认名称不同,需要修改属性名
- children为lessonDTOS代表章节下的课时
- 而label对于章节和课时不同,章节名为sectionName,课时名为theme
- 通过文档得知,label可以设置为函数,内部判断数据进行处理就可以了
// section.vue
...
<!-- 3. 绑定到模板 -->
<el-tree
:data="sections"
...
></el-tree>
...
<script>
...
data () {
return {
// 1. 声明数据
sections: [],
// 4. 根据响应数据调整属性
defaultProps: {
children: 'lessonDTOS',
label (data) {
return data.sectionName || data.theme
}
}
}
},
...
if (data.code === '000000') {
// 2. 绑定数据
this.sections = data.data
}
...
</script>
Tree组件内容定制
Tree 组件默认只有文本内容,而章节与课时除了文本之外还有具体的按钮结构,对应的功能还是各不相同的,这个时候就需要通过作用域插槽来进行内容定制,具体的方式参见以下Element文档
- 通过作用域插槽接收的node为当前节点的Node对象,data为当前节点的数据对象
// section.vue
...
<el-tree ... >
<!-- 设置插槽,并通过插槽接收组件暴露的数据 -->
<div class="inner" slot-scope="{ node, data }">
<!-- 设置内容 -->
<span>{{ node.label }}</span>
<!-- 设置后续按钮结构 -->
<!-- section 结构 -->
<span v-if="data.sectionName" class="actions">
<el-button>编辑</el-button>
<el-button>添加课时</el-button>
<el-button>状态</el-button>
</span>
<!-- lesson 结构 -->
<span v-else class="actions">
<el-button>编辑</el-button>
<el-button>上传视频</el-button>
<el-button>状态</el-button>
</span>
</div>
</el-tree>
...
调整样式
<style lang="scss" scoped>
.inner {
// 浏览器观察到父元素设置了 flex,所以当前元素 flex: 1 占满一行
flex: 1;
// 内部元素希望左右分开,所以给当前元素设置 flex
display: flex;
justify-content: space-between;
align-items: center;
// 其他样式美化
padding: 10px;
border-bottom: 1px solid #ebeef5;
}
// 当前行具有类名 .el-tree-node__content 设置了固定高度 26px, 这里要改为 auto 自适应
// 由于为 Tree 组件内的元素,需要设置样式穿透
::v-deep .el-tree-node__content {
height: auto;
}
</style>
设置完毕,内部的编辑与显示隐藏是相同功能,这些功能(新增,编辑等)就不再赘述
节点拖动处理
Tree的拖拽不设条件,但是业务中,肯定是有逻辑存在的,比如说将章节拖到课时一级就是不可能存在的逻辑,应该针对这些请求设置一些规则
通过Tree组件的属性可以定制拖拽功能
- allow-drop:通过回调返回的布尔值判断当前节点是否能被放置,三个参数
- draggingNode:正在拖拽的节点
- dropNode:放置的目标节点
- type:放置在目标节点的哪个位置
- 这个type有三种情况:prev(同级前),inner(内部),next(同级后)
// section.vue
...
<el-tree
:data="sections"
:props="defaultProps"
draggable
:allow-drop="handleAllowDrop"
>
...
handleAllowDrop (draggingNode, dropNode, type) {
// 1. 不能有放入内部的操作,但例如将章节1拖拽到章节2的课时1之前时,type 为 prev,需要进一步处理
// 2. 所有课时都具有 sectionId,通过下面的条件,限制章节不能移动到课时前后,也不能将章节的课时移动到其他章节
return type !== 'inner' && draggingNode.data.sectionId === dropNode.data.sectionId
}
拖拽更新数据处理
一般来说,后端会提供接口将当前的章节最新顺序上传,但是项目中并没有提供这样的接口,提供的单个课时位置更新的接口,所以我们需要进行遍历!依次更新处理(好处就在于我们可以借此来练习批量请求的处理操作)
首先封装接口
// services/course-section.js
...
// 新增或更新章节
export const saveOrUpdateSection = data => {
return request({
method: 'POST',
url: '/boss/course/section/saveOrUpdateSection',
data
})
}
// 新增或更新课时(因课时功能较少,此处未单独封装模块,可自行处理)
export const saveOrUpdateLesson = data => {
return request({
method: 'POST',
url: '/boss/course/lesson/saveOrUpdate',
data
})
}
Tree组件提供了node-drop方法,处理拖动后的结果
// section.vue
...
<el-tree
...
@node-drop="handleNodeDrop"
>
...
<script>
import { getSectionAndLesson, saveOrUpdateSection, saveOrUpdateLesson } from '@/services/course-section.js'
...
// 设置节点拖动后的数据更新
async handleNodeDrop (draggingNode, dropNode, tyoe, event) {
// 1. 无论是章节还是课时, dropNode 都有parent(draggingNode.parent 总为 null), 内部有childNodes
// - dropNode.parent.childNodes 可获取拖拽项所在列表的所有数据
// - 遍历操作
// 4. 由于是批量请求,可以使用 Promise.all() 便于进行统一操作
// - 将 map 返回的,由 Axios 调用返回的 Promise 对象组成的数组,传入到 Promise.all() 中
// - 设置 async await 并进行 try..catch 处理
try {
await Promise.all(dropNode.parent.childNodes.map((item, index) => {
// 2. 对章节与课时进行分别处理
// - 除了 draggingNode.data.sectionId 外,draggingNode.lessonDTOS 也可以判断
if (draggingNode.data.lessonDTOS) {
// 章节操作
return saveOrUpdateSection({
id: item.data.id,
// 按现在的索引顺序给当前级别列表设置排序序号
orderNum: index
})
} else {
// 课时操作(同上)
return saveOrUpdateLesson({
id: item.data.id,
// 按现在的索引顺序给当前级别列表设置排序序号
orderNum: index
})
}
}))
this.$message.success('数据更新成功')
} catch (err) {
this.$message.success('数据更新失败', err)
}
}
...
</script>
...
之后呢,在请求过程中添加一下loading效果,我们就可以来体会体会Promise.all的好处了
- 示例中,遍历+请求的结构,这个时候如果我们设置await就会变成了一个个发送了
- 通过Promise.all统一发送,统一进行结束处理,非常方便!
// course-section.vue
...
<el-tree
v-loading="isLoading"
...
>
...
<script>
data () {
return {
...
isLoading: false
}
},
...
async handleNodeDrop (draggingNode, dropNode, tyoe, event) {
this.isLoading = true
...
try {
...
} catch (err) {
...
}
this.isLoading = false
}
</script>
这个地方主要是业务练习,实际开发过程中让后端处理这方面的逻辑就会快很多了
上传视频处理
通过在线示例演示之后可以发现,设置上传课时视频的组件,配置路由和设置跳转
// course/video.vue (新建)
<template>
<div class="course-video">上传课时视频/div>
</template>
<script>
export default {
name: 'CourseVideo'
}
</script>
<style lang="scss" scoped></style>
// router/index.js
...
{
path: '/course/:courseId/video',
name: 'course-video',
component: () => import(/* webpackChunkName: 'course-video' */ '@/views/course/video.vue'),
props: true
}
]
...
设置跳转时,由于模板中不用加this,可以params中的courseId: this.courseId简写成了courseId
// course/section.vue
...
<el-button
type="success"
@click="$router.push({
name: 'course-video',
params: {
courseId
}
})"
>上传视频</el-button>
...
接受数据并且设置页面结构,顶部的课程相关信息展示自行完成(不再赘述)
注意:这里采用普通input标签操作,使用el-input的话DOM操作会很繁琐
// course/video.vue
<template>
<div class="course-video">
<el-card>
<div slot="header">
课程相关信息
</div>
<el-form>
<el-form-item label="视频上传">
<input type="file">
</el-form-item>
<el-form-item label="封面上传">
<input type="file">
</el-form-item>
<el-form-item>
<el-button type="primary">开始上传</el-button>
<el-button @click="$router.push({
name: 'course-section',
params: {
courseId
}
})">返回</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script>
export default {
name: 'CourseVideo',
props: {
courseId: {
type: [String, Number],
required: true
}
}
}
</script>
...
阿里云视频点播
这是一个集音视频采集,编辑,上传,自动化转码处理,媒体资源管理,高效云剪辑处理,分发加速,视频播放于一体的一站式音视频点播解决方案
上传视频功能并没有后台上传,采用的方案是使用第三方服务“阿里云视频点播”,采用第三方视频服务是一种主流方案,让公司可以更专注核心业务而不是单独维护一套视频点播系统
官方功能概述:地址
操作一共分为两步:
- 获取上传授权的相关操作已经由后台处理了
- 文件上传到需要查看阿里云官方文档操作
操作指引
文档位置:
-
阿里云视频点播页面->上传SDK->客户端上传->使用JavaScript上传SDK
操作步骤: - SDK下载,这里的SDK可以理解为包含一系列特定功能的文件
-
页面中找到Web端SDK下载部分,选择实例代码,下载
-
解压
-
找到aliyun-upload-sdk目录
-
添加到项目根目录中的public目录下,作为静态资源使用
最终的结构为:
.
├── aliyun-upload-sdk
│ ├── aliyun-upload-sdk-1.5.0.min.js
│ └── lib
│ ├── aliyun-oss-sdk-5.3.1.min.js
│ └── es6-promise.min.js
├── favicon.ico
└── index.html
SDK文件处理
由于这些JS文件,没有进行模块化处理,所以我们在项目中需要通过全局引入的方式使用
- 把文件引入到public/index.html,这些public的静态资源不会被webpack处理
- 引入时建议通过/设置路径,/在服务器中表示根目录
- 引入顺序参考文档
// /public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- 引入阿里云视频上传SDK -->
<script src="/aliyun-upload-sdk/lib/es6-promise.min.js"></script>
<script src="/aliyun-upload-sdk/lib/aliyun-oss-sdk-5.3.1.min.js"></script>
<script src="/aliyun-upload-sdk/aliyun-upload-sdk-1.5.0.min.js"></script>
<!-- built files will be auto injected -->
</body>
</html>
体验官方上传示例
从官方下载下来的示例代码中找到Vue示例,路径是"aliyun-upload-sdk-1.5.0demo\vue\vue-demo\src",内部的"STSToken.vue"(STS方式)与"UploadAuth.vue"(上传地址和凭证方式)对应两种上传方式,官方推荐的是第二种
修改了路由之后我们走一波功能测试
这里的文件无需进行风格处理
- 在JS中书写
/* eslint-disable */
可以局部禁用eslint检验- 书写位置之后就都可以不检验了
- 模板错误可以在图示位置添加空格就可以了
那么直接快进到测试完毕,观察一下流程 - 得出结论,上传凭证和地址信息(授权)是在onUploadstarted中设置的
-
阿里云提供的回调中,只有onUploadstarted书写为了不规范的camelCase,注意拼写
最后将路径更改为我们自己的组件,设置功能启动!
-
初始化阿里云上传
第一步下载完毕之后,下一步我们需要初始化上传实例,设置到video.vue中
userId为后端提供的
- AliYunUpload 可以添加window访问
- 这里提供测试ID为'1618139964448548'
- 处理其他的内容和格式
// course/video.vue
...
<script>
export default {
name: 'CourseVideo',
props: {
courseId: {
type: [String, Number],
required: true
}
},
data () {
return {
uploader: null
}
},
created () {
this.initUploader()
},
methods: {
// 初始化上传对象
initUploader () {
// 官方示例:声明 AliyunUpload.Vod 初始化回调。
this.uploader = new window.AliyunUpload.Vod({
// 阿里账号ID,必须有值
userId: '1618139964448548',
// 上传到视频点播的地域,默认值为'cn-shanghai',//eu-central-1,ap-southeast-1
region: '',
// 分片大小默认1 MB,不能小于100 KB
partSize: 1048576,
// 并行上传分片个数,默认5
parallel: 5,
// 网络原因失败时,重新上传次数,默认为3
retryCount: 3,
// 网络原因失败时,重新上传间隔时间,默认为2秒
retryDuration: 2,
// 开始上传
onUploadstarted: function (uploadInfo) {
console.log('onUploadstarted', uploadInfo)
},
// 文件上传成功
onUploadSucceed: function (uploadInfo) {
console.log('onUploadSucceed', uploadInfo)
},
// 文件上传失败
onUploadFailed: function (uploadInfo, code, message) {
console.log('onUploadFailed', uploadInfo, code, message)
},
// 文件上传进度,单位:字节
onUploadProgress: function (uploadInfo, totalSize, loadedPercent) {
console.log('onUploadProgress', uploadInfo, totalSize, loadedPercent)
},
// 上传凭证超时
onUploadTokenExpired: function (uploadInfo) {
console.log('onUploadTokenExpired', uploadInfo)
},
// 全部文件上传结束
onUploadEnd: function (uploadInfo) {
console.log('onUploadEnd', uploadInfo)
}
})
}
}
}
</script>
给上传按钮添加点击事件
// video.vue
...
<el-button type="primary"
@click="handleUpload"
>开始上传</el-button>
...
<script>
...
handleUpload () {
}
...
</script>
点击获取文件
- 文本域添加ref
- 通过$refs读取
// video.vue
...
<el-form-item label="视频上传">
<input ref="video-file" type="file">
</el-form-item>
<el-form-item label="封面上传">
<input ref="image-file" type="file">
</el-form-item>
...
<script>
...
handleUpload () {
// 获取上传的文件(视频、图片)
const videoFile = this.$refs['video-file'].files[0]
const imageFile = this.$refs['image-file'].files[0]
}
...
</script>
从文档中找到将文件添加到上传列表的方式,进行响应的处理
- uploader.addFile()将文件 添加到上传列表,多次调用会按照顺序发送文件(接口要求先发图)
- uploader.startUpload()开始上传
handleUpload () {
// 获取上传的文件(视频、图片)
const videoFile = this.$refs['video-file'].files[0]
const imageFile = this.$refs['image-file'].files[0]
// 将文件添加到上传列表
const uploader = this.uploader
// - 文档示例:uploader.addFile(event.target.files[i], null, null, null, paramData)
uploader.addFile(imageFile)
uploader.addFile(videoFile)
// 开始上传
// - 开始上传后,上面的文件回按添加的顺序依次上传
// - 这时会触发 onUploadStarted 事件
uploader.startUpload()
}
触发上传后,文件并没有真正开始上传,因为还需要发送上传凭证和地址,需要使用到后端提供的接口
实际上的执行流程就是:
- 调用uploader.startUpload()调用方法开始上传
- 调用uploader.setUploadAuthAddress()设置上传凭证和地址(在onUploadstarted钩子中)
- 凭证需要依赖后端获得
- 凭证没有问题,上传才开始执行
封装上传凭证和地址接口
由于需要上传视频和上传封面,要封装俩接口
// services/aliyun-upload.js
import request from '@/utils/request'
// 获取阿里云图片上传凭证(image 少了个 e)
export const aliyunImageUploadAddressAndAuth = () => {
return request({
method: 'GET',
url: '/boss/course/upload/aliyunImagUploadAddressAdnAuth.json'
})
}
// 获取阿里云视频上传凭证(有两个请求参数)
export const aliyunVideoUploadAddressAndAuth = params => {
return request({
method: 'GET',
url: '/boss/course/upload/aliyunVideoUploadAddressAdnAuth.json',
params
})
}
// 阿里云转码请求(transcode 是一个词,中间不用驼峰)
export const aliyunVideoTrancode = data => {
return request({
method: 'POST',
url: '/boss/course/upload/aliyunTransCode.json',
data
})
}
// 阿里云转码进度
export const getAliyunTranscodePercent = lessonId => {
return request({
method: 'GET',
url: '/boss/course/upload/aliyunTransCodePercent.json',
params: {
lessonId
}
})
}
引入到页面中
// course/video.js
...
import {
aliyunImagUploadAddressAndAuth,
aliyunVideoUploadAddressAndAuth,
aliyunVideoTranscode,
getAliyunTranscodePercent
} from '@/services/aliyun-upload'
...
上传凭证处理
由于存在图片和视频两种上传类型,所以要先在onUploadstarted中检测
操作步骤:
- 调用接口获取凭证
- 设置凭证
- 成功上传
// video.vue
...
data () {
return {
...
imageURL: ''
}
},
...
// 开始上传(uploader.startUpload() 触发后执行该回调)
// - 将回调更改为箭头函数,以便在内部通过 this 操作 Vue 实例
onUploadstarted: async uploadInfo => {
// 一、获取凭证
// console.log(uploadInfo)
// 1. 声明变量存储得到上传凭证
let uploadAddressAndAuth = null
// 2. 根据 isImage 检测上传文件类型
if (uploadInfo.isImage) {
const { data } = await getAliyunImagUploadAddressAndAuth()
if (data.code === '000000') {
// 3. data.data 即为凭证信息组成的对象
uploadAddressAndAuth = data.data
// 5. 保存图片地址,给视频接口使用
this.imageURL = uploadAddressAndAuth.imageURL
}
} else {
// 4. 观察 uploadInfo 数据,根据请求参数名设置参数
// - 由于视频接口要求传入封面图片地址 imageUrl,所以必须先发图再发视频(后端
// - 先将图片数据存储给 this,便于视频接口使用
const { data } = await getAliyunVideoUploadAddressAndAuth({
fileName: uploadInfo.file.name,
imageUrl: this.imageURL
})
if (data.code === '000000') {
// 6. 存储凭证
// - 图片与视频上传的区别在于图片存在 imageId,视频为 videoId,其他相同
uploadAddressAndAuth = data.data
}
}
// 二、设置凭证
this.uploader.setUploadAuthAndAddress(
uploadInfo,
uploadAddressAndAuth.uploadAuth,
uploadAddressAndAuth.uploadAddress,
uploadAddressAndAuth.imageId || uploadAddressAndAuth.videoId
)
// 设置完毕,上传进度开始执行
},
...
视频转码处理
转码请求接口之前就已经封装好了,文档中显示有多个请求参数,其实主要就需要:
- lessonId:课时Id
- 需要在上传视频跳转时添加相应的参数
- coverImageUrl:封面图片地址
- fileId:视频Id
- fileName:视频名称
更新跳转时的参数设置:
// section.vue
...
<el-button
type="success"
@click="$router.push({
name: 'course-video',
params: {
courseId
},
query: {
lessonId: data.id
}
})"
>上传视频</el-button>
当所有的文件都上传了之后才可以进行转码,故而应该在onUploadEnd回调中操作
// video.vue
...
data () {
return {
...
videoId: null
}
},
...
onUploadstarted: async uploadInfo => {
...
if (uploadInfo.isImage) {
...
} else {
...
if (data.code === '000000') {
...
this.videoId = data.data.videoId
}
}
...
},
...
// 全部文件上传结束
onUploadEnd: async uploadInfo => {
// 调用接口
const { data } = await aliyunVideoTranscode({
lessonId: this.$route.query.lessonId,
coverImageUrl: this.imageURL,
fileName: this.$refs['video-file'].files[0].name,
fileId: this.videoId
})
console.log(data)
}
转码请求发送之后,还需要轮询一下转码的进度
// video.vue
...
onUploadEnd: async uploadInfo => {
const { data } = await aliyunVideoTranscode({
...
})
if (data.code === '000000') {
// 转码开始后,需要轮询转码进度
const timer = setInterval(async () => {
const { data } = getAliyunTranscodePercent(this.$route.query.lessonId)
if (data === 100) {
// 当上传进度为 100,停止定时器,并进行提示
clearInterval(timer)
this.$message.success('转码成功')
}
}, 1000)
}
}
...
转码成功之后,前台页面只需要查看视频是否成功上传即可
将转码进度渲染到视图方便查看
// video.vue
...
data () {
return {
...
uploadPercent: 0,
isUploadSuccess: false,
isTranscodeSuccess: false
}
},
...
<el-form-item>
<p v-if="uploadPercent !== 0">视频上传中:{{ uploadPercent }}%</p>
<p v-if="isUploadSuccess">视频转码中:{{ isTranscodeSuccess ? '完成' : '正在转码,请稍后...' }} </p>
</el-form-item>
...
// 文件上传进度,单位:字节
// - 修改为箭头函数,内部 this 才能访问 Vue 实例
onUploadProgress: (uploadInfo, totalSize, loadedPercent) => {
console.log('onUploadProgress', uploadInfo, totalSize, loadedPercent)
// 只对视频上传进度进行监测即可
if (!uploadInfo.isImage) {
this.uploadPercent = Math.floor(loadedPercent * 100)
}
},
...
// 全部文件上传结束
onUploadEnd: async uploadInfo => {
this.isUploadSuccess = true
...
if (data === 100) {
this.isTranscodeSuccess = true
...
}
...
},
...
handleUpload () {
// 点击上传时重置状态信息
this.isTranscodeSuccess = false
this.isUploadSuccess = false
this.uploadPercent = 0
...
}
...
大功告成啦!
最后一步,发布部署
项目打包
项目打包了之后,打包后的文件生成在dist目录中
npm run build
得到了以下提示,说明打包成功,可以看到打包的详细信息
打包之后,通过serve静态文件服务器就可以进行本地浏览了
至此,全部项目就完成了