ProgrammingCourset.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624
  1. <!-- 编程课列表内容 -->
  2. <template>
  3. <div class="programming-content">
  4. <!-- 标题部分 -->
  5. <div class="top-box">
  6. <!-- 返回按钮 -->
  7. <div class="top-left-box">
  8. <div class="top-left-inner-box">
  9. <!-- 左侧返回图标 -->
  10. <div class="left-content-wrapper" @click="goBackIndex">
  11. <el-icon class="left-icon"><ArrowLeftBold /></el-icon>
  12. <span class="left-text">返回</span>
  13. </div>
  14. </div>
  15. </div>
  16. <!-- 标题 -->
  17. <div class="top-center-box" v-if="!showVideo">
  18. <div class="top-center-inner-box">
  19. <span>{{ pageTitle }}</span>
  20. </div>
  21. </div>
  22. <!-- 课程提示 -->
  23. <div class="top-right-box">
  24. <div class="top-right-inner-box">
  25. <div class="course-info-box">{{ originalCourseTitle }}</div>
  26. </div>
  27. </div>
  28. </div>
  29. <!-- 课程部分 -->
  30. <div class="lower-box" v-if="!showVideo">
  31. <!-- 左切换按钮 -->
  32. <div v-if="!showVideo && courseItems.length > 3" class="carousel-btn prev-btn" @click="prevSlide" :class="{ 'disabled-btn': currentIndex === 0 }" :disabled="currentIndex === 0">
  33. <el-icon class="btn-icon" :class="{ 'disabled-icon': currentIndex === 0 }"><ArrowLeftBold /></el-icon>
  34. </div>
  35. <div
  36. class="content-box"
  37. ref="contentBox"
  38. @mousedown="handleMouseDown"
  39. @mousemove="handleMouseMove"
  40. @mouseup="handleMouseUp"
  41. @mouseleave="handleMouseLeave"
  42. @scroll="handleScroll"
  43. >
  44. <!-- 动态渲染课程内容 -->
  45. <div
  46. v-for="(item) in courseItems"
  47. :key="item.id"
  48. class="slide-item"
  49. :style="courseItems.length <= 3 ? { float: 'left' } : {}"
  50. @click="handleCourseItemClick(item)"
  51. >
  52. <div class="box-content">
  53. <img :src="item.image" :alt="item.title" class="box-image" />
  54. <div class="box-text">{{ item.title }}</div>
  55. </div>
  56. <!-- 星星图标组 -->
  57. <div class="star-group">
  58. <div
  59. v-for="i in getStarCount(item.contentType)"
  60. :key="i"
  61. class="star-icon"
  62. :style="{
  63. backgroundImage: `url(${i <= item.progress ? star01Image : star02Image})`,
  64. }"
  65. ></div>
  66. </div>
  67. </div>
  68. </div>
  69. <!-- 右切换按钮 -->
  70. <div v-if="!showVideo && courseItems.length > 3" class="carousel-btn next-btn" @click="nextSlide" :class="{ 'disabled-btn': currentIndex >= courseItems.length - 3 }" :disabled="currentIndex >= courseItems.length - 3">
  71. <el-icon class="btn-icon" :class="{ 'disabled-icon': currentIndex >= courseItems.length - 3 }"><ArrowRightBold /></el-icon>
  72. </div>
  73. </div>
  74. <!-- 视频和编程界面 -->
  75. <Interface v-if="showVideo" :courseData="selectedCourseData" :courseList="resData" @closeVideo="showVideo = false" />
  76. </div>
  77. </template>
  78. <script setup>
  79. // 返回图标
  80. import { ArrowLeftBold, ArrowRightBold } from '@element-plus/icons-vue';
  81. import { ref, onMounted, computed, watch } from 'vue';
  82. // 导入路由
  83. import { useRouter, useRoute } from 'vue-router';
  84. // 根据ID获取课程列表
  85. import { getBlocklyByTypeId } from '@/api/programming/index.js'
  86. // 导入图片
  87. import explanation from '@/assets/programming/explanation.png'
  88. import practice from '@/assets/programming/practice.png'
  89. import summary from '@/assets/programming/summary.png'
  90. import Interface from './Interface.vue'
  91. // 星星图片
  92. import star02Image from '@/assets/programming/star02.png'
  93. import star01Image from '@/assets/programming/star01.png'
  94. // 获取路由实例
  95. const router = useRouter()
  96. const route = useRoute()
  97. // 页面标题
  98. const pageTitle = ref('')
  99. // 保存原始的课程ID和标题
  100. const originalCourseId = ref('')
  101. const originalCourseTitle = ref('')
  102. // 类型ID(从ProgrammingList页面传递过来)
  103. const typeId = ref('')
  104. // 控制视频界面显示
  105. const showVideo = ref(false)
  106. // 动态课程项数据
  107. const courseItems = ref([])
  108. // 当前选中的课程数据
  109. const selectedCourseData = ref(null)
  110. // 保存原始API返回的数据
  111. const resData = ref([])
  112. // 轮播图当前索引
  113. const currentIndex = ref(0)
  114. // 拖动相关变量
  115. const isDragging = ref(false)
  116. const startX = ref(0)
  117. const scrollLeft = ref(0)
  118. const hasMoved = ref(false) // 标记是否发生了移动
  119. // 获取contentBox元素的引用
  120. const contentBox = ref(null)
  121. // 根据内容类型获取星星数量
  122. const getStarCount = (contentType) => {
  123. if (contentType === 'video') {
  124. return 1; // video类型渲染1个星星
  125. } else if (contentType === 'blockly') {
  126. return 3; // blockly类型渲染3个星星
  127. }
  128. }
  129. // 上一页
  130. const prevSlide = () => {
  131. if (currentIndex.value > 0) {
  132. currentIndex.value = Math.max(0, currentIndex.value - 3)
  133. // 滚动到对应位置
  134. if (contentBox.value) {
  135. const itemWidth = rpxValue(140) + rpxValue(70); // 宽度+间距
  136. const scrollPosition = currentIndex.value * itemWidth;
  137. contentBox.value.scrollTo({
  138. left: scrollPosition,
  139. behavior: 'smooth'
  140. });
  141. }
  142. }
  143. }
  144. // 下一页
  145. const nextSlide = () => {
  146. if (currentIndex.value < courseItems.value.length - 3) {
  147. currentIndex.value = Math.min(courseItems.value.length - 3, currentIndex.value + 3)
  148. // 滚动到对应位置
  149. if (contentBox.value) {
  150. const itemWidth = rpxValue(140) + rpxValue(70); // 宽度+间距
  151. const scrollPosition = currentIndex.value * itemWidth;
  152. contentBox.value.scrollTo({
  153. left: scrollPosition,
  154. behavior: 'smooth'
  155. });
  156. }
  157. }
  158. }
  159. // 鼠标按下事件处理函数
  160. const handleMouseDown = (e) => {
  161. // 拖拽状态为true
  162. isDragging.value = true
  163. // 初始化移动状态为false
  164. hasMoved.value = false
  165. // 计算鼠标在容器内的相对X坐标的位置 e.pageX鼠标相对于整个页面的X坐标
  166. startX.value = e.pageX - contentBox.value.offsetLeft
  167. // 记录当前容器的滚动位置
  168. scrollLeft.value = contentBox.value.scrollLeft
  169. e.stopPropagation() // 阻止事件冒泡 防止事件继续向上传播到父元素
  170. }
  171. // 鼠标移动事件处理函数
  172. const handleMouseMove = (e) => {
  173. if (!isDragging.value) return
  174. // 标记已发生移动
  175. hasMoved.value = true
  176. // 阻止默认行为和冒泡
  177. e.preventDefault()
  178. e.stopPropagation()
  179. const x = e.pageX - contentBox.value.offsetLeft
  180. const walk = (x - startX.value) * 2 // 滚动速度
  181. contentBox.value.scrollLeft = scrollLeft.value - walk
  182. }
  183. // 鼠标松开事件处理函数
  184. const handleMouseUp = () => {
  185. isDragging.value = false
  186. // 延迟重置hasMoved,确保点击事件可以正常判断
  187. setTimeout(() => {
  188. hasMoved.value = false
  189. }, 100)
  190. }
  191. // 鼠标离开事件处理函数
  192. const handleMouseLeave = () => {
  193. isDragging.value = false
  194. hasMoved.value = false
  195. }
  196. // rpx转换为像素值的辅助函数
  197. const rpxValue = (px) => {
  198. return (px / 750) * window.innerWidth;
  199. }
  200. // 滚动事件处理函数
  201. const handleScroll = () => {
  202. if (contentBox.value && !isDragging.value) {
  203. // 检查是否滚动到最右侧
  204. const { scrollLeft, scrollWidth, clientWidth } = contentBox.value;
  205. // 当滚动到最右侧时,禁用右侧箭头
  206. if (scrollLeft + clientWidth >= scrollWidth - 10) {
  207. currentIndex.value = Math.max(0, courseItems.value.length - 3);
  208. } else {
  209. // 向右滑动,左侧按钮正常显示
  210. if (scrollLeft > 10) {
  211. // 根据滚动位置计算当前索引,但不限制为0
  212. const itemWidth = rpxValue(140) + rpxValue(70); // 宽度+间距
  213. const calculatedIndex = Math.round(scrollLeft / itemWidth);
  214. currentIndex.value = Math.max(0, Math.min(courseItems.value.length - 3, calculatedIndex));
  215. } else { // 滚动到最左侧
  216. currentIndex.value = 0;
  217. }
  218. }
  219. }
  220. }
  221. // 提取课程数据获取和处理逻辑为单独函数
  222. const fetchCourseData = () => {
  223. if (typeId.value) {
  224. getBlocklyByTypeId(typeId.value).then(res => {
  225. if (res && res.data && Array.isArray(res.data)) {
  226. console.log(res);
  227. // 保存原始API返回的数据
  228. resData.value = res.data;
  229. // 创建图片映射,根据bcLabel显示对应图片
  230. const imageMap = {
  231. '1': explanation,
  232. '2': practice,
  233. '3': summary
  234. }
  235. // 清空旧的课程项
  236. courseItems.value = [];
  237. // 遍历接口返回的数据,设置课程项
  238. res.data.forEach((item, index) => {
  239. // 每个课程项都对应位置样式和图片
  240. const positionIndex = (index % 3) + 1
  241. const image = imageMap[item.bcLabel]; // 根据bcLabel获取图片
  242. courseItems.value.push({
  243. id: item.id,
  244. title: item.bcName,
  245. image: image,
  246. contentType: item.bcContentType,
  247. progress: item.progress
  248. })
  249. })
  250. }
  251. })
  252. }
  253. }
  254. // 组件挂载时获取路由参数设置标题
  255. onMounted(() => {
  256. // 检查路由参数中是否有courseTitle
  257. const courseTitle = route.query.courseTitle
  258. if (courseTitle) {
  259. // 设置页面标题
  260. pageTitle.value = courseTitle
  261. }
  262. // 获取当前类型ID
  263. typeId.value = route.query.typeId
  264. // 保存原始的课程ID和标题,返回时使用
  265. originalCourseId.value = route.query.originalCourseId
  266. originalCourseTitle.value = route.query.originalCourseTitle
  267. // 获取到topicId后,调用函数获取课程列表
  268. fetchCourseData();
  269. })
  270. // 处理课程项点击事件
  271. const handleCourseItemClick = (item) => {
  272. // 如果在拖动过程中,则不触发点击事件
  273. if (hasMoved.value) return
  274. if (item.contentType === 'video' || item.contentType === 'blockly') {
  275. showVideo.value = true
  276. // 查找并保存完整的课程数据
  277. const fullCourseData = resData.value.find(course => course.id === item.id)
  278. if (fullCourseData) {
  279. selectedCourseData.value = fullCourseData
  280. selectedCourseData.value.ztId = originalCourseId.value
  281. }
  282. }
  283. }
  284. // 返回编程课列表
  285. const goBackIndex = () => {
  286. // 隐藏视频和游戏界面
  287. showVideo.value = false
  288. // 返回时携带原始的课程参数,使用categoryId保持与ProgrammingList.vue中参数名一致
  289. router.push({
  290. path: '/programminglist',
  291. query: {
  292. categoryId: originalCourseId.value,
  293. courseTitle: originalCourseTitle.value
  294. }
  295. })
  296. }
  297. // 监听showVideo状态变化,当从true变为false时重新获取课程数据
  298. watch(showVideo, (newValue, oldValue) => {
  299. if (oldValue === true && newValue === false) {
  300. // 当视频界面关闭时,调用函数重新获取最新的课程数据
  301. fetchCourseData();
  302. }
  303. })
  304. </script>
  305. <style scoped lang="scss">
  306. @use 'sass:math';
  307. // 定义rpx转换函数
  308. @function rpx($px) {
  309. @return math.div($px, 750) * 100vw;
  310. }
  311. .programming-content{
  312. position: fixed;
  313. top: 0;
  314. left: 0;
  315. right: 0;
  316. bottom: 0;
  317. background-image: url('@/assets/programming/list_bg03.png');
  318. background-size: cover;
  319. background-position: center;
  320. background-repeat: no-repeat;
  321. display: flex;
  322. flex-direction: column;
  323. user-select: none; /* 禁止文本选择 */
  324. }
  325. .top-box {
  326. height: 20%;
  327. display: flex;
  328. }
  329. .top-left-box,
  330. .top-right-box {
  331. flex: 1;
  332. height: 50%;
  333. display: flex;
  334. align-items: center;
  335. justify-content: center;
  336. }
  337. .top-center-box {
  338. flex: 2;
  339. display: flex;
  340. align-items: center;
  341. justify-content: center;
  342. }
  343. .top-center-inner-box{
  344. width: 100%;
  345. height: 100%;
  346. background-image: url('@/assets/programming/list_title.png');
  347. background-size: 70%;
  348. background-repeat: no-repeat;
  349. background-position: center center; /* 背景图垂直水平居中 */
  350. display: flex;
  351. align-items: center; /* 垂直居中 */
  352. justify-content: center; /* 水平居中 */
  353. cursor: pointer;
  354. position: relative; /* 相对定位 */
  355. span{
  356. font-size: rpx(16);
  357. color: white;
  358. position: relative;
  359. z-index: 1; /* 确保文字在背景图上方 */
  360. display: flex;
  361. align-items: center;
  362. justify-content: center;
  363. padding-bottom: rpx(5);
  364. }
  365. }
  366. .top-left-inner-box{
  367. display: flex;
  368. align-items: center; /* 垂直居中对齐 */
  369. width: 100%;
  370. height: 100%;
  371. .left-content-wrapper{
  372. display: flex;
  373. align-items: center; /* 保持内部元素垂直居中 */
  374. }
  375. .left-icon{
  376. font-size: rpx(14);
  377. color: white;
  378. padding-left: rpx(20);
  379. cursor: pointer;
  380. }
  381. .left-text{
  382. font-size: rpx(14);
  383. color: white;
  384. padding-left: rpx(10);
  385. cursor: pointer;
  386. }
  387. }
  388. .top-right-inner-box {
  389. width: 100%;
  390. height: 100%;
  391. display: flex;
  392. justify-content: flex-end;
  393. align-items: center;
  394. padding-right: rpx(20);
  395. .course-info-box {
  396. width: rpx(60);
  397. height: rpx(20);
  398. background-color: rgb(255, 255, 255);
  399. border-radius: rpx(15);
  400. display: flex;
  401. align-items: center;
  402. justify-content: center;
  403. color: #45300b;
  404. font-size: rpx(10);
  405. cursor: pointer;
  406. }
  407. }
  408. .lower-box {
  409. flex: 1;
  410. display: flex;
  411. align-items: center;
  412. justify-content: center;
  413. width: 100%;
  414. height: 100%;
  415. }
  416. .content-box {
  417. width: 100%;
  418. min-width: rpx(660); /* 最小宽度 */
  419. height: 100%;
  420. overflow-x: auto; /* 水平滚动条 */
  421. overflow-y: hidden; /* 取消上下滚动 */
  422. white-space: nowrap; /* 防止元素换行 */
  423. scroll-behavior: smooth; /* 平滑滚动效果 */
  424. -webkit-overflow-scrolling: touch; /* 触摸设备支持 */
  425. position: relative;
  426. flex: 1;
  427. cursor: grab; /* 显示可抓取图标 */
  428. z-index: 2;
  429. padding: 0 rpx(30);
  430. /* 设置背景图 */
  431. background-image: url('@/assets/programming/track01.png');
  432. background-size: rpx(1360) rpx(350); /* 使用固定宽度,背景图大小一致 */
  433. background-position: left calc(-1 * rpx(50));
  434. background-repeat: no-repeat;
  435. background-attachment: local; /* 背景图跟内容一起滚动 */
  436. }
  437. /* 鼠标按下时的光标样式 */
  438. .content-box:active {
  439. cursor: grabbing;
  440. }
  441. /* 隐藏滚动条但保持滚动功能 */
  442. .content-box::-webkit-scrollbar {
  443. display: none;
  444. }
  445. .content-box {
  446. -ms-overflow-style: none;
  447. scrollbar-width: none;
  448. }
  449. .slide-item {
  450. width: rpx(140); /* 设置固定宽度 */
  451. height: rpx(120); /* 高度设置 */
  452. margin: rpx(80) rpx(35);
  453. border-radius: rpx(40);
  454. background-color: rgba(255, 255, 255);
  455. display: inline-flex;
  456. align-items: center;
  457. justify-content: center;
  458. box-shadow: 0 rpx(5) rpx(10) rgba(0, 0, 0, 0.2);
  459. transition: transform 0.3s ease;
  460. cursor: pointer;
  461. z-index: 2; /* 内容在背景图上方 */
  462. vertical-align: middle;
  463. position: relative;
  464. }
  465. /* 奇数项在上层 */
  466. .slide-item:nth-child(odd) {
  467. transform: translateY(-50%);
  468. }
  469. /* 偶数项在下层 */
  470. .slide-item:nth-child(even) {
  471. transform: translateY(50%);
  472. }
  473. /* 鼠标悬停放大效果 - 在保持原有垂直位置的基础上放大 */
  474. .slide-item:nth-child(odd):hover {
  475. transform: translateY(-50%) scale(1.1);
  476. z-index: 10;
  477. }
  478. .slide-item:nth-child(even):hover {
  479. transform: translateY(50%) scale(1.1);
  480. z-index: 10;
  481. }
  482. /* 内容样式 */
  483. .box-content {
  484. display: flex;
  485. flex-direction: column;
  486. align-items: center;
  487. justify-content: center;
  488. height: 100%;
  489. width: 100%;
  490. }
  491. /* 鼠标按下时的光标样式 */
  492. .slide-item:active {
  493. cursor: grabbing;
  494. }
  495. .box-image {
  496. width: 100%;
  497. height: 90%;
  498. object-fit: contain;
  499. }
  500. .box-text {
  501. width: 100%;
  502. height: 20%;
  503. line-height: 20%;
  504. font-size: rpx(13);
  505. color: #333;
  506. text-align: center;
  507. }
  508. /* 星星图标样式 */
  509. .star-group {
  510. position: absolute;
  511. top: 100%;
  512. width: 100%;
  513. display: flex;
  514. justify-content: center;
  515. align-items: center;
  516. z-index: 3;
  517. }
  518. .star-icon {
  519. position: relative;
  520. width: rpx(23);
  521. height: rpx(23);
  522. background-size: contain;
  523. background-repeat: no-repeat;
  524. background-position: center;
  525. margin: 0 rpx(0);
  526. }
  527. /* 轮播图按钮样式 */
  528. .carousel-btn {
  529. position: absolute;
  530. top: 50%;
  531. transform: translateY(-50%);
  532. width: rpx(30);
  533. height: rpx(30);
  534. background-color: rgba(255, 255, 255, 0.8);
  535. border-radius: 50%;
  536. display: flex;
  537. align-items: center;
  538. justify-content: center;
  539. cursor: pointer;
  540. z-index: 100; /* 提高按钮层级确保始终在最上层 */
  541. box-shadow: 0 rpx(5) rpx(10) rgba(0, 0, 0, 0.2);
  542. transition: all 0.3s ease;
  543. pointer-events: all;
  544. }
  545. .carousel-btn:hover:not(.disabled-btn) {
  546. background-color: rgba(255, 255, 255, 1);
  547. transform: translateY(-50%) scale(1.1);
  548. }
  549. .carousel-btn:disabled,
  550. .disabled-btn {
  551. opacity: 0.5;
  552. cursor: not-allowed;
  553. background-color: rgba(200, 200, 200, 0.7);
  554. box-shadow: none;
  555. }
  556. .disabled-icon {
  557. color: #ccc !important;
  558. filter: grayscale(100%);
  559. opacity: 0.6;
  560. }
  561. .prev-btn {
  562. left: 1%;
  563. }
  564. .next-btn {
  565. right: 1%;
  566. }
  567. .btn-icon {
  568. font-size: rpx(15);
  569. color: #333;
  570. }
  571. </style>