四零语境前端代码仓库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1396 lines
36 KiB

  1. <template>
  2. <view class="book-container">
  3. <!-- 自定义顶部导航栏 -->
  4. <uv-status-bar></uv-status-bar>
  5. <view class="custom-navbar" :class="{ 'navbar-hidden': !showNavbar }">
  6. <uv-status-bar></uv-status-bar>
  7. <view class="navbar-content">
  8. <view class="navbar-left" @click="goBack">
  9. <uv-icon name="arrow-left" size="20" color="#262626"></uv-icon>
  10. </view>
  11. <view class="navbar-title">{{ currentPageTitle }}</view>
  12. </view>
  13. </view>
  14. <!-- Swiper内容区域 -->
  15. <swiper
  16. class="content-swiper"
  17. :current="currentPage - 1"
  18. @change="onSwiperChange"
  19. >
  20. <swiper-item
  21. v-for="(page, index) in bookPages"
  22. :key="index"
  23. class="swiper-item"
  24. >
  25. <view class="content-area" @click="toggleNavbar">
  26. <!-- 图片卡片页面 -->
  27. <view v-for="(item, index) in page" :key="index">
  28. <view v-if="item.type === 'card'" class="card-content">
  29. <view class="card-line">
  30. <image src="/static/划重点图标.png" class="card-line-image" mode="aspectFill" />
  31. <text class="card-line-text">划线重点</text>
  32. </view>
  33. <image class="card-image" :src="item.image" mode="aspectFill"></image>
  34. <text class="english-text" user-select @longpress="showWordMeaning(item.wordMeaning)">{{ item.englishText }}</text>
  35. <text class="chinese-text" user-select>{{ item.chineseText }}</text>
  36. </view>
  37. <!-- 文本页面 -->
  38. <view v-else-if="item.type === 'text'" class="text-content">
  39. <text class="content-text" user-select>{{ item.content }}</text>
  40. </view>
  41. <!-- 文本页面 -->
  42. <view v-else-if="item.type === 'image'" class="image-container">
  43. <image class="content-image" :src="item.imageUrl" mode="aspectFill"></image>
  44. </view>
  45. <!-- 视频页面 -->
  46. <view v-else-if="item.type === 'video'" class="video-content">
  47. <video :src="item.video" class="video-player" controls :poster="item.poster"></video>
  48. </view>
  49. <!-- 会员限制页面 -->
  50. <view v-else-if="item.type === 'member'" class="member-content">
  51. <text class="member-title">{{ item.title }}</text>
  52. <view class="member-button" @click="unlockBook">
  53. <text class="member-button-text">{{ item.buttonText }}</text>
  54. </view>
  55. </view>
  56. </view>
  57. </view>
  58. </swiper-item>
  59. </swiper>
  60. <!-- 自定义底部控制栏 -->
  61. <view class="custom-tabbar" :class="{ 'tabbar-hidden': !showNavbar }">
  62. <!-- 音频控制栏 -->
  63. <view class="audio-controls" :class="{ 'audio-hidden': !isTextPage }">
  64. <view class="audio-time">
  65. <text class="time-text">{{ formatTime(currentTime) }}</text>
  66. <view class="progress-container">
  67. <view class="progress-bar">
  68. <view class="progress-fill" :style="{ width: progressPercent + '%' }"></view>
  69. <view class="progress-thumb" :style="{ left: progressPercent + '%' }"></view>
  70. </view>
  71. </view>
  72. <text class="time-text">{{ formatTime(totalTime) }}</text>
  73. </view>
  74. <view class="audio-controls-row">
  75. <view class="control-btn" @click="toggleLoop">
  76. <uv-icon name="reload" size="20" :color="isLoop ? '#06DADC' : '#999'"></uv-icon>
  77. <text class="control-text">循环</text>
  78. </view>
  79. <view class="control-btn" @click="previousPage">
  80. <text class="control-text">上一页</text>
  81. </view>
  82. <view class="play-btn" @click="togglePlay">
  83. <uv-icon :name="isPlaying ? 'pause-circle-fill' : 'play-circle-fill'" size="40" color="#666"></uv-icon>
  84. </view>
  85. <view class="control-btn" @click="nextPage">
  86. <text class="control-text">下一页</text>
  87. </view>
  88. <view class="control-btn" @click="toggleSpeed">
  89. <text class="control-text">{{ playSpeed }}x</text>
  90. </view>
  91. </view>
  92. </view>
  93. <view style="background-color: #fff;position: relative;z-index: 100" >
  94. <view class="tabbar-content">
  95. <view class="tabbar-left">
  96. <view class="tab-button" @click="toggleCoursePopup">
  97. <image src="/static/课程图标.png" class="tab-icon" />
  98. <text class="tab-text">课程</text>
  99. </view>
  100. <view class="tab-button" @click="toggleSound">
  101. <image src="/static/音色切换图标.png" class="tab-icon" />
  102. <text class="tab-text">音色切换</text>
  103. </view>
  104. </view>
  105. <view class="tabbar-right">
  106. <view class="page-controls">
  107. <view class="page-numbers">
  108. <view
  109. v-for="(page, index) in bookPages"
  110. :key="index"
  111. class="page-number"
  112. :class="{ 'active': (index + 1) === currentPage }"
  113. @click="goToPage(index + 1)"
  114. >
  115. {{ index + 1 }}
  116. </view>
  117. </view>
  118. </view>
  119. </view>
  120. </view>
  121. <uv-safe-bottom></uv-safe-bottom>
  122. </view>
  123. </view>
  124. <!-- 课程选择弹出窗 -->
  125. <uv-popup
  126. mode="bottom"
  127. ref="coursePopup"
  128. round="32rpx"
  129. bg-color="#f8f8f8"
  130. >
  131. <view class="course-popup">
  132. <view class="popup-header">
  133. <view>
  134. <uv-icon name="arrow-down" color="black" size="20"></uv-icon>
  135. </view>
  136. <view class="popup-title">课程</view>
  137. <view class="popup-title" @click="toggleSort">
  138. 倒序
  139. </view>
  140. </view>
  141. <view class="course-list">
  142. <view
  143. v-for="(course, index) in displayCourseList"
  144. :key="course.id"
  145. class="course-item"
  146. :class="{ 'active': course.id === currentCourse }"
  147. @click="selectCourse(course.id)"
  148. >
  149. <view class="course-number " :class="{ 'highlight': course.id === currentCourse }">{{ String(course.index).padStart(2, '0') }}</view>
  150. <view class="course-content">
  151. <view class="course-english" :class="{ 'highlight': course.id === currentCourse }">{{ course.english }}</view>
  152. <view class="course-chinese" :class="{ 'highlight': course.id === currentCourse }">{{ course.chinese }}</view>
  153. </view>
  154. </view>
  155. </view>
  156. </view>
  157. </uv-popup>
  158. <!-- 释义弹出窗 -->
  159. <uv-popup
  160. mode="bottom"
  161. ref="meaningPopup"
  162. round="32rpx"
  163. bg-color="#FFFFFF"
  164. :overlay="true"
  165. >
  166. <view class="meaning-popup" v-if="currentWordMeaning">
  167. <view class="meaning-header">
  168. <view class="close-btn" @click="closeMeaningPopup">
  169. <text class="close-text">关闭</text>
  170. </view>
  171. <view class="meaning-title">释义</view>
  172. <view style="width: 80rpx;"></view>
  173. </view>
  174. <view class="meaning-content">
  175. <image class="meaning-image" src="/static/默认图片.png" mode="aspectFill"></image>
  176. <view class="word-info">
  177. <view class="word-main">
  178. <text class="word-text">{{ currentWordMeaning.word }}</text>
  179. </view>
  180. <view class="phonetic-container">
  181. <uv-icon name="volume-fill" size="16" color="#000"></uv-icon>
  182. <text class="phonetic-text">{{ currentWordMeaning.phonetic }}</text>
  183. </view>
  184. <view class="word-meaning">
  185. <text class="part-of-speech">{{ currentWordMeaning.partOfSpeech }}</text>
  186. <text class="meaning-text">{{ currentWordMeaning.meaning }}</text>
  187. </view>
  188. </view>
  189. <view class="knowledge-gain">
  190. <view class="knowledge-header">
  191. <image src="/static/知识收获图标.png" class="knowledge-icon" mode="aspectFill" />
  192. <text class="knowledge-title">知识收获</text>
  193. </view>
  194. <text class="knowledge-content">{{ currentWordMeaning.knowledgeGain }}</text>
  195. </view>
  196. </view>
  197. </view>
  198. </uv-popup>
  199. </view>
  200. </template>
  201. <script>
  202. export default {
  203. data() {
  204. return {
  205. voiceId: '',
  206. courseId: '',
  207. showNavbar: true,
  208. currentPage: 1,
  209. currentCourse: 1, // 当前课程索引
  210. currentWordMeaning: null, // 当前显示的单词释义
  211. isReversed: false, // 是否倒序显示
  212. // 音频控制相关数据
  213. isPlaying: false,
  214. currentTime: 0,
  215. totalTime: 0,
  216. isLoop: false,
  217. playSpeed: 1.0,
  218. speedOptions: [1.0, 1.25, 1.5, 2.0],
  219. // 音频数组管理
  220. currentPageAudios: [], // 当前页面的音频数组
  221. currentAudioIndex: 0, // 当前播放的音频索引
  222. audioContext: null, // 音频上下文
  223. currentAudio: null, // 当前音频实例
  224. // 音频缓存管理
  225. audioCache: {}, // 页面音频缓存 {pageIndex: {audios: [], totalDuration: 0}}
  226. courseIdList: [],
  227. bookTitle: '',
  228. courseList: [
  229. ],
  230. // 二维数组 代表每个页面
  231. bookPages: [
  232. ],
  233. // 存储每个页面的标题
  234. pageTitles: [],
  235. }
  236. },
  237. computed: {
  238. displayCourseList() {
  239. return this.isReversed ? [...this.courseList].reverse() : this.courseList;
  240. },
  241. // 判断当前页面是否为文字类型
  242. isTextPage() {
  243. const currentPageData = this.bookPages[this.currentPage - 1];
  244. // currentPageData是一个数组 其中的一个元素的type是text就会返回true
  245. return currentPageData && currentPageData.some(item => item.type === 'text');
  246. },
  247. // 计算音频播放进度百分比
  248. progressPercent() {
  249. return this.totalTime > 0 ? (this.currentTime / this.totalTime) * 100 : 0;
  250. },
  251. // 动态页面标题
  252. currentPageTitle() {
  253. return this.pageTitles[this.currentPage - 1] || this.bookTitle;
  254. }
  255. },
  256. methods: {
  257. // 获取当前页面的音频内容
  258. async getCurrentPageAudio() {
  259. // 检查缓存中是否已有当前页面的音频数据
  260. const cacheKey = `${this.courseId}_${this.currentPage}`;
  261. if (this.audioCache[cacheKey]) {
  262. console.log('从缓存加载音频数据:', cacheKey);
  263. // 从缓存加载音频数据
  264. this.currentPageAudios = this.audioCache[cacheKey].audios;
  265. this.totalTime = this.audioCache[cacheKey].totalDuration;
  266. this.currentAudioIndex = 0;
  267. this.isPlaying = false;
  268. this.currentTime = 0;
  269. return;
  270. }
  271. // 清空当前页面音频数组
  272. this.currentPageAudios = [];
  273. this.currentAudioIndex = 0;
  274. this.isPlaying = false;
  275. this.currentTime = 0;
  276. this.totalTime = 0;
  277. // 对着当前页面的每一个[]元素进行切割 如果是文本text类型则进行音频请求
  278. const currentPageData = this.bookPages[this.currentPage - 1];
  279. if (currentPageData) {
  280. // 收集所有text类型的元素
  281. const textItems = currentPageData.filter(item => item.type === 'text');
  282. if (textItems.length > 0) {
  283. // 并行发送所有音频请求
  284. const audioPromises = textItems.map(async (item, index) => {
  285. try {
  286. // 进行音频请求 - 修正字段名:使用content而不是text
  287. const radioRes = await this.$api.music.textToVoice({
  288. text: item.content,
  289. voiceId: this.voiceId,
  290. });
  291. console.log(`音频请求响应 ${index + 1}:`, radioRes);
  292. if(radioRes.code === 200){
  293. // API返回的是Base64编码的WAV音频数据,在uniapp环境中需要特殊处理
  294. const base64Data = radioRes.result;
  295. // 在uniapp中,可以直接使用base64数据作为音频源
  296. const audioUrl = `data:audio/wav;base64,${base64Data}`;
  297. // 同时保存到原始数据中以保持兼容性
  298. item.audioUrl = audioUrl;
  299. console.log(`音频URL设置成功 ${index + 1}:`, audioUrl);
  300. return {
  301. url: audioUrl,
  302. text: item.content,
  303. duration: 0, // 音频时长,需要在加载后获取
  304. index: index // 保持原始顺序
  305. };
  306. } else {
  307. console.error(`音频请求失败 ${index + 1}:`, radioRes);
  308. return null;
  309. }
  310. } catch (error) {
  311. console.error(`音频请求异常 ${index + 1}:`, error);
  312. return null;
  313. }
  314. });
  315. // 等待所有音频请求完成
  316. const audioResults = await Promise.all(audioPromises);
  317. // 按原始顺序添加到音频数组中,过滤掉失败的请求
  318. this.currentPageAudios = audioResults
  319. .filter(result => result !== null)
  320. .sort((a, b) => a.index - b.index)
  321. .map(result => ({
  322. url: result.url,
  323. text: result.text,
  324. duration: result.duration
  325. }));
  326. console.log('所有音频请求完成,共获取', this.currentPageAudios.length, '个音频');
  327. }
  328. // 如果有音频,计算总时长
  329. if (this.currentPageAudios.length > 0) {
  330. await this.calculateTotalDuration();
  331. // 将音频数据保存到缓存中
  332. const cacheKey = `${this.courseId}_${this.currentPage}`;
  333. this.audioCache[cacheKey] = {
  334. audios: [...this.currentPageAudios], // 深拷贝音频数组
  335. totalDuration: this.totalTime
  336. };
  337. console.log('音频数据已缓存:', cacheKey, this.audioCache[cacheKey]);
  338. // 限制缓存大小
  339. this.limitCacheSize(10);
  340. }
  341. }
  342. },
  343. // 获取音色列表 拿第一个做默认的音色id
  344. async getVoiceList() {
  345. const voiceRes = await this.$api.music.list()
  346. if(voiceRes.code === 200){
  347. this.voiceId = voiceRes.result.id
  348. console.log('111');
  349. await this.getCurrentPageAudio()
  350. }
  351. },
  352. toggleNavbar() {
  353. this.showNavbar = !this.showNavbar
  354. },
  355. goBack() {
  356. uni.navigateBack()
  357. },
  358. toggleCoursePopup() {
  359. if (this.$refs.coursePopup) {
  360. this.$refs.coursePopup.open()
  361. }
  362. // console.log('123123123');
  363. },
  364. toggleSort() {
  365. this.isReversed = !this.isReversed
  366. },
  367. selectCourse(courseId) {
  368. this.currentCourse = courseId
  369. if (this.$refs.coursePopup) {
  370. this.$refs.coursePopup.close()
  371. }
  372. // 这里可以添加切换课程的逻辑
  373. // console.log('选择课程:', courseId)
  374. this.getCourseList(courseId)
  375. },
  376. showWordMeaning(wordMeaning) {
  377. if (wordMeaning) {
  378. this.currentWordMeaning = wordMeaning
  379. if (this.$refs.meaningPopup) {
  380. this.$refs.meaningPopup.open()
  381. }
  382. }
  383. },
  384. closeMeaningPopup() {
  385. if (this.$refs.meaningPopup) {
  386. this.$refs.meaningPopup.close()
  387. }
  388. this.currentWordMeaning = null
  389. },
  390. // 计算音频总时长
  391. async calculateTotalDuration() {
  392. let totalDuration = 0;
  393. for (let i = 0; i < this.currentPageAudios.length; i++) {
  394. const audio = this.currentPageAudios[i];
  395. try {
  396. const duration = await this.getAudioDuration(audio.url);
  397. audio.duration = duration;
  398. totalDuration += duration;
  399. } catch (error) {
  400. console.error('获取音频时长失败:', error);
  401. // 如果无法获取时长,估算一个默认值(假设每100字符约5秒)
  402. const estimatedDuration = Math.max(5, audio.text.length / 20);
  403. audio.duration = estimatedDuration;
  404. totalDuration += estimatedDuration;
  405. }
  406. }
  407. this.totalTime = totalDuration;
  408. console.log('音频总时长:', totalDuration, '秒');
  409. },
  410. // 获取音频时长
  411. getAudioDuration(audioUrl) {
  412. return new Promise((resolve, reject) => {
  413. const audio = uni.createInnerAudioContext();
  414. audio.src = audioUrl;
  415. audio.onCanplay(() => {
  416. resolve(audio.duration || 5); // 如果无法获取时长,默认5秒
  417. audio.destroy();
  418. });
  419. audio.onError((error) => {
  420. console.error('音频加载失败:', error);
  421. reject(error);
  422. audio.destroy();
  423. });
  424. // 设置超时
  425. setTimeout(() => {
  426. reject(new Error('获取音频时长超时'));
  427. audio.destroy();
  428. }, 3000);
  429. });
  430. },
  431. // 音频控制方法
  432. togglePlay() {
  433. if (this.currentPageAudios.length === 0) {
  434. uni.showToast({
  435. title: '当前页面没有音频内容',
  436. icon: 'none'
  437. });
  438. return;
  439. }
  440. if (this.isPlaying) {
  441. this.pauseAudio();
  442. } else {
  443. this.playAudio();
  444. }
  445. },
  446. // 播放音频
  447. playAudio() {
  448. if (this.currentPageAudios.length === 0) return;
  449. // 如果没有当前音频实例或者需要切换音频
  450. if (!this.currentAudio || this.currentAudio.src !== this.currentPageAudios[this.currentAudioIndex].url) {
  451. this.createAudioInstance();
  452. }
  453. this.currentAudio.play();
  454. this.isPlaying = true;
  455. },
  456. // 暂停音频
  457. pauseAudio() {
  458. if (this.currentAudio) {
  459. this.currentAudio.pause();
  460. }
  461. this.isPlaying = false;
  462. },
  463. // 创建音频实例
  464. createAudioInstance() {
  465. // 销毁之前的音频实例
  466. if (this.currentAudio) {
  467. this.currentAudio.destroy();
  468. }
  469. const audio = uni.createInnerAudioContext();
  470. audio.src = this.currentPageAudios[this.currentAudioIndex].url;
  471. audio.playbackRate = this.playSpeed;
  472. // 音频事件监听
  473. audio.onPlay(() => {
  474. console.log('音频开始播放');
  475. this.isPlaying = true;
  476. });
  477. audio.onPause(() => {
  478. console.log('音频暂停');
  479. this.isPlaying = false;
  480. });
  481. audio.onTimeUpdate(() => {
  482. this.updateCurrentTime();
  483. });
  484. audio.onEnded(() => {
  485. console.log('当前音频播放结束');
  486. this.onAudioEnded();
  487. });
  488. audio.onError((error) => {
  489. console.error('音频播放错误:', error);
  490. this.isPlaying = false;
  491. uni.showToast({
  492. title: '音频播放失败',
  493. icon: 'none'
  494. });
  495. });
  496. this.currentAudio = audio;
  497. },
  498. // 更新当前播放时间
  499. updateCurrentTime() {
  500. if (!this.currentAudio) return;
  501. let totalTime = 0;
  502. // 计算之前音频的总时长
  503. for (let i = 0; i < this.currentAudioIndex; i++) {
  504. totalTime += this.currentPageAudios[i].duration;
  505. }
  506. // 加上当前音频的播放时间
  507. totalTime += this.currentAudio.currentTime;
  508. this.currentTime = totalTime;
  509. },
  510. // 音频播放结束处理
  511. onAudioEnded() {
  512. if (this.currentAudioIndex < this.currentPageAudios.length - 1) {
  513. // 播放下一个音频
  514. this.currentAudioIndex++;
  515. this.playAudio();
  516. } else {
  517. // 所有音频播放完毕
  518. if (this.isLoop) {
  519. // 循环播放
  520. this.currentAudioIndex = 0;
  521. this.playAudio();
  522. } else {
  523. // 停止播放
  524. this.isPlaying = false;
  525. this.currentTime = this.totalTime;
  526. }
  527. }
  528. },
  529. toggleLoop() {
  530. this.isLoop = !this.isLoop;
  531. },
  532. toggleSpeed() {
  533. const currentIndex = this.speedOptions.indexOf(this.playSpeed);
  534. const nextIndex = (currentIndex + 1) % this.speedOptions.length;
  535. this.playSpeed = this.speedOptions[nextIndex];
  536. // 如果当前有音频在播放,更新播放速度
  537. if (this.currentAudio) {
  538. this.currentAudio.playbackRate = this.playSpeed;
  539. }
  540. },
  541. // 上一个音频
  542. previousAudio() {
  543. if (this.currentPageAudios.length === 0) return;
  544. if (this.currentAudioIndex > 0) {
  545. this.currentAudioIndex--;
  546. if (this.isPlaying) {
  547. this.playAudio();
  548. }
  549. }
  550. },
  551. // 下一个音频
  552. nextAudio() {
  553. if (this.currentPageAudios.length === 0) return;
  554. if (this.currentAudioIndex < this.currentPageAudios.length - 1) {
  555. this.currentAudioIndex++;
  556. if (this.isPlaying) {
  557. this.playAudio();
  558. }
  559. }
  560. },
  561. async previousPage() {
  562. if (this.currentPage > 1) {
  563. // 停止当前音频播放
  564. this.pauseAudio();
  565. this.currentPage--;
  566. // 获取对应页面的数据(如果还没有获取过)
  567. if (this.courseIdList[this.currentPage - 1] && this.bookPages[this.currentPage - 1].length === 0) {
  568. await this.getBookPages(this.courseIdList[this.currentPage - 1]);
  569. }
  570. // 重新获取当前页面的音频
  571. await this.getCurrentPageAudio();
  572. }
  573. },
  574. async nextPage() {
  575. if (this.currentPage < this.bookPages.length) {
  576. // 停止当前音频播放
  577. this.pauseAudio();
  578. this.currentPage++;
  579. // 获取对应页面的数据(如果还没有获取过)
  580. if (this.courseIdList[this.currentPage - 1] && this.bookPages[this.currentPage - 1].length === 0) {
  581. await this.getBookPages(this.courseIdList[this.currentPage - 1]);
  582. }
  583. // 重新获取当前页面的音频
  584. await this.getCurrentPageAudio();
  585. }
  586. },
  587. formatTime(seconds) {
  588. const mins = Math.floor(seconds / 60);
  589. const secs = Math.floor(seconds % 60);
  590. return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
  591. },
  592. toggleSound() {
  593. console.log('音色切换')
  594. uni.navigateTo({
  595. url: '/subPages/home/music'
  596. })
  597. },
  598. unlockBook() {
  599. console.log('解锁全书')
  600. // 这里可以跳转到会员页面或者调用解锁接口
  601. uni.navigateTo({
  602. url: '/pages/index/member'
  603. })
  604. },
  605. async goToPage(page) {
  606. // 停止当前音频播放
  607. this.pauseAudio();
  608. this.currentPage = page
  609. console.log('跳转到页面:', page)
  610. // 获取对应页面的数据(如果还没有获取过)
  611. if (this.courseIdList[this.currentPage - 1] && this.bookPages[this.currentPage - 1].length === 0) {
  612. await this.getBookPages(this.courseIdList[this.currentPage - 1]);
  613. }
  614. // 重新获取当前页面的音频
  615. await this.getCurrentPageAudio();
  616. },
  617. async onSwiperChange(e) {
  618. // 停止当前音频播放
  619. this.pauseAudio();
  620. this.currentPage = e.detail.current + 1
  621. // 获取对应页面的数据(如果还没有获取过)
  622. if (this.courseIdList[this.currentPage - 1] && this.bookPages[this.currentPage - 1].length === 0) {
  623. await this.getBookPages(this.courseIdList[this.currentPage - 1]);
  624. }
  625. // 重新获取当前页面的音频
  626. await this.getCurrentPageAudio();
  627. },
  628. async getCourseList(id) {
  629. const res = await this.$api.book.coursePage({
  630. id: id
  631. })
  632. if (res.code === 200) {
  633. this.courseIdList = res.result.map(item => item.id)
  634. // 初始化二维数组 换一种方式
  635. this.bookPages = this.courseIdList.map(() => [])
  636. // 初始化标题数组
  637. this.pageTitles = this.courseIdList.map(() => '')
  638. // 初始化第一页
  639. if (this.courseIdList.length > 0) {
  640. await this.getBookPages(this.courseIdList[0])
  641. }
  642. }
  643. },
  644. async getBookPages(id) {
  645. const res = await this.$api.book.coursesPageDetail({
  646. id: id
  647. })
  648. if (res.code === 200) {
  649. // 使用$set确保响应式更新
  650. // 确保当前页面存在
  651. if (this.currentPage - 1 < this.bookPages.length) {
  652. this.$set(this.bookPages, this.currentPage - 1, JSON.parse(res.result.content))
  653. // 保存页面标题
  654. this.$set(this.pageTitles, this.currentPage - 1, res.result.title || '')
  655. }
  656. }
  657. },
  658. // 获取课程列表
  659. async getCoursePageList (bookId) {
  660. const res = await this.$api.book.course({
  661. id: bookId
  662. })
  663. if (res.code === 200) {
  664. this.courseList = res.result.records
  665. // 打上序列号
  666. this.courseList = this.courseList.map((item, index) => ({
  667. ...item,
  668. index,
  669. }))
  670. }
  671. }
  672. },
  673. async onLoad(args) {
  674. this.courseId = args.courseId
  675. // 先获取点进来的课程的页面列表
  676. await Promise.all([this.getCourseList(this.courseId), this.getCoursePageList(args.bookId)])
  677. await this.getVoiceList()
  678. },
  679. // 页面卸载时清理音频资源
  680. onUnload() {
  681. if (this.currentAudio) {
  682. this.currentAudio.destroy();
  683. this.currentAudio = null;
  684. }
  685. this.isPlaying = false;
  686. // 清理音频缓存
  687. this.clearAudioCache();
  688. },
  689. // 页面隐藏时暂停音频
  690. onHide() {
  691. this.pauseAudio();
  692. },
  693. // 清理音频缓存
  694. clearAudioCache() {
  695. this.audioCache = {};
  696. console.log('音频缓存已清理');
  697. },
  698. // 限制缓存大小,保留最近访问的页面
  699. limitCacheSize(maxSize = 10) {
  700. const cacheKeys = Object.keys(this.audioCache);
  701. if (cacheKeys.length > maxSize) {
  702. // 删除最旧的缓存项
  703. const keysToDelete = cacheKeys.slice(0, cacheKeys.length - maxSize);
  704. keysToDelete.forEach(key => {
  705. delete this.audioCache[key];
  706. });
  707. console.log('缓存大小已限制,删除了', keysToDelete.length, '个缓存项');
  708. }
  709. }
  710. }
  711. </script>
  712. <style lang="scss" scoped>
  713. .book-container {
  714. width: 100%;
  715. height: 100vh;
  716. background-color: #F8F8F8;
  717. position: relative;
  718. overflow: hidden;
  719. }
  720. .custom-navbar {
  721. position: fixed;
  722. top: 0;
  723. left: 0;
  724. right: 0;
  725. background-color: #F8F8F8;
  726. z-index: 1000;
  727. transition: transform 0.3s ease;
  728. &.navbar-hidden {
  729. transform: translateY(-100%);
  730. }
  731. }
  732. .navbar-content {
  733. display: flex;
  734. align-items: center;
  735. justify-content: space-between;
  736. padding: 20rpx 32rpx;
  737. // padding-top: calc(20rpx + var(--status-bar-height, 0));
  738. height: 60rpx;
  739. }
  740. .navbar-left,
  741. .navbar-right {
  742. width: 80rpx;
  743. display: flex;
  744. align-items: center;
  745. }
  746. .navbar-right {
  747. justify-content: flex-end;
  748. flex: 1;
  749. }
  750. .navbar-title {
  751. transform: translateX(-50rpx);
  752. flex: 1;
  753. text-align: center;
  754. font-family: PingFang SC;
  755. font-weight: 500;
  756. font-size: 32rpx;
  757. color: #262626;
  758. line-height: 48rpx;
  759. }
  760. .content-swiper {
  761. flex: 1;
  762. height: calc(100vh - 100rpx);
  763. // margin-top: 100rpx;
  764. margin-bottom: 100rpx;
  765. }
  766. .swiper-item {
  767. height: 100%;
  768. }
  769. .content-area {
  770. flex: 1;
  771. padding: 0 40rpx;
  772. padding-top: 100rpx;
  773. // background: linear-gradient(180deg, #DEFFFF 0%, #FBFEFF 22.65%, #F0FBFF 100%);
  774. height: 100%;
  775. box-sizing: border-box;
  776. overflow-y: auto;
  777. }
  778. .card-content {
  779. background: linear-gradient(180deg, #DEFFFF 0%, #FBFEFF 22.65%, #F0FBFF 100%);
  780. display: flex;
  781. flex-direction: column;
  782. gap: 32rpx;
  783. height: 1172rpx;
  784. margin-top: 20rpx;
  785. border-radius: 32rpx;
  786. // height: 100%;
  787. padding: 40rpx;
  788. // margin: 0
  789. border: 1px solid #FFFFFF;
  790. box-sizing: border-box;
  791. .card-line {
  792. display: flex;
  793. align-items: center;
  794. // margin-bottom: 20rpx;
  795. }
  796. .card-line-image {
  797. width: 48rpx;
  798. height: 48rpx;
  799. margin-right: 16rpx;
  800. }
  801. .card-line-text {
  802. font-family: PingFang SC;
  803. font-weight: 600;
  804. font-size: 30rpx;
  805. line-height: 48rpx;
  806. color: #3B3D3D;
  807. }
  808. .card-image {
  809. width: 590rpx;
  810. height: 268rpx;
  811. border-radius: 24rpx;
  812. // margin-bottom: 20rpx;
  813. }
  814. .english-text {
  815. display: block;
  816. font-family: PingFang SC;
  817. font-weight: 600;
  818. font-size: 32rpx;
  819. line-height: 48rpx;
  820. color: #3B3D3D;
  821. // margin-bottom: 16rpx;
  822. }
  823. .chinese-text {
  824. display: block;
  825. font-family: PingFang SC;
  826. font-weight: 400;
  827. font-size: 28rpx;
  828. line-height: 48rpx;
  829. color: #4F4F4F;
  830. }
  831. }
  832. /* 会员限制页面样式 */
  833. .member-content {
  834. display: flex;
  835. flex-direction: column;
  836. align-items: center;
  837. justify-content: center;
  838. height: 90%;
  839. background-color: #F8F8F8;
  840. padding: 40rpx;
  841. margin: -40rpx;
  842. box-sizing: border-box;
  843. }
  844. .member-title {
  845. font-family: PingFang SC;
  846. font-weight: 500;
  847. font-size: 40rpx;
  848. line-height: 1;
  849. color: #6f6f6f;
  850. text-align: center;
  851. margin-bottom: 48rpx;
  852. }
  853. .member-button {
  854. width: 670rpx;
  855. height: 72rpx;
  856. background: #06DADC;
  857. border-radius: 200rpx;
  858. display: flex;
  859. align-items: center;
  860. justify-content: center;
  861. }
  862. .member-button-text {
  863. font-family: PingFang SC;
  864. font-weight: 400;
  865. font-size: 30rpx;
  866. color: #FFFFFF;
  867. }
  868. .video-content {
  869. width: 100vw;
  870. margin: 200rpx -40rpx 0;
  871. height: 500rpx;
  872. background-color: #FFFFFF;
  873. // padding: 40rpx;
  874. border-radius: 24rpx;
  875. display: flex;
  876. align-items: center;
  877. justify-content: center;
  878. .video-player{
  879. width: 670rpx;
  880. margin: 0 auto;
  881. height: 376rpx;
  882. }
  883. }
  884. .text-content {
  885. width: 100vw;
  886. background-color: #F6F6F6;
  887. height: 100%;
  888. padding: 40rpx;
  889. margin: -40rpx;
  890. box-sizing: border-box;
  891. }
  892. .content-text {
  893. font-family: PingFang SC;
  894. // font-weight: 400;
  895. font-size: 28rpx;
  896. color: #3B3D3D;
  897. line-height: 48rpx;
  898. letter-spacing: 0;
  899. text-align: justify;
  900. word-break: break-all;
  901. }
  902. .custom-tabbar {
  903. position: fixed;
  904. bottom: 0;
  905. left: 0;
  906. right: 0;
  907. // background-color: #fff;
  908. border-top: 1rpx solid #EEEEEE;
  909. z-index: 1000;
  910. transition: transform 0.3s ease;
  911. &.tabbar-hidden {
  912. transform: translateY(100%);
  913. }
  914. }
  915. .tabbar-content {
  916. display: flex;
  917. align-items: center;
  918. justify-content: space-between;
  919. padding: 24rpx 62rpx;
  920. // z-index: 100;
  921. // position: relative;
  922. // background-color: #fff;
  923. // padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
  924. height: 88rpx;
  925. }
  926. .tabbar-left {
  927. display: flex;
  928. align-items: center;
  929. gap: 35rpx;
  930. }
  931. .tab-button {
  932. display: flex;
  933. align-items: center;
  934. flex-direction: column;
  935. gap: 8rpx;
  936. }
  937. .tab-icon {
  938. width: 52rpx;
  939. height: 52rpx;
  940. }
  941. .tab-text {
  942. font-family: PingFang SC;
  943. // font-weight: 400;
  944. font-size: 22rpx;
  945. color: #999;
  946. line-height: 24rpx;
  947. }
  948. .tabbar-right {
  949. flex: 1;
  950. display: flex;
  951. justify-content: flex-end;
  952. }
  953. .page-controls {
  954. display: flex;
  955. align-items: center;
  956. }
  957. .page-numbers {
  958. display: flex;
  959. align-items: center;
  960. gap: 8rpx;
  961. overflow-x: auto;
  962. max-width: 400rpx;
  963. &::-webkit-scrollbar {
  964. display: none;
  965. }
  966. }
  967. .page-number {
  968. min-width: 84rpx;
  969. height: 58rpx;
  970. display: flex;
  971. align-items: center;
  972. justify-content: center;
  973. border-radius: 100rpx;
  974. font-family: PingFang SC;
  975. // font-weight: 400;
  976. font-size: 30rpx;
  977. color: #3B3D3D;
  978. background-color: transparent;
  979. border: 1px solid #3B3D3D;
  980. transition: all 0.3s ease;
  981. &.active {
  982. border: 1px solid $primary-color;
  983. color: $primary-color;
  984. }
  985. }
  986. /* 课程弹出窗样式 */
  987. .course-popup {
  988. padding: 0 32rpx;
  989. max-height: 80vh;
  990. }
  991. .popup-header {
  992. display: flex;
  993. justify-content: space-between;
  994. align-items: center;
  995. padding: 20rpx 0;
  996. border-bottom: 2rpx solid #EEEEEE
  997. // margin-bottom: 40rpx;
  998. }
  999. .popup-title {
  1000. font-family: PingFang SC;
  1001. font-weight: 500;
  1002. font-size: 34rpx;
  1003. color: #181818;
  1004. }
  1005. .course-list {
  1006. max-height: 60vh;
  1007. overflow-y: auto;
  1008. }
  1009. .course-item {
  1010. display: flex;
  1011. align-items: center;
  1012. gap: 24rpx;
  1013. padding-top: 24rpx;
  1014. padding-right: 8rpx;
  1015. padding-bottom: 24rpx;
  1016. padding-left: 8rpx;
  1017. border-bottom: 1px solid #EEEEEE;
  1018. cursor: pointer;
  1019. &:last-child {
  1020. border-bottom: none;
  1021. }
  1022. }
  1023. .course-number {
  1024. width: 80rpx;
  1025. font-family: PingFang SC;
  1026. // font-weight: 400;
  1027. font-size: 36rpx;
  1028. color: #999;
  1029. &.highlight {
  1030. color: $primary-color;
  1031. }
  1032. // margin-right: 24rpx;
  1033. }
  1034. .course-content {
  1035. flex: 1;
  1036. }
  1037. .course-english {
  1038. font-family: PingFang SC;
  1039. font-weight: 600;
  1040. font-size: 36rpx;
  1041. line-height: 44rpx;
  1042. color: #252545;
  1043. margin-bottom: 8rpx;
  1044. &.highlight {
  1045. color: $primary-color;
  1046. }
  1047. }
  1048. .course-chinese {
  1049. font-size: 28rpx;
  1050. line-height: 48rpx;
  1051. color: #3B3D3D;
  1052. &.highlight {
  1053. color: $primary-color;
  1054. }
  1055. }
  1056. /* 释义弹窗样式 */
  1057. .meaning-popup {
  1058. // width: 670rpx;
  1059. background-color: #FFFFFF;
  1060. // border-radius: 32rpx;
  1061. overflow: hidden;
  1062. }
  1063. .meaning-header {
  1064. display: flex;
  1065. justify-content: space-between;
  1066. align-items: center;
  1067. padding: 32rpx;
  1068. border-bottom: 2rpx solid #EEEEEE;
  1069. }
  1070. .close-btn {
  1071. width: 80rpx;
  1072. }
  1073. .close-text {
  1074. font-family: PingFang SC;
  1075. font-size: 32rpx;
  1076. color: #8b8b8b;
  1077. }
  1078. .meaning-title {
  1079. font-family: PingFang SC;
  1080. font-weight: 500;
  1081. font-size: 34rpx;
  1082. color: #181818;
  1083. }
  1084. .meaning-content {
  1085. padding: 32rpx;
  1086. }
  1087. .meaning-image {
  1088. width: 670rpx;
  1089. height: 268rpx;
  1090. border-radius: 24rpx;
  1091. margin-bottom: 32rpx;
  1092. }
  1093. .word-info {
  1094. margin-bottom: 32rpx;
  1095. }
  1096. .word-main {
  1097. display: flex;
  1098. align-items: center;
  1099. gap: 16rpx;
  1100. margin-bottom: 8rpx;
  1101. }
  1102. .word-text {
  1103. font-family: PingFang SC;
  1104. font-weight: 500;
  1105. font-size: 40rpx;
  1106. color: #181818;
  1107. }
  1108. .phonetic-container{
  1109. display: flex;
  1110. gap: 24rpx;
  1111. .phonetic-text {
  1112. font-family: PingFang SC;
  1113. font-size: 28rpx;
  1114. color: #262626;
  1115. margin-bottom: 16rpx;
  1116. }
  1117. }
  1118. .word-meaning {
  1119. display: flex;
  1120. gap: 16rpx;
  1121. }
  1122. .part-of-speech {
  1123. font-family: PingFang SC;
  1124. font-size: 28rpx;
  1125. color: #262626;
  1126. }
  1127. .meaning-text {
  1128. font-family: PingFang SC;
  1129. font-size: 28rpx;
  1130. color: #181818;
  1131. flex: 1;
  1132. }
  1133. .knowledge-gain {
  1134. background: linear-gradient(180deg, #DEFFFF 0%, #FBFEFF 22.65%, #F0FBFF 100%);
  1135. border-radius: 24rpx;
  1136. padding: 32rpx 40rpx;
  1137. }
  1138. .knowledge-header {
  1139. display: flex;
  1140. align-items: center;
  1141. gap: 16rpx;
  1142. margin-bottom: 20rpx;
  1143. }
  1144. .knowledge-icon {
  1145. width: 48rpx;
  1146. height: 48rpx;
  1147. }
  1148. .knowledge-title {
  1149. font-family: PingFang SC;
  1150. font-weight: 600;
  1151. font-size: 30rpx;
  1152. color: #3B3D3D;
  1153. }
  1154. .knowledge-content {
  1155. font-family: PingFang SC;
  1156. font-size: 28rpx;
  1157. color: #4f4f4f;
  1158. line-height: 48rpx;
  1159. }
  1160. /* 音频控制栏样式 */
  1161. .audio-controls {
  1162. background: #fff;
  1163. padding: 20rpx 40rpx;
  1164. border-bottom: 1rpx solid #eee;
  1165. transition: transform 0.3s ease;
  1166. position: relative;
  1167. z-index: 10;
  1168. &.audio-hidden {
  1169. transform: translateY(100%);
  1170. }
  1171. }
  1172. .audio-time {
  1173. display: flex;
  1174. align-items: center;
  1175. margin-bottom: 20rpx;
  1176. }
  1177. .time-text {
  1178. font-size: 28rpx;
  1179. color: #999;
  1180. min-width: 80rpx;
  1181. }
  1182. .progress-container {
  1183. flex: 1;
  1184. margin: 0 20rpx;
  1185. }
  1186. .progress-bar {
  1187. position: relative;
  1188. height: 6rpx;
  1189. background: #f0f0f0;
  1190. border-radius: 3rpx;
  1191. }
  1192. .progress-fill {
  1193. position: absolute;
  1194. left: 0;
  1195. top: 0;
  1196. height: 100%;
  1197. background: #06DADC;
  1198. border-radius: 3rpx;
  1199. transition: width 0.1s ease;
  1200. }
  1201. .progress-thumb {
  1202. position: absolute;
  1203. top: 50%;
  1204. width: 16rpx;
  1205. height: 16rpx;
  1206. background: #06DADC;
  1207. border-radius: 50%;
  1208. transform: translate(-50%, -50%);
  1209. transition: left 0.1s ease;
  1210. }
  1211. .audio-controls-row {
  1212. display: flex;
  1213. align-items: center;
  1214. justify-content: space-between;
  1215. }
  1216. .control-btn {
  1217. display: flex;
  1218. // flex-direction: column;
  1219. align-items: center;
  1220. padding: 10rpx;
  1221. gap: 8rpx;
  1222. }
  1223. .control-text {
  1224. font-size: 28rpx;
  1225. color: #4A4A4A;
  1226. // margin-top: 8rpx;
  1227. }
  1228. .play-btn {
  1229. display: flex;
  1230. align-items: center;
  1231. justify-content: center;
  1232. padding: 10rpx;
  1233. }
  1234. </style>