index.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. <template>
  2. <a-layout>
  3. <a-layout-sider v-model:collapsed="collapsed" :trigger="null" collapsible collapsedWidth="0">
  4. <div class="classTitle">
  5. <div>{{ classDetail.courseName }}</div>
  6. </div>
  7. <a-menu
  8. v-model:openKeys="openKeys"
  9. v-model:selectedKeys="selectedKeys"
  10. theme="dark"
  11. mode="inline"
  12. @click="menuClick"
  13. >
  14. <a-sub-menu :key="String(idx + 1)" v-for="(item, idx) in classTimeList">
  15. <template #title>{{ item.name }}</template>
  16. <a-menu-item :key="itemc.id" v-for="(itemc, idxc) in item.classHours">
  17. <span class="mr-2">
  18. <CheckSquareOutlined v-if="selectedKeys.includes(itemc.id)" />
  19. <BorderOutlined v-else />
  20. </span>
  21. <span>{{ itemc.name }}</span>
  22. </a-menu-item>
  23. </a-sub-menu>
  24. </a-menu>
  25. </a-layout-sider>
  26. <a-layout>
  27. <a-layout-header style="padding: 0 20px">
  28. <div class="flex-between">
  29. <div>
  30. <menu-unfold-outlined
  31. v-if="collapsed"
  32. class="trigger"
  33. @click="() => (collapsed = !collapsed)"
  34. style="font-size: 30px; color: #fff"
  35. />
  36. <menu-fold-outlined
  37. v-else
  38. class="trigger"
  39. @click="() => (collapsed = !collapsed)"
  40. style="font-size: 30px; color: #fff"
  41. />
  42. </div>
  43. <div @click="isLike">
  44. <a-tooltip title="收藏" style="cursor: pointer" :getPopupContainer="(trigger) => trigger.parentElement">
  45. <heart-outlined :style="{ 'font-size': '20px', color: classDetail.isCollect ? '#ff4d4f' : '#fff' }" />
  46. </a-tooltip>
  47. </div>
  48. </div>
  49. </a-layout-header>
  50. <a-layout-content style="overflow-y: auto">
  51. <a-card :bordered="false">
  52. <video
  53. style="height: 900px; width: 100%; background-color: #000"
  54. ref="videoRef"
  55. controls
  56. Controlslist="nodownload noplaybackrate noremoteplayback disablePictureInPicture"
  57. @timeupdate="timeUpdate"
  58. :currentTime="currentTimenew"
  59. :poster="videoPoster"
  60. >
  61. <source :src="videoUrl" type="video/mp4" />
  62. <source :src="videoUrl" type="video/ogg" />
  63. 您的浏览器不支持 HTML5 video 标签。
  64. </video>
  65. <rightMenu :idsObj="idsObj" :dataList="classTimeData" ref="rightNenuRef" @videoSpeed="videoSpeed"></rightMenu>
  66. </a-card>
  67. <div style="display: flex; justify-content: center">
  68. <a-card :bordered="false" class="mt-2" style="width: 1200px">
  69. <a-tabs v-model:activeKey="tabsActiveKey">
  70. <a-tab-pane key="1" tab="讲义" style="height: 900px">
  71. <handouts :itemObj="itemObj" :hourId="classHourData.id" v-if="classHourData"></handouts>
  72. </a-tab-pane>
  73. <a-tab-pane key="2" tab="字幕">
  74. <subtitleBox :url="danmuObj.url" v-if="tabsActiveKey == 2" @videoSpeed="videoSpeed"></subtitleBox>
  75. </a-tab-pane>
  76. <a-tab-pane key="3" tab="笔记">
  77. <div style="min-height: 600px">
  78. <note :idsObj="idsObj" ref="noteRef" v-if="tabsActiveKey == 3" @edit="noteEdit"></note>
  79. </div>
  80. </a-tab-pane>
  81. <a-tab-pane key="4" tab="问答">
  82. <div style="min-height: 600px">
  83. <askDiv :idsObj="idsObj" ref="askDivRef" v-if="tabsActiveKey == 4" @edit="askEdit"></askDiv>
  84. </div>
  85. </a-tab-pane>
  86. </a-tabs>
  87. </a-card>
  88. </div>
  89. <forumBtn :forumData="forumData" resourceType="0"></forumBtn>
  90. </a-layout-content>
  91. </a-layout>
  92. </a-layout>
  93. </template>
  94. <script setup name="classCentre">
  95. import classCentre from '@/api/student/classCentre'
  96. import rightMenu from './rightMenu.vue'
  97. import { useRoute, useRouter } from 'vue-router'
  98. import sysConfig from '@/config/index'
  99. import note from './note.vue'
  100. import askDiv from './ask.vue'
  101. import handouts from './handouts.vue'
  102. import subtitleBox from './subtitle.vue'
  103. const route = useRoute()
  104. const router = useRouter()
  105. const classDetail = ref({})
  106. const classTimeList = ref([])
  107. const classTimeData = ref([])
  108. const openKeys = ref(['1'])
  109. const selectedKeys = ref([])
  110. const collapsed = ref(false)
  111. const videoRef = ref()
  112. const currentTimenew = ref(0)
  113. const idsObj = computed(() => {
  114. let item = findNodeByKey(classTimeList.value, selectedKeys.value[0], classTimeList.value[0])
  115. return {
  116. courseId: route.query.id,
  117. chapterId: selectedKeys.value[0],
  118. hourId: classHourData.value?.id,
  119. ...item
  120. }
  121. })
  122. function findNodeByKey(list, id, parent = null) {
  123. for (const item of list) {
  124. const itemWithParent = {
  125. ...item,
  126. parent: parent
  127. }
  128. if (itemWithParent.id === id) {
  129. return itemWithParent
  130. }
  131. if (item.classHours && Array.isArray(item.classHours)) {
  132. const found = findNodeByKey(item.classHours, id, itemWithParent)
  133. if (found) return found
  134. }
  135. }
  136. return null
  137. }
  138. const videoPoster = computed(() => {
  139. return classTimeData.value.filter((r) => r.funcType == 0)[0]?.url
  140. })
  141. const getClassData = () => {
  142. classCentre.addViewCount({ courseId: route.query.id })
  143. classCentre.courseDetail({ courseId: route.query.id }).then((data) => {
  144. classDetail.value = data
  145. classCentre.coursechapterList({ courseId: data.courseId }).then((data) => {
  146. classTimeList.value = data
  147. selectedKeys.value = [data[0]?.classHours[0].id]
  148. if (selectedKeys.value[0]) {
  149. menuClick()
  150. }
  151. })
  152. })
  153. }
  154. const classHourData = ref()
  155. const menuClick = (e) => {
  156. rightNenuRef.value.onClose()
  157. classCentre.courseTimeDetail({ id: e?.key ? e.key : selectedKeys.value[0] }).then((data) => {
  158. classHourData.value = data
  159. classTimeData.value = data.courseRelates.map((r) => {
  160. return {
  161. ...r,
  162. url: sysConfig.FILE_URL + r.url
  163. }
  164. })
  165. videoRef.value.src = classTimeData.value.filter((r) => r.funcType == 1)[0]?.url
  166. footprintClassAdd()
  167. getVideoTime()
  168. })
  169. }
  170. const tabsActiveKey = ref('1')
  171. const noteRef = ref()
  172. const askDivRef = ref()
  173. const itemObj = computed(() => {
  174. let item = classTimeData.value.length > 0 ? classTimeData.value.filter((r) => r.funcType == 2)[0] : { url: '' }
  175. return item
  176. })
  177. const danmuObj = computed(() => {
  178. let item = classTimeData.value.length > 0 ? classTimeData.value.filter((r) => r.funcType == 3)[0] : { url: '' }
  179. return item
  180. })
  181. const videoUrl = ref('')
  182. const newsschedule = ref(0)
  183. const currTime = ref(null)
  184. const maxTime = ref(0)
  185. const initialtime = ref(0)
  186. const videoContext = ref()
  187. const timeStamp1 = ref()
  188. const allTime = ref()
  189. const biNum = ref()
  190. const currentTime = ref()
  191. const videoStart = () => {
  192. videoContext.value = videoRef.value
  193. if (initialtime.value > 0) {
  194. videoContext.value.currentTime = initialtime.value
  195. timeStamp1.value = initialtime.value
  196. }
  197. }
  198. const timeUpdate = (e) => {
  199. //获取当前播放时间 durationinitialtime
  200. if (timeStamp1.value != parseInt(e.target.currentTime)) {
  201. allTime.value = parseInt(e.target.duration) //视频总时长(秒)
  202. //对播放的时间进行整
  203. timeStamp1.value = parseInt(e.target.currentTime) //播放进度 (秒)
  204. biNum.value = Math.floor((timeStamp1.value / allTime.value) * 10000) / 100 //暂时没用到
  205. currentTime.value = e.target.currentTime
  206. if (e.srcElement.currentTime - currTime.value > 3) {
  207. addClassPlan(3)
  208. console.log('快进了')
  209. }
  210. if (currTime.value - e.srcElement.currentTime > 3) {
  211. addClassPlan(3)
  212. console.log('快退了')
  213. }
  214. currTime.value = e.srcElement.currentTime
  215. // if (videoJumpTime.value) {
  216. // videoJumpTime.value = false
  217. // newsschedule.value = currentTime.value
  218. // } else {
  219. // if (newsschedule.value <= currentTime.value) {
  220. // //请求接口获取的参数就是判断当前观看的时长是否小于当前时间 小于当前时间就禁止拖动进度条
  221. // if (e.srcElement.currentTime - currTime.value > 3) {
  222. // //这里拖动进度条时间 - 当前观看的时长 > 3秒 这边判定它为拖动进度条
  223. // e.srcElement.currentTime = currTime.value > maxTime.value ? currTime.value : maxTime.value
  224. // let newsVal = initialtime.value
  225. // if (newsVal) {
  226. // videoContext.value.currentTime = newsVal
  227. // }
  228. // //当前用户记录观看时间 大于 实施播放时间
  229. // if (currTime.value > newsVal) {
  230. // videoContext.value.currentTime = currTime.value
  231. // }
  232. // console.log('快进了')
  233. // }
  234. // currTime.value = e.srcElement.currentTime
  235. // maxTime.value = currTime.value > maxTime.value ? currTime.value : maxTime.value
  236. // }
  237. // }
  238. }
  239. }
  240. const rightNenuRef = ref()
  241. const noteEdit = (e) => {
  242. rightNenuRef.value.selectBtn({ key: 6, type: 2 }, e)
  243. }
  244. const askEdit = (e) => {
  245. rightNenuRef.value.selectBtn({ key: 7, type: 4 }, e)
  246. }
  247. const videoJumpTime = ref(false)
  248. const videoSpeed = (e) => {
  249. videoJumpTime.value = true
  250. currentTimenew.value = e.startTime
  251. }
  252. const forumData = computed(() => {
  253. let item = findNodeByKey(classTimeList.value, selectedKeys.value[0], classTimeList.value[0]) ?? {}
  254. if (!item.src) {
  255. item.src = classTimeData.value.filter((r) => r.funcType == 1)[0]?.url
  256. }
  257. return {
  258. id: classHourData.value?.id,
  259. title: item.parent?.name,
  260. videoUrl: btoa(encodeURIComponent(videoRef.value?.src ? videoRef.value.src : item.src)),
  261. courseId: route.query.id,
  262. chapterId: selectedKeys.value[0],
  263. courseName: classDetail.value?.courseName,
  264. chapterName: classHourData.value?.name
  265. }
  266. })
  267. const showPdf = ref(true)
  268. function errorHandler() {
  269. showPdf.value = false
  270. }
  271. const getVideoTime = () => {
  272. classCentre
  273. .theLastDetail({
  274. funcType: 1,
  275. type: 1,
  276. hourld: classHourData.value?.id
  277. })
  278. .then((data) => {
  279. if (data.endTime) {
  280. initialtime.value = parseFloat(data.endTime) / 1000
  281. currentTimenew.value = parseFloat(data.endTime) / 1000
  282. }
  283. })
  284. }
  285. // 毫秒转换时分秒
  286. function msToHMS(ms) {
  287. if (typeof ms !== 'number' || isNaN(ms)) return '00:00:00'
  288. let totalSeconds = Math.floor(ms / 1000)
  289. let hours = String(Math.floor(totalSeconds / 3600)).padStart(2, '0')
  290. let minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0')
  291. let seconds = String(totalSeconds % 60).padStart(2, '0')
  292. return `${hours}:${minutes}:${seconds}`
  293. }
  294. // 时分秒转换毫秒
  295. function hmsToMs(hms) {
  296. if (!hms) return 0
  297. const parts = hms.split(':').map((p) => parseFloat(p.replace(',', '.')))
  298. // 支持 hh:mm:ss 或 mm:ss
  299. let ms = 0
  300. if (parts.length === 3) {
  301. ms = (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 1000
  302. } else if (parts.length === 2) {
  303. ms = (parts[0] * 60 + parts[1]) * 1000
  304. } else if (parts.length === 1) {
  305. ms = parts[0] * 1000
  306. }
  307. return ms
  308. }
  309. onBeforeUnmount(() => {
  310. addClassPlan(1)
  311. })
  312. const nowTimesStr = Date.now()
  313. const outNowTimesStr = ref()
  314. const addClassPlan = (type) => {
  315. outNowTimesStr.value = Date.now()
  316. let progress = parseFloat((videoRef.value?.currentTime / videoRef.value?.duration) * 100)
  317. if (
  318. (progress || progress == 0) &&
  319. (initialtime.value || initialtime.value == 0) &&
  320. (videoRef.value.currentTime || videoRef.value.currentTime == 0)
  321. ) {
  322. classCentre
  323. .classPlanAdd({
  324. startTime: parseFloat(initialtime.value),
  325. endTime: Math.round(videoRef.value.currentTime * 1000),
  326. progress: Math.round(progress),
  327. hourId: classHourData.value?.id,
  328. stayTime: outNowTimesStr.value - nowTimesStr,
  329. type: type,
  330. funcType: type
  331. })
  332. .then((data) => {})
  333. }
  334. }
  335. const isLike = () => {
  336. classCentre
  337. .classCollectAdd(
  338. {
  339. courseId: route.query.id
  340. },
  341. classDetail.value.isCollect
  342. )
  343. .then(() => {
  344. classCentre.courseDetail({ courseId: route.query.id }).then((data) => {
  345. classDetail.value = data
  346. })
  347. })
  348. }
  349. //足迹
  350. const footprintClassAdd = () => {
  351. classCentre.footprintClassAdd({
  352. hourName: classHourData.value.name,
  353. chapterName: classHourData.value.name,
  354. courseName: classDetail.value.courseName,
  355. hourId: classHourData.value.id,
  356. chapterId: selectedKeys.value[0],
  357. courseId: classDetail.value.courseId,
  358. fileId: classHourData.value.courseRelates.filter((r) => r.funcType == 1)[0].relateId,
  359. fileName: classHourData.value.courseRelates.filter((r) => r.funcType == 1)[0].name,
  360. filePath: classHourData.value.courseRelates.filter((r) => r.funcType == 1)[0].url
  361. })
  362. }
  363. onMounted(() => {
  364. getClassData()
  365. getVideoTime()
  366. nextTick(() => {
  367. videoStart()
  368. })
  369. })
  370. </script>
  371. <style scoped lang="less">
  372. .classTitle {
  373. height: 64px;
  374. width: 200px;
  375. font-size: 18px;
  376. color: #ccc;
  377. display: flex;
  378. justify-content: center;
  379. align-items: center;
  380. }
  381. .flex-between {
  382. display: flex;
  383. justify-content: space-between;
  384. }
  385. </style>