Pārlūkot izejas kodu

适配视频播放页面

shmily1213 1 dienu atpakaļ
vecāks
revīzija
7a94eb4e20

+ 21 - 1
src/api/modules/study.ts

@@ -1,6 +1,6 @@
 import { ApiResponse, ApiResponseList } from "@/types";
 import flyio from "../flyio";
-import type { Batch, ClassKnowledgeRecord, DirectedSchool, Examinee, ExamPaper, ExamPaperSubmit, FavoriteQuestion, FavoriteQuestionListRequestDTO, GetExamPaperRequestDTO, Knowledge, KnowledgeListRequestDTO, KnowledgeRecord, OpenExamineeRequestDTO, PaperWork, PaperWorkRecord, PaperWorkRecordDetail, PaperWorkRecordQuery, PracticeHistory, PracticeRecord, SimulatedRecord, SimulationExamSubject, SimulationTestInfo, StudentExamRecord, StudentPlanStudyRecord, StudentSubject, StudentVideoRecord, StudyPlan, Subject, SubjectListRequestDTO, TeachClass, VideoCourse, VideoCourseKnowledge, VideoCourseRequestDTO, VideoCourseSubject, VideoCourseSubjectRequestDTO, VideoStudy, WrongBookQuestion, WrongBookQuestionRequestDTO } from "@/types/study";
+import type { Batch, ClassKnowledgeRecord, DirectedSchool, Examinee, ExamPaper, ExamPaperSubmit, FavoriteQuestion, FavoriteQuestionListRequestDTO, GetExamPaperRequestDTO, Knowledge, KnowledgeListRequestDTO, KnowledgeRecord, OpenExamineeRequestDTO, PaperWork, PaperWorkRecord, PaperWorkRecordDetail, PaperWorkRecordQuery, PracticeHistory, PracticeRecord, SimulatedRecord, SimulationExamSubject, SimulationTestInfo, StudentExamRecord, StudentPlanStudyRecord, StudentSubject, StudentVideoRecord, StudyPlan, Subject, SubjectListRequestDTO, TeachClass, VideoCourse, VideoCourseKnowledge, VideoCoursePlayInfo, VideoCourseRecordDTO, VideoCourseRequestDTO, VideoCourseSubject, VideoCourseSubjectRequestDTO, VideoStudy, WrongBookQuestion, WrongBookQuestionRequestDTO } from "@/types/study";
 import { EnumPaperWorkState } from "@/common/enum";
 
 /**
@@ -379,4 +379,24 @@ export function getVideoCourseList(params: VideoCourseRequestDTO) {
   return flyio.get('/front/videoCourse/video/list', params) as Promise<ApiResponseList<VideoCourse>>;
 }
 
+/**
+ * 获取视频课程播放信息
+ * @param videoId 
+ * @returns 
+ */
+export function getVideoCoursePlayInfo(videoId: string) {
+  return flyio.get('/front/comm/vod/getVideoPlayInfo', { videoId }) as Promise<ApiResponse<VideoCoursePlayInfo>>;
+}
 
+/**
+ * 保存视频课程学习记录
+ * @param params 
+ * @returns 
+ */
+export function saveVideoCourseRecord(params: VideoCourseRecordDTO) {
+  return flyio.post('/front/videoCourse/saveWatchRecord', null, { params }, {
+    headers: {
+      'Content-Type': 'application/www-form-urlencoded'
+    }
+  }) as Promise<ApiResponse<any>>;
+}

+ 0 - 17
src/hooks/useDebounce.ts

@@ -66,21 +66,4 @@ export function useDebounce<T extends (...args: any[]) => any>(
   };
   
   return debounced;
-}
-
-/**
- * 用于Vue组件中的防抖钩子
- * @param value 需要防抖的值
- * @param wait 等待时间(毫秒)
- * @returns 防抖后的值
- */
-export function useDebouncedValue<T>(value: T, wait: number): T {
-  let debouncedValue = value;
-  let timeoutId: ReturnType<typeof setTimeout> | null = null;
-  
-  // 简单实现,实际项目中可能需要结合ref或reactive使用
-  // 完整实现可能需要:import { ref, watch } from 'vue';
-  // 这里仅提供一个基础版本
-  
-  return debouncedValue;
 }

+ 5 - 0
src/main.ts

@@ -104,6 +104,11 @@ export function createApp() {
             borderRadius: '24rpx'
           })
         }
+      },
+      tags: {
+        customClass: {
+          default: ''
+        }
       }
     }
   })

+ 3 - 1
src/pagesMain/pages/index/components/index-guide.vue

@@ -70,7 +70,9 @@ const loadData = () => {
     newsList.value = res.rows;
   });
 }
