index.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. <template>
  2. <view class="page_body">
  3. <!-- 头部 -->
  4. <nav-bar height="96" title="视频详情" :onlyTitle="true" :showHeaderborder="true" @GoBack="GoBack"></nav-bar>
  5. <view class="page_content">
  6. <view class="video_content">
  7. <!-- duration:指定视频时长,单位为秒(s) initial-time:指定视频初始播放位置,单位为秒(s) -->
  8. <!-- :duration="state?.videoInfo?.duration" -->
  9. <video
  10. ref="myVideoRef"
  11. id="myVideoRef"
  12. :title="state?.videoInfo?.courseName || ''"
  13. :initial-time="state?.videoInfo?.initialTime"
  14. :show-mute-btn="true"
  15. :poster="state?.videoInfo?.videoCoverUrl"
  16. :src="state?.videoInfo?.attachmentUrl"
  17. style="width: 100%; height: 100%; object-fit: contain;"
  18. @timeupdate="videoTimeupdate"
  19. >
  20. </video>
  21. </view>
  22. <view class="video_title">{{state?.videoInfo?.courseName}}</view>
  23. <view class="video_progress">
  24. <view class="video_progress_box">
  25. <text class="video_progress_text" v-if="state?.videoInfo?.courseType===0">必修任务:{{state.videoInfo.courseStatus===0?`剩余${state.videoInfo.remainingTime}`:'已结束'}}</text>
  26. <text class="video_progress_text">观看进度:{{state?.videoInfo?.progress}}</text>
  27. </view>
  28. <view class="task_collect">
  29. <view class="preview">
  30. <image class="img" src="@/static/image/icon/preview.png"></image>
  31. <text class="text">{{state?.videoInfo?.watchCount || 0}}</text>
  32. </view>
  33. <view class="collect" v-if="state?.videoInfo?.courseType===1" @click="ChangeFollowStatus()">
  34. <uni-icons v-if="state?.videoInfo?.followStatus==1" class="img filled" type="heart-filled"></uni-icons>
  35. <uni-icons v-else class="img" type="heart"></uni-icons>
  36. <text :class="['text',{'filled':state?.videoInfo?.followStatus==1}]">{{state?.videoInfo?.followCount || 0}}</text>
  37. </view>
  38. </view>
  39. </view>
  40. <view class="video_class_name">
  41. <text class="class_name_item">{{state?.videoInfo?.teachCourseTypeName}}</text>
  42. <text class="class_name_item">{{state?.videoInfo?.teachCourseTypeClassName}}</text>
  43. <text class="class_name_item">{{state?.videoInfo?.createdBy}}</text>
  44. </view>
  45. <view class="video_time">
  46. <text class="time_item">{{state?.videoInfo?.courseType===0 ?'开始':'上传'}}时间:{{state?.videoInfo?.courseStartDatetime}}</text>
  47. <text class="time_item" v-if="state?.videoInfo?.courseType===0">结束时间:{{state.videoInfo.courseEndDatetime}}</text>
  48. </view>
  49. <view class="video_desc">
  50. 课程简介:{{state?.videoInfo?.courseContent}}
  51. </view>
  52. </view>
  53. <!-- 提示框 -->
  54. <customPopupDialog ref="popupDialogRef" title="试题弹出" dialogWidth="702" :isMaskClick="false" :showDialogClose="false" :showCancelBtn="false" @DialogConfirm="DialogConfirm">
  55. <view class="dialog_questions_input">
  56. <view class="questions_title">{{state?.question?.codeName}}:{{state?.question?.questionsName}}</view>
  57. <uni-easyinput type="textarea" v-model="state.questionsAnswer" :style="state.easyinputStyle" placeholder="请输入你的回答" placeholderStyle="font-weight: 400;font-size: 28rpx;color: #999999;"></uni-easyinput>
  58. </view>
  59. </customPopupDialog>
  60. </view>
  61. </template>
  62. <script setup>
  63. import uniEasyinput from '@/uni_modules/uni-easyinput/components/uni-easyinput/uni-easyinput.vue';
  64. import uniIcons from '@/uni_modules/uni-icons/components/uni-icons/uni-icons.vue';
  65. import navBar from '@/components/navBar.vue';
  66. import customPopupDialog from '@/components/customPopupDialog.vue';
  67. import { teacherSelectCourseDetail,addQuestionAnswerData,updateFollowCountTeacher,synTeacherLearnData } from '@/reqApi/studentStudy.js';
  68. import {
  69. onLoad,
  70. onUnload,
  71. onShow
  72. } from '@dcloudio/uni-app';
  73. import {
  74. reactive,
  75. ref,
  76. getCurrentInstance,
  77. } from 'vue';
  78. const state = reactive({
  79. id: '', //视频id
  80. videoInfo:{},
  81. currentTime:'',//播放进度时分秒
  82. question:null,//试题弹出信息
  83. easyinputStyle:{
  84. color: '#303133',
  85. borderColor: '#E9E9E9'
  86. },
  87. questionsAnswer:'',
  88. lastValidTime:'',//上一次的播放进度
  89. });
  90. //提示框
  91. const popupDialogRef = ref(null);
  92. const myVideoRef = ref(null);
  93. const videoCtx = ref(null);//播放器
  94. onLoad((option) => {
  95. state.id = option.id;
  96. const instance = getCurrentInstance();
  97. videoCtx.value = uni.createVideoContext('myVideoRef', instance.proxy);
  98. GetCourseDetail();
  99. })
  100. onShow(() => {
  101. // #ifdef APP-PLUS
  102. const answer = uni.getStorageSync('videoDetailQuestionPopupSubmit');
  103. if(answer){
  104. state.questionsAnswer = answer?answer:'';
  105. uni.removeStorageSync('videoDetailQuestionPopupSubmit');
  106. DialogConfirm();
  107. }
  108. // #endif
  109. })
  110. //视频详情
  111. const GetCourseDetail = () => {
  112. teacherSelectCourseDetail(state.id).then(res => {
  113. if(res.code == 200){
  114. const resData = res.data;
  115. resData.initialTime = timeToSecond(resData.courseLearnTime ?? '');//视频初始播放位置
  116. state.lastValidTime = timeToSecond(resData.courseLearnTime ?? '');
  117. resData.duration = timeToSecond(resData.videoTime ?? '');//视频时长,单位为秒(s)
  118. if(resData?.questionsList?.length > 0){
  119. resData.questionsList.forEach(item=>{
  120. const showTimeDate = timeToSecond(item.showTime);
  121. item.isWriteAnswer = showTimeDate < resData.initialTime;//是否已作答 true 已作答
  122. })
  123. }
  124. state.videoInfo = resData;
  125. }
  126. })
  127. }
  128. //计算秒数
  129. const timeToSecond = (timeStr) => {
  130. if(timeStr == '') return 0
  131. const timeArr = timeStr.split(':');
  132. const [h, m, s] = timeArr.map(item => Number(item));
  133. return h * 3600 + m * 60 + s;
  134. }
  135. //播放进度变化时触发,event.detail = {currentTime, duration} 。触发频率 250ms 一次
  136. const videoTimeupdate = (event) => {
  137. const currentTime = event.detail.currentTime;
  138. if(currentTime > 0){
  139. // 1. 判断:如果当前时间小于记录值,说明在回退
  140. const diff = (Number(currentTime) - Number(state.lastValidTime)).toFixed(2);
  141. const dragStatus = state.videoInfo.dragStatus || 0;//0 不可拖拽、默认不可拖拽
  142. const learnStatus = state.videoInfo.learnStatus ?? 3;//学习状态
  143. const progress = state.videoInfo.progress;//进度
  144. if (diff > 0.35 && dragStatus == 0 && (learnStatus!=4 || (learnStatus!=1 && progress!='100%'))) {
  145. videoCtx.value.seek(state.lastValidTime);//跳转到s秒
  146. }
  147. state.currentTime = formatSecondsToHms(currentTime);
  148. const questionsList = state?.videoInfo?.questionsList || [];
  149. const question = questionsList.find(item=>item.showTime == state.currentTime);//视频显示的时间
  150. const isSelf = state?.videoInfo?.courseType==1// 0任务学习 1 自主学习 自主学习不弹试题
  151. if(question && !isSelf && !question.isWriteAnswer){//未作答
  152. videoCtx?.value?.pause();//暂停播放
  153. state.questionsAnswer = '';
  154. state.question = question;//试题弹框信息
  155. // #ifdef APP-PLUS
  156. //打开nvue弹窗页面,把question对象编码传过去
  157. uni.setStorageSync('videoDetailSendQuestion', JSON.stringify(question))
  158. setTimeout(()=>{
  159. uni.navigateTo({
  160. url:'/pages/studentStudy/videoDetails/subNVue/questionPopup'
  161. })
  162. },0)
  163. // #endif
  164. // #ifndef APP-PLUS
  165. popupDialogRef.value.OpenDialog();
  166. // #endif
  167. }
  168. state.lastValidTime = currentTime;//上一次播放进度
  169. // 教师学习进度更新接口
  170. if(learnStatus!=4 || (learnStatus!=1 && progress!='100%')){
  171. state.videoInfo.progress = Math.min(currentTime / Number(state.videoInfo.duration) * 100, 100).toFixed(2) + '%';
  172. setSynTeachLearnData();
  173. }
  174. }
  175. }
  176. //提交试题
  177. const DialogConfirm = () => {
  178. if(state.questionsAnswer == ''){
  179. uni.showToast({
  180. title: '请输入答案!',
  181. icon: 'none'
  182. })
  183. return false
  184. }
  185. addQuestionAnswerData({
  186. teachCourseId: state.id,
  187. teachQuestionsId: state?.question?.id || '',
  188. teachQuestionsAnswer: state.questionsAnswer
  189. }).then(res=>{
  190. if(res.code == 200){
  191. state.question.isWriteAnswer = true;//状态修改成已作答
  192. // #ifndef APP-PLUS
  193. popupDialogRef.value.CloseDialog();//关闭弹框
  194. // #endif
  195. videoCtx?.value?.play();//播放
  196. }else{
  197. uni.showToast({
  198. title: res.msg || '请求异常',
  199. icon: 'none'
  200. })
  201. }
  202. })
  203. }
  204. //教师学习进度更新接口
  205. const setSynTeachLearnData = () => {
  206. const videoTime = state.videoInfo.videoTime ?? '';//视频时长
  207. // 学习状态: 1-已结束(原已过期状态-课程结束了,但是用户没学完),2-待开始,3-未完成,4-已完成
  208. const learnStatus = state.videoInfo.learnStatus ?? 3;
  209. synTeacherLearnData({
  210. studCourseId:state.id,
  211. courseLearnTime:state.currentTime,
  212. learnStatus:state.currentTime==videoTime && learnStatus!=1 && learnStatus!=4?4:learnStatus
  213. }).then(res=>{
  214. if(res.code==200 && state.currentTime==videoTime && learnStatus!=1 && learnStatus!=4){
  215. state.videoInfo.learnStatus = 4;//学习状态
  216. state.videoInfo.progress = '100%';//进度
  217. }
  218. })
  219. }
  220. // 数字补零工具
  221. const addZero = (num) => {
  222. return num < 10 ? '0' + num : String(num);
  223. }
  224. //转换时分秒
  225. const formatSecondsToHms = (seconds) => {
  226. let total = Math.floor(seconds);
  227. const h = Math.floor(total / 3600);
  228. const m = Math.floor((total % 3600) / 60);
  229. const s = total % 60;
  230. const hh = addZero(h);
  231. const mm = addZero(m);
  232. const ss = addZero(s);
  233. return `${hh}:${mm}:${ss}`;
  234. }
  235. //收藏
  236. const ChangeFollowStatus = () => {
  237. updateFollowCountTeacher(state.id).then(res=>{
  238. if(res.code == 200){
  239. state.videoInfo.followStatus = state.videoInfo.followStatus===1?0:1;
  240. state.videoInfo.followCount = res.data;
  241. }
  242. })
  243. }
  244. //页面销毁时触发
  245. onUnload(()=>{
  246. videoCtx?.value?.pause();//暂停播放
  247. })
  248. //返回
  249. const GoBack = () => {
  250. uni.navigateBack({
  251. delta: 1
  252. });
  253. }
  254. </script>
  255. <style scoped lang="scss">
  256. .video_content {
  257. width: 100%;
  258. border-radius: 8rpx;
  259. margin-top: 24rpx;
  260. height: 398rpx;
  261. overflow: hidden;
  262. }
  263. .video_title{
  264. width: 100%;
  265. margin-top: 32rpx;
  266. font-weight: 500;
  267. font-size: 36rpx;
  268. color: #333333;
  269. display: -webkit-box;
  270. -webkit-line-clamp: 2;
  271. -webkit-box-orient: vertical;
  272. overflow: hidden;
  273. text-overflow: ellipsis;
  274. }
  275. .video_progress{
  276. display: flex;
  277. justify-content: space-between;
  278. width: 100%;
  279. margin-top: 16rpx;
  280. align-items: flex-start;
  281. .video_progress_box{
  282. display: inline-flex;
  283. flex-wrap: wrap;
  284. gap: 20rpx;
  285. .video_progress_text{
  286. font-weight: 400;
  287. font-size: 24rpx;
  288. color: #2E64FA;
  289. }
  290. }
  291. .task_collect {
  292. display: inline-flex;
  293. gap: 20rpx;
  294. .preview,
  295. .collect {
  296. display: inline-flex;
  297. align-items: center;
  298. }
  299. .img {
  300. width: 24rpx;
  301. height: 24rpx;
  302. margin-right: 12rpx;
  303. font-size: 24rpx !important;
  304. color: #999999 !important;
  305. &.filled{
  306. color: #2e64fa !important;
  307. }
  308. }
  309. .text {
  310. font-weight: 400;
  311. font-size: 24rpx;
  312. color: #999999;
  313. &.filled{
  314. color: #2e64fa;
  315. }
  316. }
  317. }
  318. }
  319. .video_class_name{
  320. display: flex;
  321. width: 100%;
  322. margin-top: 16rpx;
  323. gap: 16rpx 24rpx;
  324. flex-wrap: wrap;
  325. .class_name_item{
  326. font-weight: 400;
  327. font-size: 24rpx;
  328. color: #666666;
  329. }
  330. }
  331. .video_time{
  332. display: flex;
  333. width: 100%;
  334. margin-top: 16rpx;
  335. gap: 16rpx;
  336. flex-wrap: wrap;
  337. .time_item{
  338. font-weight: 400;
  339. font-size: 24rpx;
  340. color: #666666;
  341. }
  342. }
  343. .video_desc{
  344. display: flex;
  345. width: 100%;
  346. margin-top: 32rpx;
  347. flex-wrap: wrap;
  348. word-break: break-all;
  349. font-weight: 400;
  350. font-size: 24rpx;
  351. color: #999999;
  352. line-height: 44rpx;
  353. }
  354. .dialog_questions_input{
  355. display: flex;
  356. width: 100%;
  357. flex-direction: column;
  358. .questions_title{
  359. display: flex;
  360. width: 100%;
  361. font-weight: 400;
  362. font-size: 32rpx;
  363. color: #303133;
  364. text-align: left;
  365. word-break: break-all;
  366. }
  367. :deep(.uni-easyinput){
  368. margin-top: 20rpx;
  369. font-size: 28rpx;
  370. .is-input-border{
  371. border-radius: 8rpx;
  372. border-color: #E9E9E9 !important;
  373. }
  374. .uni-easyinput__content-textarea{
  375. height: 440rpx;
  376. min-height: 440rpx;
  377. font-size: 28rpx;
  378. margin:20rpx 24rpx 20rpx 14rpx;
  379. }
  380. .input-padding{
  381. padding-left: 10rpx;
  382. }
  383. }
  384. }
  385. </style>