课程管理(二)

2021-03-15  本文已影响0人  amanohina

课程内容管理

课程内容管理指的是前后台课程详情中课程目录的内容管理,内容中包含章节和课时部分(对应了课程视频)


大概长这样,这次我们只讲拖拽功能,其他的都是经典时尚重复工作,不再赘述

后台通过 课程管理->指定课程->内容管理操作
创建组件并配置路由,同时设置跳转功能

// 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组件,后续通过属性配置可以直接设置拖拽功能

<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
    }
  })
}

引入并使用

// 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)
    }
  }
}
...

将数据绑定到视图

// 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文档

// 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组件的属性可以定制拖拽功能

// 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的好处了

// 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>
...

阿里云视频点播

这是一个集音视频采集,编辑,上传,自动化转码处理,媒体资源管理,高效云剪辑处理,分发加速,视频播放于一体的一站式音视频点播解决方案
上传视频功能并没有后台上传,采用的方案是使用第三方服务“阿里云视频点播”,采用第三方视频服务是一种主流方案,让公司可以更专注核心业务而不是单独维护一套视频点播系统
官方功能概述:地址
操作一共分为两步:

操作指引

文档位置:

.
├── 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
<!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"(上传地址和凭证方式)对应两种上传方式,官方推荐的是第二种
修改了路由之后我们走一波功能测试
这里的文件无需进行风格处理

初始化阿里云上传

第一步下载完毕之后,下一步我们需要初始化上传实例,设置到video.vue中
userId为后端提供的

// 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>

点击获取文件

// 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>

从文档中找到将文件添加到上传列表的方式,进行响应的处理

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()
}

触发上传后,文件并没有真正开始上传,因为还需要发送上传凭证和地址,需要使用到后端提供的接口
实际上的执行流程就是:

封装上传凭证和地址接口

由于需要上传视频和上传封面,要封装俩接口

// 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
  )
  // 设置完毕,上传进度开始执行
},
...

视频转码处理

转码请求接口之前就已经封装好了,文档中显示有多个请求参数,其实主要就需要:

// 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静态文件服务器就可以进行本地浏览了
至此,全部项目就完成了
上一篇下一篇

猜你喜欢

热点阅读