-loadData();
+onShow(() => {
+  loadData();
+});
 </script>
 <style lang="scss" scoped>
 .wrap {

+ 1 - 1
src/pagesMain/pages/index/components/index-news.vue

@@ -29,7 +29,7 @@ const loadData = async () => {
   newsList.value = rows;
 }
 
-onLoad(() => {
+onShow(() => {
   loadData();
 });
 </script>

+ 12 - 8
src/pagesStudy/components/video-table.vue

@@ -12,14 +12,8 @@
     </view>
     <view class="mx-30">
       <ie-table :table-columns="tableColumns" :data="data">
-        <template #name="{ item }">
-          <view class="flex items-center justify-center">
-            <ie-image :src="item.avatar" class="w-60 h-60 bg-back" :round="999" />
-            <text class="ml-10 flex-1 min-w-1 ellipsis-1">{{ item.name }}</text>
-          </view>
-        </template>
         <template #duration="{ item }">
-          <text>{{ Math.round(item.study / 60) }}分钟</text>
+          <text>{{ getStudyDuration(item) }}</text>
         </template>
       </ie-table>
     </view>
@@ -64,6 +58,16 @@ const tableColumns = ref<TableColumnConfig[]>([
     slot: 'duration',
   }
 ])
-const data = computed(() => props.data.list || [])
+const data = computed(() => props.data.list || []);
+
+const getStudyDuration = (item: Study.VideoStudyRecord) => {
+  const time = Number(item.study) / 60;
+  if (time > 1) {
+    return Math.round(time) + '分钟';
+  } else {
+    // 显示秒
+    return Number(item.study) + '秒';
+  }
+}
 </script>
 <style lang="scss" scoped></style>

+ 1 - 1
src/pagesStudy/pages/video/index/index.vue

@@ -9,7 +9,7 @@
       </template>
       <view class="h-[8px] bg-back" />
       <uv-collapse ref="collapse" :border="false">
-        <uv-collapse-item v-for="item in knowledgeList" :key="item.code" :data="item" :lazy="true" :load="handleLoad">
+        <uv-collapse-item v-for="item in knowledgeList" :key="item.code" :data="item" :duration="200" :lazy="true" :load="handleLoad">
           <template #title="{ expanded, loading }">
             <view class="flex items-center justify-between">
               <view class="flex items-center gap-10">

+ 112 - 4
src/pagesStudy/pages/video/play/play.vue

@@ -1,29 +1,137 @@
 <template>
-  <ie-page>
+  <ie-page bg-color="#F6F8FA">
     <ie-navbar :title="pageTitle" />
+    <view class="">
+      <view class="h-[200px] bg-black">
+        <video v-if="playUrl" id="player" class="w-full h-full" :src="playUrl" :poster="coverUrl" object-fit="fill"
+          controls enable-play-gesture play-btn-position="center" :picture-in-picture-mode="[]"
+          :show-background-playback-button="false" @timeupdate="handleTimeUpdate" @ended="handlePlayEnd">
+        </video>
+      </view>
+      <view class="flex items-center gap-20 px-20 py-16 bg-white">
+        <text class="text-28 text-fore-light">倍速播放</text>
+        <uv-tags v-for="item in rateList" :key="item" :text="`x${item}`" size="mini" type="primary"
+          :plain="currentRate !== item" @click="handleRate(item)" custom-class="w-[28px] text-center">
+        </uv-tags>
+      </view>
+      <view class="bg-white">
+        <uv-cell-group>
+          <uv-cell v-for="item in videoList" :key="item.aliId" :title="item.name">
+            <template #title>
+              <view class="flex items-center gap-20" @click="handlePlay(item)">
+                <ie-image :src="item.img" custom-class="w-100 h-64" />
+                <view class="flex-1 min-w-1 ellipsis-1 text-28"
+                  :class="[currentVideo?.aliId === item.aliId ? 'text-primary' : 'text-fore-title']">
+                  {{ item.name }}
+                </view>
+                <view class="text-24 text-primary flex items-center">
+                  <uv-icon :name="currentVideo?.aliId === item.aliId ? 'play-circle-fill' : 'play-circle'" size="20"
+                    color="var(--primary-color)" />
+                </view>
+              </view>
+            </template>
+          </uv-cell>
+        </uv-cell-group>
+      </view>
+    </view>
   </ie-page>
 </template>
 
 <script lang="ts" setup>
 import { useTransferPage } from '@/hooks/useTransferPage';
 import type { Study } from '@/types';
+import { getVideoCoursePlayInfo, saveVideoCourseRecord } from '@/api/modules/study';
 
+let videoContext: UniApp.VideoContext;
+const rateList = ref<number[]>([0.5, 1, 1.25, 1.5, 2]);
 const { prevData, transferBack } = useTransferPage();
-const currentVideo = ref < Study.VideoCourse > ();
+const currentRate = ref<number>(1);
+const currentVideo = ref<Study.VideoCourse>();
+const duration = ref(0);
+const currentTime = ref(0);
 
 const videoList = computed(() => {
   const { knowledge } = prevData.value as { knowledge: Study.VideoCourseKnowledge };
-  return knowledge.children;
+  return knowledge?.children || [];
 });
 const videoIndex = computed(() => {
   return videoList.value.findIndex(item => item.aliId === currentVideo.value?.aliId);
 });
 const pageTitle = computed(() => {
-  return videoList.value[videoIndex.value].name;
+  return videoList.value[videoIndex.value]?.name || '';
 });
+const playUrl = ref('');
+const coverUrl = ref('');
+const handleRate = (item: number) => {
+  if (videoContext) {
+    currentRate.value = item;
+    videoContext.playbackRate(item);
+  }
+}
+const handleTimeUpdate = (e: any) => {
+  const newTime = Number(e.detail.currentTime);
+  if (newTime > currentTime.value && (newTime - currentTime.value > 5)) {
+    saveRecord(newTime, duration.value);
+    currentTime.value = newTime;
+  }
+  if (!isNaN(e.detail.duration)) {
+    duration.value = Number(e.detail.duration);
+  }
+}
+const handlePlayEnd = () => {
+  saveRecord(duration.value, duration.value);
+}
+const saveRecord = (current: number, duration: number) => {
+  if (current > 0 && duration > 0) {
+    const percentage = ((current / duration) * 100).toFixed(2);
+    saveVideoCourseRecord({
+      sectionId: currentVideo.value!.aliId,
+      duration: Math.floor(duration),
+      percent: percentage,
+      type: currentVideo.value!.aliIdType
+    }).then(res => {
+      console.log(res);
+    });
+  }
+}
+const handlePlay = async (item: Study.VideoCourse) => {
+  currentVideo.value = item;
+  // 等待播放暂停
+  videoContext.pause();
+  await new Promise(resolve => setTimeout(resolve, 150));
+  currentTime.value = 0;
+  duration.value = 0;
+  await loadData();
+  setTimeout(() => {
+    videoContext.play();
+  }, 0);
+}
+const loadData = async () => {
+  if (currentVideo.value) {
+    const aliId = currentVideo.value?.aliId || '';
+    if (aliId.includes('https') || aliId.includes('http')) {
+      playUrl.value = aliId;
+    } else {
+      uni.$ie.showLoading();
+      await getVideoCoursePlayInfo(aliId).then(res => {
+        playUrl.value = res.data.palyUrl;
+        coverUrl.value = res.data.coverUrl;
+      }).finally(() => {
+        uni.$ie.hideLoading();
+      });
+    }
+  }
+}
 
 onLoad(() => {
   currentVideo.value = prevData.value.video;
+  loadData();
+});
+onUnload(() => {
+  saveRecord(currentTime.value, duration.value);
+});
+onReady(() => {
+  videoContext = uni.createVideoContext('player');
 });
 </script>
 

+ 20 - 0
src/types/study.ts

@@ -649,4 +649,24 @@ export interface VideoCourse {
   aliIdType: number;
   img: string;
   name: string;
+}
+/**
+ * 视频课程播放信息
+ */
+export interface VideoCoursePlayInfo {
+  coverUrl: string;
+  duration: string;
+  palyUrl: string;
+  title: string;
+  videoId: string;
+}
+
+/**
+ * 视频课程学习记录
+ */
+export interface VideoCourseRecordDTO {
+  sectionId: string;
+  duration: number;
+  percent: string;
+  type: number;
 }

+ 3 - 1
src/uni_modules/uv-tags/components/uv-tags/uv-tags.vue

@@ -7,7 +7,7 @@
         <view class="uv-tags-wrapper">
             <view
                 class="uv-tags"
-                :class="[`uv-tags--${shape}`, !plain && `uv-tags--${type}`, plain && `uv-tags--${type}--plain`, `uv-tags--${size}`,`uv-tags--${size}--${closePlace}`, plain && plainFill && `uv-tags--${type}--plain--fill`]"
+                :class="[`uv-tags--${shape}`, !plain && `uv-tags--${type}`, plain && `uv-tags--${type}--plain`, `uv-tags--${size}`,`uv-tags--${size}--${closePlace}`, plain && plainFill && `uv-tags--${type}--plain--fill`, customClass]"
                 @tap.stop="clickHandler"
                 :style="[{
 					marginRight: closable&& closePlace=='right-top' ? '10px' : 0,
@@ -222,6 +222,8 @@ export default {
     }
 
     &__text {
+      flex: 1;
+      text-align: center;;
         &--tiny {
             font-size: 10px;
             line-height: 10px;