学习功能,课程详情与视频

学习功能组件准备

整体分为上中下三部分,顶部为标题,中间为学习课程列表,底部为导航
那么我们就首先引入LayoutFooter组件

// learn/index.vue
<template>
  <div class="learn">
    <!-- 顶部功能 -->
    <!-- 底部导航 -->
    <layout-footer></layout-footer>
  </div>
</template>

<script>
import LayoutFooter from '@/components/LayoutFooter'
export default {
  name: 'Learn',
  components: {
    LayoutFooter
  }
}
</script>

头部功能

设置NarBar进行标题显示就可以了

...
    <!-- 顶部功能 -->
    <van-nav-bar title="已购课程"></van-nav-bar>
...

课程列表公共组件

顶部列表与Course中使用的CourseContentList组件列表是非常类似的,我们可以把它封装成公共组件来减少工作量
Course显示的是所有的课程,而学习展示的则是用于已经购买的课程,数据不同,但是结构相同
这里将CourseContentList.vue作为公共组件移动到src/components 中
那么要注意:CourseContent中对于CourseContentList的引入路径要随之修改

// course/components/CourseContent.vue
...
import CourseContentList from '@/components/CourseContentList'
...

引入组件:

...
    <!-- 课程列表 -->
    <course-content-list></course-content-list>
...

<script>
...
import CourseContentList from '@/components/CourseContentList'
export default {
  name: 'Learn',
  components: {
...
    CourseContentList
  }
}
</script>

// 同理,要保持顶部和底部的距离,防止覆盖一部分内容
<style lang="scss" scoped>
.course-content-list {
  top: 50px;
  bottom: 50px;
}
</style>

接口抽离

课程列表组件初始设置时使用的是所有课程接口,如果我们改为其他的数据(已购课程),那么应当由父组件进行接口设置,再将其传入子组件,由子组件在适当的时机进行调用
修改我们的CourseContentList.vue
(+、-表示当前行代码我们应该去除还是新加)

