VideoPreview.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. <template>
  2. <div ref="videoArea" class="video-preview" :style="[getAreaStyle(), getComponentStyle()]">
  3. <SerialNumberPosition v-if="isEnable(data.property.sn_display_mode)" :property="data.property" />
  4. <div v-if="'independent' === data.property.view_method" ref="videoAreaBox" class="main">
  5. <ul class="view-independent">
  6. <li v-for="(file, i) in data.file_list" :key="i" :style="getVideoItemStyle()">
  7. <VideoPlay
  8. view-size="small"
  9. view-method="independent"
  10. :file-id="file.file_id"
  11. :cur-video-index="curVideoIndex"
  12. @changeFile="changeFile"
  13. />
  14. </li>
  15. </ul>
  16. </div>
  17. <div v-else ref="videoAreaBox">
  18. <div class="view-list">
  19. <el-carousel
  20. ref="video_carousel"
  21. indicator-position="none"
  22. direction="vertical"
  23. :autoplay="false"
  24. :interval="0"
  25. :style="{ width: elementWidth - 248 - 32 + 'px', height: elementHeight <= 0 ? 169 : elementHeight + 'px' }"
  26. >
  27. <el-carousel-item v-for="(file, i) in data.file_list" :key="i">
  28. <VideoPlay
  29. view-size="big"
  30. :file-id="file.file_id"
  31. :cur-video-index="curVideoIndex"
  32. @changeFile="changeFile"
  33. />
  34. </el-carousel-item>
  35. </el-carousel>
  36. <div class="container-box" :style="{ height: elementHeight <= 0 ? 169 : elementHeight + 'px' }">
  37. <ul
  38. ref="container"
  39. class="view-list-bottom"
  40. :style="{ height: elementHeight + 'px', transform: `translateY(${translateY}px)` }"
  41. >
  42. <li v-for="(file, i) in data.file_list" :key="i" @click="handleAudioClick(i)">
  43. <VideoPlay view-size="small" :file-id="file.file_id" :audio-index="i" :cur-video-index="curVideoIndex" />
  44. </li>
  45. </ul>
  46. <button v-if="viewTopBottomBtn" class="arrow top" @click="scroll(1)">
  47. <i class="el-icon-arrow-up"></i>
  48. </button>
  49. <button v-if="viewTopBottomBtn" class="arrow bottom" @click="scroll(-1)">
  50. <i class="el-icon-arrow-down"></i>
  51. </button>
  52. </div>
  53. </div>
  54. </div>
  55. </div>
  56. </template>
  57. <script>
  58. import { getVideoData } from '@/views/book/courseware/data/video';
  59. import PreviewMixin from '../common/PreviewMixin';
  60. import VideoPlay from '../common/VideoPlay.vue';
  61. import { getRandomNumber } from '@/utils';
  62. export default {
  63. name: 'VideoPreview',
  64. components: { VideoPlay },
  65. mixins: [PreviewMixin],
  66. inject: ['getDragStatus'],
  67. data() {
  68. return {
  69. data: getVideoData(),
  70. curVideoIndex: 0,
  71. elementWidth: 0,
  72. elementHeight: 0,
  73. viewTopBottomBtn: false,
  74. fileLen: 0,
  75. translateY: 0,
  76. elementID: '',
  77. isResizing: false,
  78. resizeObserver: null,
  79. isFirstLoad: true,
  80. };
  81. },
  82. watch: {
  83. data: {
  84. handler(val) {
  85. this.fileLen = val.file_list.length;
  86. if (this.fileLen === 0) return;
  87. if (this.data.property.view_method === 'list') {
  88. const ele = this.$refs.videoAreaBox;
  89. const sn_position = this.data.property.sn_position;
  90. // 序号在左和右补齐序号高度,去掉padding(8*2)
  91. if (sn_position.includes('left') || sn_position.includes('right')) {
  92. this.elementWidth = ele.clientWidth - 16;
  93. this.elementHeight = ele.clientHeight + 30;
  94. } else {
  95. this.elementWidth = ele.clientWidth;
  96. this.elementHeight = ele.clientHeight;
  97. }
  98. // if (ele.clientHeight <= 0) {
  99. // this.elementHeight = this.data.min_height;
  100. // }
  101. const mainEle = this.$refs.videoArea;
  102. // 检查元素是否包含已知的类名
  103. mainEle.classList.forEach((className) => {
  104. // 排除已知的类名
  105. if (className !== 'audio-area') {
  106. // 打印另一个类名
  107. this.elementID = className;
  108. }
  109. });
  110. }
  111. if (this.data.property.view_method === 'independent') {
  112. const ele = this.$refs.videoAreaBox;
  113. const sn_position = this.data.property.sn_position;
  114. if (sn_position.includes('left') || sn_position.includes('right')) {
  115. this.elementWidth = ele.clientWidth - 16;
  116. this.elementHeight = ele.clientHeight + 30;
  117. } else {
  118. this.elementWidth = ele.clientWidth;
  119. this.elementHeight = ele.clientHeight;
  120. }
  121. // if (ele.clientHeight <= 0) {
  122. // this.elementHeight = this.data.min_height;
  123. // }
  124. }
  125. this.elementHeight = Math.max(this.elementHeight, this.data.min_height);
  126. // this.$emit('handleHeightChange', this.id, this.elementHeight + 'px');
  127. },
  128. deep: true,
  129. },
  130. elementHeight() {
  131. this.isViewTopBottomBtn();
  132. },
  133. fileLen() {
  134. this.isViewTopBottomBtn();
  135. },
  136. },
  137. mounted() {
  138. this.$nextTick(() => {
  139. // const canvasElement = document.querySelector('.canvas');
  140. // if (!canvasElement) {
  141. const ele = this.$refs.videoAreaBox;
  142. const sn_position = this.data.property.sn_position;
  143. // 序号在左和右补齐序号高度,去掉padding(8*2)
  144. if (sn_position.includes('left') || sn_position.includes('right')) {
  145. this.elementWidth = ele.clientWidth - 16;
  146. this.elementHeight = ele.clientHeight + 30;
  147. } else {
  148. this.elementWidth = ele.clientWidth;
  149. this.elementHeight = ele.clientHeight;
  150. }
  151. if (ele.clientHeight <= 0) {
  152. this.elementHeight = this.data.min_height;
  153. }
  154. this.fileLen = this.data.file_list.length;
  155. // }
  156. this.resizeObserver = new ResizeObserver((entries) => {
  157. if (!this.getDragStatus() && !this.isFirstLoad) return;
  158. this.isResizing = true; // 标记为调整中
  159. const delay = this.isFirstLoad ? 500 : 0;
  160. setTimeout(() => {
  161. for (let entry of entries) {
  162. window.requestAnimationFrame(() => {
  163. const sn_position = this.data.property.sn_position;
  164. // 序号在上方和下方减去序号高度
  165. let w = entry.contentRect.width - 16;
  166. let h = entry.contentRect.height;
  167. if (sn_position.includes('top') || sn_position.includes('bottom')) {
  168. w = entry.contentRect.width;
  169. h = entry.contentRect.height - 30;
  170. }
  171. if (this.elementWidth === w) {
  172. this.elementHeight = h;
  173. } else {
  174. this.elementWidth = w;
  175. }
  176. });
  177. }
  178. }, delay);
  179. this.isFirstLoad = false;
  180. // 防抖:100ms 后恢复监听
  181. setTimeout(() => {
  182. this.isResizing = false;
  183. }, 500);
  184. });
  185. this.resizeObserver.observe(this.$el);
  186. });
  187. },
  188. beforeDestroy() {
  189. if (this.resizeObserver) {
  190. this.resizeObserver.disconnect();
  191. }
  192. },
  193. methods: {
  194. getVideoItemStyle() {
  195. if (this.data.property.view_method !== 'independent') {
  196. return {};
  197. }
  198. const height = this.elementHeight > 0 ? this.elementHeight : Number(this.data.min_height);
  199. const width = (height * 16) / 9;
  200. return {
  201. width: `${width}px`,
  202. height: `${height}px`,
  203. flexShrink: 0,
  204. };
  205. },
  206. handleAudioClick(index) {
  207. // 获取 Carousel 实例
  208. const carousel = this.$refs.video_carousel;
  209. // 切换到对应索引的文件
  210. carousel.setActiveItem(index);
  211. this.curVideoIndex = index;
  212. },
  213. changeFile(type) {
  214. // 获取 Carousel 实例
  215. const carousel = this.$refs.video_carousel;
  216. // 切换到对应索引的文件
  217. if (type === 'prev') {
  218. carousel.prev();
  219. this.curVideoIndex += -1;
  220. } else {
  221. carousel.next();
  222. this.curVideoIndex += 1;
  223. }
  224. if (this.curVideoIndex >= this.data.file_id_list.length) {
  225. this.curVideoIndex = 0;
  226. }
  227. if (this.curVideoIndex < 0) {
  228. this.curVideoIndex = this.data.file_id_list.length - 1;
  229. }
  230. },
  231. handleResize() {
  232. const width = this.$refs.videoAreaBox.clientWidth;
  233. if (width !== this.elementWidth) {
  234. this.elementWidth = width;
  235. }
  236. },
  237. // 是否显示上下箭头
  238. isViewTopBottomBtn() {
  239. // 计算右侧列表图片高度
  240. let listHeight = this.fileLen * this.data.min_height + 20 * (this.fileLen - 1);
  241. if (listHeight > this.elementHeight) {
  242. this.viewTopBottomBtn = true;
  243. } else {
  244. this.viewTopBottomBtn = false;
  245. this.translateY = 0;
  246. }
  247. },
  248. // 滚动图片列表
  249. scroll(direction) {
  250. const container = this.$refs.container;
  251. if (!container) return;
  252. // 获取实际的列表高度
  253. const listHeight = container.scrollHeight;
  254. const maxScroll = listHeight - this.elementHeight;
  255. // 计算每个item的实际高度(含间距)
  256. const itemHeight = this.fileLen > 1 ? (listHeight - 20) / (this.fileLen - 1) : listHeight;
  257. const step = itemHeight; // 每次滚动的距离
  258. // 计算滚动后的 translateY 值
  259. let newY = this.translateY + step * direction;
  260. // 检查是否超出上下边界
  261. if (newY > 0) {
  262. // 滚动到第一张图片时不再向上滚动
  263. this.translateY = 0;
  264. } else if (newY < -maxScroll) {
  265. // 滚动到最后一张图片时不再向下滚动
  266. this.translateY = -maxScroll;
  267. } else {
  268. // 在边界内时执行滚动
  269. this.translateY = newY;
  270. }
  271. },
  272. /**
  273. * 获取无文本内容的数据结构,用于保存为个人模板时的样式模板
  274. */
  275. getNoTextContentData() {
  276. let noTextContentData = JSON.parse(JSON.stringify(this.data));
  277. const resetFieldMap = {
  278. analysis_list: [],
  279. answer_list: [],
  280. };
  281. Object.assign(noTextContentData, resetFieldMap);
  282. if (noTextContentData.answer) {
  283. noTextContentData.answer.answer_list = [];
  284. noTextContentData.answer.reference_answer = '';
  285. }
  286. return noTextContentData;
  287. },
  288. },
  289. };
  290. </script>
  291. <style lang="scss" scoped>
  292. @use '@/styles/mixin.scss' as *;
  293. .video-preview {
  294. @include preview-base;
  295. .alone-video-area {
  296. :deep .el-carousel {
  297. background-color: #d9d9d9;
  298. &__container {
  299. height: 100%;
  300. }
  301. }
  302. }
  303. .view-independent {
  304. display: flex;
  305. gap: 20px;
  306. width: fit-content;
  307. padding: 0;
  308. margin: 0;
  309. > li {
  310. flex-shrink: 0;
  311. list-style: none;
  312. :deep(.video-play) {
  313. width: 100%;
  314. height: 100%;
  315. }
  316. }
  317. }
  318. .main {
  319. position: relative;
  320. overflow: auto hidden !important;
  321. // Firefox 滚动条样式
  322. scrollbar-width: none; // 默认隐藏
  323. // 自定义滚动条样式 - 完全浮动
  324. &::-webkit-scrollbar {
  325. width: 0; // 默认宽度为 0
  326. height: 0;
  327. transition:
  328. width 0.3s ease,
  329. height 0.3s ease;
  330. }
  331. // 鼠标悬停时显示滚动条
  332. &:hover::-webkit-scrollbar {
  333. width: 8px;
  334. height: 8px;
  335. }
  336. &::-webkit-scrollbar-thumb {
  337. background-color: rgba(0, 0, 0, 50%);
  338. border-radius: 4px;
  339. transition: background-color 0.3s ease;
  340. &:hover {
  341. background-color: rgba(0, 0, 0, 70%);
  342. }
  343. }
  344. &::-webkit-scrollbar-track {
  345. background-color: transparent;
  346. }
  347. &:hover {
  348. scrollbar-width: thin; // 鼠标悬停时显示
  349. scrollbar-color: rgba(0, 0, 0, 50%) transparent;
  350. }
  351. }
  352. .view-list {
  353. display: flex;
  354. column-gap: 32px;
  355. :deep .el-carousel {
  356. background-color: #d9d9d9;
  357. &__container {
  358. height: 100%;
  359. }
  360. }
  361. .container-box {
  362. position: relative;
  363. overflow: hidden;
  364. .arrow {
  365. position: absolute;
  366. z-index: 10;
  367. width: 100%;
  368. height: 40px;
  369. text-align: center;
  370. background-color: rgba(0, 0, 0, 10%);
  371. border-radius: 0;
  372. }
  373. .arrow:hover {
  374. background-color: rgba(0, 0, 0, 30%);
  375. }
  376. .top {
  377. top: 0;
  378. }
  379. .bottom {
  380. bottom: 0;
  381. }
  382. .view-list-bottom {
  383. display: flex;
  384. flex-direction: column;
  385. row-gap: 20px;
  386. > li {
  387. width: 248px;
  388. height: calc(248px * 9 / 16);
  389. }
  390. }
  391. }
  392. }
  393. }
  394. </style>