<script>
// CourseContentList.vue
...
-import { getQueryCourses } from '@/services/course'
...
+props: {
+  // 用于请求数据的函数
+  fetchData: {
+    type: Function,
+    required: true
+  }
+},
...
async onRefresh () {
    ...
-  // 重新请求数据
-  const { data } = await getQueryCourses({
+  const { data } = await this.fetchData({
  ...
},
async onLoad () {
+  const { data } = await getQueryCourses({
-  const { data } = await this.fetchData({
...
}

修改CourseContent.vue

// CourseContent.vue
...
<course-content-list
+  :fetchData="fetchData"
></course-content-list>
...
-import { getAllAds } from '@/services/course'
+import { getAllAds, getQueryCourses } from '@/services/course'
...
methods: {
  // 传入请求
+  fetchData (options) {
+    return getQueryCourses(options)
+  },
...

学习组件处理

封装接口

这里我们使用已经获取已购课程接口:接口

// 获取已购课程信息
export const getPurchaseCourse = () => {
  return request({
    method: 'GET',
    url: '/front/course/getPurchaseCourse'
  })
}

引入到页面中,并给子组件发送请求方法名


数据绑定改进

由于所有课程接口和已购课程接口响应的数据格式不同,在进行数据绑定时需要进行检测

  • 响应数据的格式
    • 所有课程为:data.data.records
    • 已购课程为:data.content
  • 数据对应的键不同
    • 课程名称不同
    • 图片不同
    • 已购课程没有价格相关数据
      那么我们根据以上的条件可以进行代码的改进,比如加上||判断,加上v-if控制显示
        <van-cell
        v-for="item in list"
        :key="item.id">
        <!-- 课程左侧图片 -->
        <div>
          <!-- 所有课程与已购课程图片数据属性名不同,检测使用 -->
            <img :src="item.courseImgUrl || item.image">
        </div>
        <!-- 课程右侧信息 -->
        <div class="course-info">
          <!-- 名称检测 -->
            <h3 v-text="item.courseName || item.name"></h3>
            <p v-html="item.previewFirstField" class="course-preview"></p>
            <!-- 如果为已购,无需再显示价格区域 -->
            <p class="price-container" v-if="item.price">
                <span class="course-discount">¥{{ item.discounts }}</span>
                <s class="course-price">¥{{ item.price }}</s>
            </p>
        </div>
        </van-cell>

响应数据格式检测:

// CourseContentList.js 响应数据格式检测
...
  async onRefresh () {
    ...
    // 如果存在数据,清空并课程数据,否则结束
    if (data.data && data.data.records && data.data.records.length !== 0) {
      this.list = data.data.records
+    } else if (data.content && data.content.length !== 0) {
+      this.list = data.content
    }
    ...
  },
  async onLoad () {
        ...
    // 检测,如果没有数据了,结束,如果有,保存
    if (data.data && data.data.records && data.data.records.length !== 0) {
      this.list.push(...data.data.records)
+    } else if (data.content && data.content.length !== 0) {
+      this.list.push(...data.content)
    }
        ...
    // 数据全部加载完成
+    if (data.data && data.data.records && data.data.records.length < 10) {
      this.finished = true
+    } else if (data.content && data.content.length < 10) {
+      this.finished = true
    }
  }
}
...

课程详情

组件准备
创建src/views/course-info/index.vue,在组件中要通过props接收路径传参

<template>
  <div class="course-info">课程内容的id:{{ courseId }}</div>
</template>

<script>
export default {
  name: 'CourseInfo',
  props: {
    courseId: {
      type: [String, Number],
      required: true
    }
  }
}
</script>

<style lang="scss" scoped>

</style>

设置路由规则

// router/index.js
...
  {
    path: '/course-info/:courseId/',
    name: 'course-info',
    component: () => import(/* webpackChunkName: 'course-info' */'@/views/course/info'),
    props: true
  },
...

设置跳转

// course/components/CourseContentList.vue
...
<van-cell
  v-for="item in list"
  ...
  @click="$router.push({
    name: 'course-info',
    params: {
      courseId: item.id
    }
  })"
>
...

接口封装

使用接口:获取课程详情接口

// 获取课程详情信息
export const getCourseById = params => {
  return request({
    method: 'GET',
    url: '/front/course/getCourseById',
    params
  })
}

引入到页面,并且发送请求验证没有问题

// course-info/index.vue
...
<script>
import { getCourseById } from '@/services/course'

export default {
  ...
  data () {
    return {
      // 课程信息
      course: {}
    }
  },
  created () {
    this.loadCourse()
  },
  methods: {
    async loadCourse () {
      const { data } = await getCourseById({
        courseId: this.courseId
      })
      this.course = data.content
      console.log(data)
    }
  }
}
</script>

<style lang="scss" scoped></style>

主体内容区域处理

整体采用Vant的cell单元格处理

// course-info/index.vue
<template>
  <div class="course-info">
    <van-cell-group>
      <!-- 课程图片 -->
      <van-cell class="course-img"></van-cell>
      <!-- 课程描述 -->
      <van-cell class="course-desctription"></van-cell>
        <!-- 课程详细内容 -->
      <van-cell class="course-detail"></van-cell>
    </van-cell-group>
  </div>
</template>

顶部图片和课程信息,外加修改具体的样式

// course-info/index.vue
...
<!-- 课程图片 -->
<van-cell class="course-img">
  <img :src="course.courseImgUrl" alt="">
</van-cell>
<!-- 课程描述 -->
<van-cell class="course-desctription">
  <!-- 课程名称 -->
  <h2 v-text="course.courseName"></h2>
  <!-- 课程概述 -->
  <p v-text="course.previewFirstField"></p>
  <!-- 课程销售信息 -->
  <div class="course-saleInfo">
    <p class="course-price">
      <span class="discounts">¥{{ course.discounts }} </span>
      <span>¥{{ course.price }}</span>
    </p>
    <span class="tag">{{ course.sales }}人已购</span>
    <span class="tag">每周三、五更新</span>
  </div>
</van-cell>
...
<style lang="scss" scoped>
.van-cell {
  padding: 0;
}
.course-img {
  height: 280px;
}

.course-desctription {
  padding: 10px 20px;
  height: 150px;
}

.course-desctription h2 {
  padding: 0;
}

.course-saleInfo {
  display: flex;
}

.course-price {
  flex: 1;
  margin: 0;
}
.course-price .discounts {
  color: #ff7452;
  font-size: 24px;
  font-weight: 700;
}

.course-saleInfo .tag{
  line-height: 15px;
  background: #f8f9fa;
  border-radius: 2px;
  padding: 7px 8px;
  font-size: 12px;
  font-weight: 700;
  color: #666;
  margin-left: 10px;
}
</style>

主体选项卡

选项卡使用vant的Tab标签页组件
设置到页面中

<van-tabs v-model="active">
  <van-tab title="标签 1">内容 1</van-tab>
  <van-tab title="标签 2">内容 2</van-tab>
  <van-tab title="标签 3">内容 3</van-tab>
  <van-tab title="标签 4">内容 4</van-tab>
</van-tabs>

课程详情

设置详情部分数据

<van-tab title="详情">
       <!-- 课程详情信息在后台是通过富文本编辑器设置的 -->
      <!-- 内容为html文本 -->
  <div v-html="course.courseDescription"></div>
</van-tab>

当详情内容过于长的时候,将选项卡部分固定,可以使用Tab组件的粘性定位功能(scrollspy表示滚动导航,美化效果拉满)

// course-info/index.vue
<van-tabs sticky scrollspy>...</van-tabs>

章节列表处理

课程内容要显示课程的章节和课时信息

封装接口

接口为获取课程章节:地址
封装到course.js中

// course.js
...
// 获取课程章节
export const getSectionAndLesson = params => {
  return request({
    method: 'GET',
    url: '/front/course/session/getSectionAndLesson',
    params
  })
}

封装章节的组件

Vant没有提供与需求相似的用于显示章节和课时的组件,我们可以自行封装一手
准备组件文件,CourseSectionAndLesson用于单个章节与内部课时展示

<template>
  <div class="course-section-and-lesson">章节列表</div>
</template>

<script>
export default {
  name: 'CourseSectionAndLesson',
  props: {
    sectionData: {
      type: Object,
      required: true
    }
  }
}
</script>

<style lang="scss" scoped>

</style>

引入该组件

// course-info/index.vue
...
<van-tab title="内容">
  <course-section-and-lesson></course-section-and-lesson>
</van-tab>
...
<script>
import CourseSectionAndLesson from './components/CourseSectionAndLesson'
import { getCourseById, getSectionAndLesson } from '@/services/course'
...
components: {
  CourseSectionAndLesson
},
...
data () {
  return {
        ...
    // 章节信息
    sections: {}
  }
},
created () {
    ...
  this.loadSection()
},
...
methods: {
  async loadSection () {
    // 请求数据
    const { data } = await getSectionAndLesson({
      courseId: this.courseId
    })
    this.sections = data.content.courseSectionList
    console.log(data)
  },
...
</script>

遍历sections,同时从父组件中传递数据给章节组件

<van-tab title="内容">
  <course-section-and-lesson
    v-for="item in sections"
    :key="item.id"
    :section-data="item">
  </course-section-and-lesson>
</van-tab>

章节组件布局处理

<template>
  <div class="section-and-lesson">
    <!-- 章节 -->
    <h2 class="section" v-text="sectionData.sectionName"></h2>
    <!-- 课时 -->
    <p
      v-for="item in sectionData.courseLessons"
      :key="item.id"
      class="lesson"
    >
      <!-- 课时标题 -->
      <span v-text="item.theme"></span>
      <!-- 课时图标,使用 Vant 的 icon 图标组件 -->
      <van-icon v-if="item.canPlay" name="play-circle" size="20" />
      <van-icon v-else name="lock" size="20" />
    </p>
  </div>
</template>

...

<style lang="scss" scoped>
.section-and-lesson {
  padding: 0 20px;
}
// 让课时标题与图标两端显示
.lesson {
  display: flex;
  justify-content: space-between;
}
</style>

底部支付功能

布局处理

整体采用vant的Tabbar组件,也可自行设置元素进行固定定位处理

// course-info/index.vue
<template>
  <div class="course-info">
    <!-- 如果已购,去除底部支付区域并设置主体内容区域占满屏幕 -->
    <van-cell-group :style="styleOptions">
      ...
    </van-cell-group>
    <!-- 底部支付功能 -->
    <van-tabbar v-if="!course.isBuy">
      <div class="price">
        <span v-text="course.discountsTag"></span>
        <span class="discounts">¥{{ course.discounts }}</span>
        <span>¥{{ course.price }}</span>
      </div>
      <van-button
        type="primary"
      >立即购买</van-button>
    </van-tabbar>
  </div>
</template>
...
data () {
  return {
    ...
    // 样式信息
    styleOptions: {}
  }
},
...
async loadCourse () {
    ...
  if (data.content.isBuy) {
    this.styleOptions.bottom = 0
  }
}
...
<style lang="scss" scoped>
...
// 修改 discounts 选择器范围,让顶部与底部均可使用
.discounts {
  color: #ff7452;
  font-size: 24px;
  font-weight: 700;
}
...

// 添加底部导航后设置
.van-cell-group {
  position: fixed;
  // 预留底部支付区域高度
  width: 100%;
  top: 0;
  bottom: 50px;
  overflow-y: auto;
}

// 调整内部文字位置
.van-tabbar {
  line-height: 50px;
  // 设置 padding 后元素超出窗口
  padding: 0 20px;
  // 设置 box-sizing
  box-sizing: border-box;
  display: flex;
  // 内部元素左右显示
  justify-content: space-between;
  // 内容居中
  align-items: center;
}

span {
  font-size: 14px;
}
// 尺寸调整
.van-button {
  width: 50%;
  height: 80%;
}
</style>

如果已经购买了该课程,就无需显示这个功能了

  • 显示隐藏控制
  • 主体内容区域位置处理
// course-info/index.vue
<template>
  <div class="course-info">
    <!-- 如果已购,去除底部支付区域并设置主体内容区域占满屏幕 -->
    <van-cell-group :style="styleOptions">
      ...
    </van-cell-group>
    <!-- 底部支付功能 -->
    <van-tabbar v-if="!course.isBuy">
      ...
    </van-tabbar>
  </div>
</template>
...
data () {
  return {
    ...
    // 样式信息
    styleOptions: {}
  }
},
...
async loadCourse () {
    ...
  if (data.content.isBuy) {
    this.styleOptions.bottom = 0
  }
}
...

视频播放组件

组件准备

当点击某个可播放的课时时,需要进行视频播放,设置视频组件用于播放视频

  • 设置导航用于返回上一页
// course-info/video.vue
<template>
  <div class="course-video">
    <!-- 导航 -->
    <van-nav-bar
      title="视频"
      left-text="返回"
      @click-left="$router.go(-1)"
    />
  </div>
</template>

<script>
export default {
  name: 'CourseVideo'
}
</script>

<style lang="scss" scoped></style>

设置路由:

// router/index.js
...
// 视频页
{
  path: '/lesson-video/:lessonId/',
  name: 'lesson-video',
  component: () => import(/* webpackChunkName: 'lesson-video' */'@/views/course-info/video'),
  props: true
},
...

点击课时时,如果可以播放,那么就跳转该视频页,并传递ID课时

// CourseSection.vue
...
<p
  ...
  @click="handleClick(item)"
>
...
<script>
  methods: {
    handleClick (lessonInfo) {
      if (lessonInfo.canPlay) {
        this.$router.push({
          name: 'lesson-video',
          params: {
            lessonId: lessonInfo.id
          }
        })
      }
    }
  }
...

video.vue接收lessonId用于请求视频数据

// course-info/video.vue
...
    props: {
    lessonId: {
      type: [String, Number],
      required: true
    }
  },
...

接口封装

需要使用以下的接口

  • 根据fileId获取阿里云对应的视频播放信息:接口
// course.js
...
// 根据fileId获取阿里云对应的视频播放信息
export const getVideoInfo = params => {
  return request({
    method: 'GET',
    url: '/front/course/media/videoPlayInfo',
    params
  })
}

引入,请求

// video.vue
...
import { getVideoInfo } from '@/services/course'
...
  created () {
    this.loadVideo()
  },
  methods: {
    async loadVideo () {
      const { data } = await getVideoInfo({
        lessonId: this.lessonId
      })
      console.log(data)
    }
  }

阿里云视频点播

播放需要使用阿里云的视频播放功能

在public/index.html中引入文件:

  • css文件:
<link rel="stylesheet" href="https://g.alicdn.com/de/prismplayer/2.9.3/skins/default/aliplayer-min.css" />
  • js文件:
<script src="https://g.alicdn.com/de/prismplayer/2.9.3/aliplayer-h5-min.js"></script>

可使用在线配置获取创建实例代码

  • 选择playauth播放方式即可
    具体的代码
<template>
  <div class="course-video">
    ...
    <!-- 设置视频容器 -->
    <div id="video-container"></div>
  </div>
</template>

<script>
// 引入接口,请求视频播放需要的 vid 与 playAuth
import { getVideoInfo } from '@/services/course'
export default {
  ...
  created () {
    this.loadVideo()
  },
  methods: {
    async loadVideo () {
      const { data } = await getVideoInfo({
        lessonId: this.lessonId
      })
      // 初始化播放器
      const player = new window.Aliplayer({
        // 视频容器 ID
        id: 'video-container',
        // 视频 ID
        vid: data.content.fileId,
        // 播放凭证
        playauth: data.content.playAuth,
        qualitySort: 'asc',
        format: 'mp4',
        mediaType: 'video',
        width: '100%',
        // 高度调整
        height: '100%',
        autoplay: true,
        isLive: false,
        rePlay: false,
        playsinline: true,
        preload: true,
        controlBarVisibility: 'hover',
        useH5Prism: true
      }, function (player) {
        console.log('The player is created')
      })
      console.log(player)
    }
  }
}
</script>

<style lang="scss" scoped>
.course-video {
  width: 100%;
  height: 210px;
}
#video-container {
  width: 100%;
  height: auto;
}
</style>

小知识:手机和电脑链接到同一个无线网络下,可以使用手机访问IP地址:8080端口访问项目,可以调试项目
完成

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容