| <template> | |
|     <view class="book-container"> | |
|         <!-- 条件编译 --> | |
| 
 | |
|         <!-- #ifndef H5 --> | |
|         <uv-status-bar></uv-status-bar> | |
|         <!-- 自定义顶部导航栏 --> | |
|         <view class="custom-navbar" :class="{ 'navbar-hidden': !showNavbar }"> | |
|             <uv-status-bar></uv-status-bar> | |
|             <view class="navbar-content"> | |
|                 <view class="navbar-left" @click="goBack"> | |
|                     <uv-icon name="arrow-left" size="20" color="#262626"></uv-icon> | |
|                 </view> | |
|                 <view class="navbar-title">{{ currentPageTitle }}</view> | |
|             </view> | |
|         </view> | |
|         <!-- #endif --> | |
| 
 | |
|         <!-- Swiper内容区域 --> | |
|         <swiper class="content-swiper" :current="currentPage - 1" @change="onSwiperChange"> | |
|             <swiper-item v-for="(page, index) in bookPages" :key="index" class="swiper-item"> | |
|                 <scroll-view scroll-y :scroll-top="scrollTops[index] || 0" :scroll-with-animation="true" | |
|                     style="height: 100vh;" class="scroll-container" @scroll="onScroll" @touchstart="onTouchStart" | |
|                     @touchmove="onTouchMove" @touchend="onTouchEnd"> | |
|                     <view class="content-area" @click="toggleNavbar"> | |
|                         <view class="title">{{ currentPageTitle }}</view> | |
|                         <!-- 会员限制页面 --> | |
|                         <view v-if="!isMember && pagePay[index] === 'Y' && userInfo.freeUser != 'Y'" class="member-content"> | |
|                             <text class="member-title">{{ pageTitles[index] }}</text> | |
|                             <view class="member-button" @click.stop="unlockBook"> | |
|                                 <text class="member-button-text">升级会员解锁</text> | |
|                             </view> | |
|                         </view> | |
|  | |
|                         <!-- 图片卡片页面 --> | |
|                         <view class="card-content" v-else-if="pageTypes[index] === '1'"> | |
|                             <view class="card-line"> | |
|                                 <image :src="configParamContent('highlight_icon')" class="card-line-image" | |
|                                     mode="aspectFill" /> | |
|                                 <text class="card-line-text">划线重点</text> | |
|                             </view> | |
|                             <view v-for="(item, itemIndex) in page" :key="itemIndex" class="text-content"> | |
|                                 <image class="card-image" v-if="item && item.type === 'image'" :src="item.imageUrl" | |
|                                     mode="widthFix"></image> | |
|                                 <!-- <view :class="['english-text-container', 'clickable-text', { 'lead-text': isCardTextHighlighted(page, itemIndex) }]" v-else-if="item && item.type === 'text' && item.language === 'en' && item.content" @click.stop="handleTextClick(item.content, item, index)" > | |
|                   <text  | |
|                     v-for="(token, tokenIndex) in splitEnglishSentence(item.content)"  | |
|                     :key="tokenIndex" | |
|                     :class="['english-token', { 'clickable-word': token.isWord && findWordDefinition(token.text) }]" | |
|                     @click.stop="token.isWord && findWordDefinition(token.text) ? handleWordClick(token.text) : null" | |
|                     user-select | |
|                     :style="item.style" | |
|                   >{{ token.text }}</text> | |
|                 </view> --> | |
|                                 <!-- <view :class="{ 'lead-text': isCardTextHighlighted(page, itemIndex) }" v-else-if="item && item.type === 'text' && item.language === 'zh' && item.content" @click.stop="handleTextClick(item.content, item, index)"> --> | |
|                                 <view :class="{  | |
|                                     'lead-text': isCardTextHighlighted(page, itemIndex), | |
|                                     'introduction-text' : item.isLead, | |
|                                 }" | |
|                                     v-else-if="item && item.type === 'text' && item.content" | |
|                                     @click.stop="handleTextClick(item.content, item, index)"> | |
|                                     <text v-for="(segment, segmentIndex) in processChineseText(item.content)" | |
|                                         :key="segmentIndex" | |
|                                         :class="['chinese-segment', { 'clickable-keyword': segment.isKeyword }]" | |
|                                         @click.stop="segment.isKeyword ? handleChineseKeywordClick(segment.keywordData) : handleTextClick(item.content, item, index)" | |
|                                         user-select :style="item.style" :id="`text-segment-${segmentIndex}`">{{ | |
|                                         segment.text }}</text> | |
|                                 </view> | |
|                             </view> | |
|                         </view> | |
|  | |
|                         <view v-else> | |
|                             <view v-for="(item, itemIndex) in page" :key="itemIndex"> | |
|                                 <!-- 文本页面 --> | |
|                                 <view v-if="item && item.type === 'text' && item.content" class="text-content"> | |
|                                     <view :class="{  | |
|                                         'lead-text': isTextHighlighted(page, itemIndex), | |
|                                         'introduction-text' : item.isLead, | |
|                                     }" | |
|                                         @click.stop="handleTextClick(item.content, item, index)" | |
|                                         :ref="`textRef_${index}_${itemIndex}`" :id="`text-${itemIndex}`"> | |
|                                         <text class="content-text clickable-text" | |
|                                             :style="item.style" user-select> | |
|                                             {{ item.content }} | |
|                                         </text> | |
|                                     </view> | |
|                                 </view> | |
|  | |
|                                 <!-- 图片页面 --> | |
|                                 <view v-else-if="item.type === 'image'" class="image-container" | |
|                                     :ref="`imageRef_${index}_${itemIndex}`"> | |
|                                     <image class="content-image" :src="item.imageUrl" mode="widthFix"></image> | |
|                                 </view> | |
|  | |
|                                 <!-- 视频页面 --> | |
|                                 <view v-else-if="item.type === 'video'" class="video-content" @click.stop> | |
|                                     <!-- 视频加载状态 --> | |
|                                     <view v-if="videoLoading" class="video-loading"> | |
|                                         <text class="loading-text">视频加载中...</text> | |
|                                     </view> | |
|  | |
|                                     <!-- 视频播放器 --> | |
|                                     <video v-else :src="item.url" class="video-player" controls :poster="item.coverUrl" | |
|                                         @loadstart="onVideoLoadStart" @loadeddata="onVideoLoadStart" | |
|                                         @error="onVideoError"></video> | |
|                                 </view> | |
|                             </view> | |
|                         </view> | |
|                     </view> | |
|                 </scroll-view> | |
|             </swiper-item> | |
|         </swiper> | |
| 
 | |
|         <!-- 自定义底部控制栏 --> | |
|         <CustomTabbar :show-navbar="showNavbar" :current-page="currentPage" :course-id="courseId" :voice-id="voiceId" | |
|             :book-pages="bookPages" :is-text-page="isTextPage" :should-load-audio="shouldLoadAudio" | |
|             :is-member="isMember" :current-page-requires-member="currentPageRequiresMember" :page-pay="pagePay" | |
|             :is-word-audio-playing="isWordAudioPlaying" @toggle-course-popup="toggleCoursePopup" | |
|             @toggle-sound="toggleSound" @go-to-page="goToPage" @previous-page="previousPage" @next-page="nextPage" | |
|             @audio-state-change="onAudioStateChange" @highlight-change="onHighlightChange" | |
|             @scroll-to-text="onScrollToText" @voice-change-complete="onVoiceChangeComplete" | |
|             @voice-change-error="onVoiceChangeError" @page-data-needed="onPageDataNeeded" ref="customTabbar" /> | |
| 
 | |
|         <!-- 课程选择弹出窗 --> | |
|         <CoursePopup :style="{ zIndex: 10000 }" :course-list="courseList" :current-course="currentCourse" | |
|             :is-reversed="isReversed" @toggle-sort="toggleSort" @select-course="selectCourse" ref="coursePopup" /> | |
| 
 | |
|         <!-- 释义弹出窗 --> | |
|         <MeaningPopup :style="{ zIndex: 10000 }" :current-word-meaning="currentWordMeaning" | |
|             @close-meaning-popup="closeMeaningPopup" @repeat-word-audio="repeatWordAudio" ref="meaningPopup" /> | |
| 
 | |
|         <!-- 悬浮按钮组件 --> | |
|         <FloatingButtons  | |
|             :is-last-page="isLastPage"  | |
|             :has-next-course="hasNextCourse" | |
|             @next-course="goToNextCourse" | |
|             @back-to-start="backToStart" | |
|         /> | |
|     </view> | |
| </template> | |
| 
 | |
| <script> | |
| import AudioControls from './AudioControls.vue' | |
| import CustomTabbar from './components/CustomTabbar.vue' | |
| import CoursePopup from './components/CoursePopup.vue' | |
| import MeaningPopup from './components/MeaningPopup.vue' | |
| import FloatingButtons from './components/FloatingButtons.vue' | |
| import audioManager from '@/utils/audioManager.js' | |
| 
 | |
| export default { | |
|     components: { | |
|         AudioControls, | |
|         CustomTabbar, | |
|         CoursePopup, | |
|         MeaningPopup, | |
|         FloatingButtons | |
|     }, | |
|     data() { | |
|         return { | |
|             isMember: false, | |
|             memberId: '', | |
|             voiceId: null, | |
|             courseId: '', | |
|             showNavbar: true, | |
|             currentPage: 1, // 当前页面索引 | |
|             currentCourse: 1, // 当前课程索引 | |
|             currentWordMeaning: null, // 当前显示的单词释义 | |
|             isReversed: false, // 是否倒序显示 | |
|             // 文本高亮相关 - 由AudioControls组件管理,这里只保留必要的接口 | |
|             currentHighlightIndex: -1, // 当前高亮的文本索引,用于模板渲染 | |
|             wordAudioCache: {}, // 單詞語音緩存 | |
|             // 注意:音频实例现在由audioManager统一管理,不再在组件中维护 | |
|             isWordAudioPlaying: false, // 是否有单词音频正在播放 | |
|  | |
|             // 音频状态相关 - 这些状态现在由AudioControls组件管理 | |
|             // 保留这些属性用于与AudioControls组件的数据同步 | |
|             isAudioLoading: false, // 音频是否正在加载 | |
|             hasAudioData: false, // 是否有音频数据 | |
|             audioLoadFailed: false, // 音频加载是否失败 | |
|  | |
|             // 视频状态相关 | |
|             videoLoading: false, // 视频是否正在加载 | |
|  | |
|             // 滚动相关 | |
|             scrollTops: [], // 每个页面的scroll-view滚动位置数组 | |
|             scrollDebounceTimer: null, // 滚动防抖定时器 | |
|             isScrolling: false, // 是否正在滚动中 | |
|  | |
|             // 手动滚动检测相关 | |
|             isUserTouching: false, // 用户是否正在触摸屏幕 | |
|             touchStartTime: 0, // 触摸开始时间 | |
|             touchStartY: 0, // 触摸开始Y坐标 | |
|             userScrollTimer: null, // 用户滚动检测定时器 | |
|             lastUserScrollTime: 0, // 最后一次用户滚动时间 | |
|             courseIdList: [], | |
|             bookTitle: '', | |
|             courseList: [ | |
| 
 | |
|             ], | |
| 
 | |
|             // 二维数组 代表每个页面 | |
|             bookPages: [ | |
| 
 | |
|             ], | |
|             // 存储每个页面的标题 | |
|             pageTitles: [], | |
|             // 存储每个页面的type信息 | |
|             pageTypes: [], | |
|             // 存储每个页面的单词释义数据 | |
|             pageWords: [], | |
|             // 存储每个页面的付费状态 | |
|             pagePay: [], | |
|         } | |
|     }, | |
|     onShow() { | |
|         if (uni.getStorageSync('token')) { | |
|             this.$store.dispatch('getUserInfo'); | |
|         } | |
|     }, | |
|     computed: { | |
|         displayCourseList() { | |
|             return this.isReversed ? [...this.courseList].reverse() : this.courseList; | |
|         }, | |
|         // 判断当前页面是否为文字类型 | |
|         isTextPage() { | |
|             // 如果是卡片页面(type为'1'),不显示音频控制栏 | |
|             if (this.currentPageType === '1') { | |
|                 return false; | |
|             } | |
| 
 | |
|             const currentPageData = this.bookPages[this.currentPage - 1]; | |
|             // currentPageData是一个数组 其中的一个元素的type是text就会返回true | |
|             return currentPageData && currentPageData.some(item => item.type === 'text'); | |
|         }, | |
| 
 | |
|         // 判断当前页面是否需要加载音频(包括文本页面和卡片页面) | |
|         shouldLoadAudio() { | |
|             // 文本页面需要加载音频 | |
|             if (this.isTextPage) { | |
|                 return true; | |
|             } | |
| 
 | |
|             // 卡片页面(type为'1')也需要加载音频以支持点击播放 | |
|             if (this.currentPageType === '1') { | |
|                 return true; | |
|             } | |
| 
 | |
|             return false; | |
|         }, | |
| 
 | |
| 
 | |
|         // 动态页面标题 | |
|         currentPageTitle() { | |
|             return this.pageTitles[this.currentPage - 1] || this.bookTitle; | |
|         }, | |
| 
 | |
|         // 当前页面类型 | |
|         currentPageType() { | |
|             return this.pageTypes[this.currentPage - 1] || ''; | |
|         }, | |
| 
 | |
|         // 当前页面的单词释义数据 | |
|         currentPageWords() { | |
|             return this.pageWords[this.currentPage - 1] || []; | |
|         }, | |
| 
 | |
|         // 当前页面是否需要会员 | |
|         currentPageRequiresMember() { | |
|             // 免费用户不受会员限制 | |
|             if (this.userInfo && this.userInfo.freeUser === 'Y') { | |
|                 return false; | |
|             } | |
|             return this.pagePay[this.currentPage - 1] === 'Y'; | |
|         }, | |
| 
 | |
|         // 判断是否为当前课程的最后一页 | |
|         isLastPage() { | |
|             return this.currentPage === this.bookPages.length; | |
|         }, | |
| 
 | |
|         // 判断是否有下一课 | |
|         hasNextCourse() { | |
|             if (!this.courseList || this.courseList.length === 0) return false; | |
|             // 使用 courseId 而不是 currentCourse,因为 courseId 是当前正在学习的课程ID | |
|             const currentCourseIndex = this.courseList.findIndex(course => course.id == this.courseId); | |
|             return currentCourseIndex >= 0 && currentCourseIndex < this.courseList.length - 1; | |
|         } | |
|     }, | |
|     // watch: { | |
|     //   scrollTops: { | |
|     //     handler(newVal, oldVal) { | |
|     //       console.log('📊 scrollTops变化:', { | |
|     //         currentPage: this.currentPage, | |
|     //         newScrollTops: newVal, | |
|     //         currentPageScrollTop: newVal[this.currentPage - 1] | |
|     //       }); | |
|     //     }, | |
|     //     deep: true | |
|     //   } | |
|     // }, | |
|     methods: { | |
|         // 触摸开始事件 - 检测用户开始触摸 | |
|         onTouchStart(e) { | |
|             this.isUserTouching = true; | |
|             this.touchStartTime = Date.now(); | |
|             this.touchStartY = e.touches[0].pageY; | |
| 
 | |
|             // 清除之前的用户滚动定时器 | |
|             if (this.userScrollTimer) { | |
|                 clearTimeout(this.userScrollTimer); | |
|                 this.userScrollTimer = null; | |
|             } | |
| 
 | |
|             console.log('👆 用户开始触摸屏幕'); | |
|         }, | |
| 
 | |
|         // 触摸移动事件 - 检测用户滚动操作 | |
|         onTouchMove(e) { | |
|             if (!this.isUserTouching) return; | |
| 
 | |
|             const currentY = e.touches[0].pageY; | |
|             const deltaY = Math.abs(currentY - this.touchStartY); | |
| 
 | |
|             // 如果移动距离超过阈值,认为是滚动操作 | |
|             if (deltaY > 10) { | |
|                 // 记录用户滚动时间 | |
|                 this.lastUserScrollTime = Date.now(); | |
|                  | |
|                 // 如果当前正在自动滚动,立即停止 | |
|                 if (this.isScrolling) { | |
|                     console.log('🛑 检测到用户手动滚动,停止自动滚动'); | |
|                     this.isScrolling = false; | |
| 
 | |
|                     // 清除滚动防抖定时器 | |
|                     if (this.scrollDebounceTimer) { | |
|                         clearTimeout(this.scrollDebounceTimer); | |
|                         this.scrollDebounceTimer = null; | |
|                     } | |
|                 } | |
|             } | |
|         }, | |
| 
 | |
|         // 触摸结束事件 - 用户停止触摸 | |
|         onTouchEnd(e) { | |
|             this.isUserTouching = false; | |
| 
 | |
|             // 设置一个短暂的延迟,在用户停止触摸后的一段时间内仍然阻止自动滚动 | |
|             // 这样可以避免用户刚停止滚动就立即触发自动滚动 | |
|             this.userScrollTimer = setTimeout(() => { | |
|                 console.log('✋ 用户滚动操作结束,允许自动滚动'); | |
|                 this.userScrollTimer = null; | |
|             }, 500); // 减少到500ms,提高响应性 | |
|  | |
|             console.log('👆 用户停止触摸屏幕'); | |
|         }, | |
| 
 | |
|         // 检查是否应该阻止自动滚动 | |
|         shouldPreventAutoScroll() { | |
|             // 降低敏感度:只有在用户正在触摸且最近有滚动行为时才阻止 | |
|             const now = Date.now(); | |
|             const recentUserScroll = this.userScrollTimer !== null && (now - this.lastUserScrollTime) < 1000; | |
|             return this.isUserTouching && recentUserScroll; | |
|         }, | |
| 
 | |
|         // 处理scroll-view滚动事件 | |
|         onScroll(e) { | |
|             // 更新当前页面的滚动位置 | |
|             const scrollTop = e.detail.scrollTop; | |
|             const currentPageIndex = this.currentPage - 1; | |
|             const previousScrollTop = this.scrollTops[currentPageIndex] || 0; | |
| 
 | |
|             // 只有当滚动位置发生显著变化时才更新 | |
|             if (Math.abs(previousScrollTop - scrollTop) > 5) { | |
|                 // 检测是否为手动滚动(如果正在自动滚动中,但滚动位置与预期不符,则认为是手动滚动) | |
|                 if (this.isScrolling) { | |
|                     // 如果滚动差异很大,可能是用户手动滚动,中断自动滚动状态 | |
|                     const scrollDifference = Math.abs(previousScrollTop - scrollTop); | |
|                     if (scrollDifference > 50) { // 调整阈值,避免误判 | |
|                         console.log('🖐️ 检测到手动滚动,中断自动滚动状态'); | |
|                         this.isScrolling = false; | |
|                         this.lastUserScrollTime = Date.now(); // 记录手动滚动时间 | |
|                     } | |
|                 } | |
| 
 | |
|                 this.$set(this.scrollTops, currentPageIndex, scrollTop); | |
|             } | |
|         }, | |
| 
 | |
|         // 视频事件处理方法 | |
|         onVideoLoadStart() { | |
| 
 | |
| 
 | |
|             this.videoLoading = true; | |
|         }, | |
| 
 | |
|         onVideoCanPlay() { | |
| 
 | |
| 
 | |
|             this.videoLoading = false; | |
| 
 | |
|         }, | |
| 
 | |
|         onVideoError() { | |
|             this.videoLoading = false; | |
|             uni.showToast({ | |
|                 title: '视频加载失败', | |
|                 icon: 'none', | |
|                 duration: 2000 | |
|             }); | |
|         }, | |
| 
 | |
|         // 獲取用戶會員信息 判斷是否和傳參傳過來的會員id相同 | |
|         async getMemberInfo() { | |
|             // 检查是否为免费用户 | |
|             if (this.userInfo && this.userInfo.freeUser === 'Y') { | |
|                 this.isMember = true; // 免费用户享有会员权限 | |
|                 return; | |
|             } | |
| 
 | |
|             const memberRes = await this.$api.member.getUserMemberInfo() | |
|             if (memberRes.code === 200) { | |
|                 this.isMember = memberRes.result.map(item => item.memberId).includes(this.memberId) | |
|             } | |
|         }, | |
| 
 | |
|         // 处理AudioControls组件的事件 | |
|         onAudioStateChange(audioState) { | |
|             // 更新高亮状态 | |
|             this.currentHighlightIndex = audioState.currentHighlightIndex; | |
| 
 | |
|             // 更新音频加载状态(用于控制UI显示) | |
|             if (audioState.hasOwnProperty('isLoading')) { | |
|                 this.isAudioLoading = audioState.isLoading; | |
|             } | |
| 
 | |
|             // 更新音频数据状态 | |
|             if (audioState.hasOwnProperty('hasAudioData')) { | |
|                 this.hasAudioData = audioState.hasAudioData; | |
|             } | |
| 
 | |
|             // 更新音频加载失败状态 | |
|             if (audioState.hasOwnProperty('audioLoadFailed')) { | |
|                 this.audioLoadFailed = audioState.audioLoadFailed; | |
|             } | |
|         }, | |
| 
 | |
|         // 处理页面数据需要重新加载的事件 | |
|         async onPageDataNeeded(pageNumber) { | |
|             console.log('收到页面数据需要重新加载的请求,页面:', pageNumber); | |
| 
 | |
|             // 如果页面数据不存在或为空,重新获取 | |
|             if (!this.bookPages || this.bookPages.length === 0 || !this.bookPages[pageNumber - 1]) { | |
|                 console.log('页面数据不存在,重新获取页面数据'); | |
|                 try { | |
|                     await this.getBookPages(); | |
|                     console.log('页面数据重新获取完成'); | |
| 
 | |
|                     // 页面数据更新后,AudioControls组件的bookPages监听器会自动触发音频获取 | |
|                     // 无需手动调用getCurrentPageAudio,避免重复调用 | |
|                 } catch (error) { | |
|                     console.error('重新获取页面数据失败:', error); | |
|                 } | |
|             } else { | |
|                 console.log('页面数据已存在,无需重新获取'); | |
|             } | |
|         }, | |
| 
 | |
|         // 处理音色切换完成事件 | |
|         onVoiceChangeComplete(data) { | |
| 
 | |
|             // 可以在这里添加一些UI反馈,比如显示切换成功的提示 | |
|             if (data.hasAudioData) { | |
| 
 | |
|             } else { | |
| 
 | |
|             } | |
| 
 | |
|             // 如果启用了预加载所有页面 | |
|             if (data.preloadAllPages) { | |
| 
 | |
|                 // 可以显示一个提示,告诉用户正在后台加载 | |
|                 uni.showToast({ | |
|                     title: '正在加载新音色...', | |
|                     icon: 'loading', | |
|                     duration: 2000 | |
|                 }); | |
|             } | |
|         }, | |
| 
 | |
|         // 处理音色切换错误事件 | |
|         onVoiceChangeError(error) { | |
|             console.error('音色切换失败:', error); | |
|             // 可以在这里显示错误提示给用户 | |
|             uni.showToast({ | |
|                 title: '音色切换失败,请重试', | |
|                 icon: 'none', | |
|                 duration: 2000 | |
|             }); | |
|         }, | |
| 
 | |
|         // 处理音频切换时的自动滚动 | |
|         // onScrollToText(refName) { | |
|         //   try { | |
|         //     console.log('🎯 onScrollToText 被调用:', refName); | |
|         //      | |
|         //     // 调用scrollTo插件 | |
|         //     this.$scrollTo(refName); | |
|         //      | |
|         //   } catch (error) { | |
|         //     console.error('❌ onScrollToText 执行失败:', error); | |
|         //   } | |
|         // }, | |
|  | |
|         // 处理文本点击事件 | |
|         handleTextClick(textContent, item, pageIndex) { | |
|             // console.log('🎯 ===== 文本点击事件开始 ====='); | |
|             // console.log('📝 点击文本:', textContent); | |
|             // console.log('📄 textContent类型:', typeof textContent); | |
|             // console.log('❓ textContent是否为undefined:', textContent === undefined); | |
|             // console.log('📦 完整item对象:', item); | |
|             // console.log('📝 item.content:', item ? item.content : 'item为空'); | |
|             // console.log('📖 当前页面索引:', this.currentPage); | |
|             // console.log('👆 点击的页面索引:', pageIndex); | |
|             // console.log('📊 当前页面类型:', this.currentPageType); | |
|             // console.log('📄 是否为文本页面:', this.isTextPage); | |
|             // console.log('📋 当前页面数据:', this.bookPages[this.currentPage - 1]); | |
|             // console.log('📏 页面数据长度:', this.bookPages[this.currentPage - 1] ? this.bookPages[this.currentPage - 1].length : '页面不存在'); | |
|  | |
|             // 检查音频播放状态 | |
|             // console.log('🎵 ===== 音频状态检查 ====='); | |
|             // console.log('  isWordAudioPlaying:', this.isWordAudioPlaying); | |
|             // console.log('  currentWordAudio存在:', !!this.currentWordAudio); | |
|             // console.log('  currentWordMeaning存在:', !!this.currentWordMeaning); | |
|  | |
|             if (this.isWordAudioPlaying) { | |
|                 // console.log('⚠️ 检测到单词音频正在播放状态,这可能会阻止句子音频播放'); | |
|                 // console.log('🔄 尝试重置音频播放状态...'); | |
|                 this.isWordAudioPlaying = false; | |
|                 // console.log('✅ 音频播放状态已重置'); | |
|             } | |
| 
 | |
|             // 检查是否点击的是当前页面 | |
|             if (pageIndex !== undefined && pageIndex !== this.currentPage - 1) { | |
|                 console.warn('⚠️ 点击的不是当前页面,忽略点击事件'); | |
|                 // console.log(`  期望页面: ${this.currentPage - 1}, 点击页面: ${pageIndex}`); | |
|                 return; | |
|             } | |
| 
 | |
|             // 验证参数有效性 | |
|             if (!item) { | |
|                 console.error('❌ handleTextClick: item参数为空'); | |
|                 uni.showToast({ | |
|                     title: '数据错误,请刷新页面', | |
|                     icon: 'none' | |
|                 }); | |
|                 return; | |
|             } | |
| 
 | |
|             // 如果textContent为undefined,尝试从item中获取 | |
|             if (!textContent && item && item.content) { | |
|                 textContent = item.content; | |
|             } | |
| 
 | |
|             // 最终验证textContent | |
|             if (!textContent || typeof textContent !== 'string' || textContent.trim() === '') { | |
|                 console.error('❌ handleTextClick: 无效的文本内容', textContent); | |
|                 uni.showToast({ | |
|                     title: '文本内容无效', | |
|                     icon: 'none' | |
|                 }); | |
|                 return; | |
|             } | |
| 
 | |
| 
 | |
|             if (!this.$refs.customTabbar) { | |
|                 console.error('❌ customTabbar引用不存在'); | |
|                 uni.showToast({ | |
|                     title: '音频控制组件未准备好', | |
|                     icon: 'none' | |
|                 }); | |
|                 return; | |
|             } | |
| 
 | |
|             // console.log('  audioControls存在:', !!this.$refs.customTabbar.$refs.audioControls); | |
|  | |
|             if (!this.$refs.customTabbar.$refs.audioControls) { | |
|                 console.error('❌ audioControls引用不存在'); | |
|                 uni.showToast({ | |
|                     title: '音频控制组件未准备好', | |
|                     icon: 'none' | |
|                 }); | |
|                 return; | |
|             } | |
| 
 | |
|             // 检查当前页面是否为文本页面或卡片页面 | |
|             // 卡片页面(type为'1')现在也支持整句音频播放 | |
|  | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
| 
 | |
|             // 特别针对划线重点页面的调试 | |
|             if (this.currentPageType === '1') { | |
| 
 | |
|             } | |
| 
 | |
|             if (!this.isTextPage && this.currentPageType !== '1') { | |
|                 console.warn('⚠️ 当前页面不是文本页面或卡片页面'); | |
| 
 | |
|                 uni.showToast({ | |
|                     title: '当前页面不支持音频播放', | |
|                     icon: 'none' | |
|                 }); | |
|                 return; | |
|             } | |
| 
 | |
| 
 | |
| 
 | |
|             // 获取音频控制组件实例 | |
|             const audioControls = this.$refs.customTabbar.$refs.audioControls; | |
| 
 | |
|             // 检查音频是否正在加载中 | |
|             if (audioControls.isAudioLoading) { | |
| 
 | |
|                 uni.showToast({ | |
|                     title: '音频正在加载中,请稍后再试', | |
|                     icon: 'loading', | |
|                     duration: 1500 | |
|                 }); | |
| 
 | |
|                 // 等待音频加载完成后自动播放 | |
|                 const checkAndPlay = () => { | |
|                     if (!audioControls.isAudioLoading && audioControls.currentPageAudios.length > 0) { | |
| 
 | |
|                         const success = audioControls.playSpecificAudio(textContent); | |
|                         if (!success) { | |
|                             console.error('❌ 音频加载完成后播放失败'); | |
|                         } | |
|                     } else if (!audioControls.isAudioLoading) { | |
|                         console.error('❌ 音频加载完成但没有音频数据'); | |
|                         uni.showToast({ | |
|                             title: '当前页面没有音频内容', | |
|                             icon: 'none' | |
|                         }); | |
|                     } else { | |
|                         // 继续等待 | |
|                         setTimeout(checkAndPlay, 500); | |
|                     } | |
|                 }; | |
| 
 | |
|                 // 延迟检查,给音频加载一些时间 | |
|                 setTimeout(checkAndPlay, 500); | |
|                 return; | |
|             } | |
| 
 | |
|             // 检查是否有音频数据 | |
|             if (!audioControls.currentPageAudios || audioControls.currentPageAudios.length === 0) { | |
|                 console.warn('⚠️ 当前页面没有音频数据,尝试重新加载'); | |
|                 uni.showToast({ | |
|                     title: '正在重新加载音频...', | |
|                     icon: 'loading' | |
|                 }); | |
| 
 | |
|                 // 尝试重新加载音频 | |
|                 audioControls.getCurrentPageAudio(); | |
| 
 | |
|                 // 等待重新加载完成后播放 | |
|                 const retryPlay = () => { | |
|                     if (!audioControls.isAudioLoading && audioControls.currentPageAudios.length > 0) { | |
| 
 | |
|                         const success = audioControls.playSpecificAudio(textContent); | |
|                         if (!success) { | |
|                             console.error('❌ 音频重新加载后播放失败'); | |
|                         } | |
|                     } else if (!audioControls.isAudioLoading) { | |
|                         console.error('❌ 音频重新加载失败'); | |
|                         uni.showToast({ | |
|                             title: '音频加载失败,请检查网络连接', | |
|                             icon: 'none' | |
|                         }); | |
|                     } else { | |
|                         // 继续等待 | |
|                         setTimeout(retryPlay, 500); | |
|                     } | |
|                 }; | |
| 
 | |
|                 setTimeout(retryPlay, 1500); | |
|                 return; | |
|             } | |
| 
 | |
|             // 调用AudioControls组件的播放指定音频方法 | |
|  | |
|             const success = audioControls.playSpecificAudio(textContent); | |
| 
 | |
|             // console.log('🎵 playSpecificAudio 返回结果:', success); | |
|  | |
|             if (success) { | |
|                 // console.log('✅ 成功播放指定音频段落'); | |
|             } else { | |
|                 console.error('❌ 播放指定音频段落失败'); | |
|                 // console.log('💡 失败可能原因:'); | |
|                 // console.log('  1. 文本内容与音频数据不匹配'); | |
|                 // console.log('  2. 音频数据尚未加载完成'); | |
|                 // console.log('  3. 音频文件路径错误或文件损坏'); | |
|                 // console.log('  4. 网络连接问题'); | |
|             } | |
| 
 | |
|             // console.log('🎯 ===== 文本点击事件结束 ====='); | |
|         }, | |
| 
 | |
|         onHighlightChange(highlightData) { | |
|             // 兼容旧格式(直接传递索引)和新格式(传递对象) | |
|             if (typeof highlightData === 'number') { | |
|                 // 旧格式:直接是索引 | |
|                 this.currentHighlightIndex = highlightData; | |
|             } else if (typeof highlightData === 'object' && highlightData !== null) { | |
|                 // 新格式:包含详细信息的对象 | |
|                 this.currentHighlightIndex = highlightData.highlightIndex; | |
| 
 | |
|                 // 可以在这里处理分段音频的额外信息 | |
|                 if (highlightData.isSegmented) { | |
|                     // console.log('分段音频高亮:', { | |
|                     //   highlightIndex: highlightData.highlightIndex, | |
|                     //   segmentIndex: highlightData.segmentIndex, | |
|                     //   startIndex: highlightData.startIndex, | |
|                     //   endIndex: highlightData.endIndex, | |
|                     //   currentText: highlightData.currentText | |
|                     // }); | |
|                 } | |
|             } else { | |
|                 // 清除高亮 | |
|                 this.currentHighlightIndex = -1; | |
|             } | |
|         }, | |
| 
 | |
|         // 处理滚动到高亮文本 | |
|         onScrollToText(scrollData) { | |
|             console.log('📍 收到滚动请求:', scrollData); | |
|              | |
|             // 检查是否应该阻止自动滚动(用户正在手动操作) | |
|             if (this.shouldPreventAutoScroll()) { | |
|                 console.log('🚫 用户正在手动滚动,跳过自动滚动到文本'); | |
|                 return; | |
|             } | |
| 
 | |
|             // 防抖处理:如果正在滚动中,清除之前的定时器 | |
|             if (this.scrollDebounceTimer) { | |
|                 clearTimeout(this.scrollDebounceTimer); | |
|             } | |
| 
 | |
|             this.scrollDebounceTimer = setTimeout(() => { | |
|                 // 再次检查是否应该阻止自动滚动 | |
|                 if (this.shouldPreventAutoScroll()) { | |
|                     console.log('🚫 防抖延迟后检测到用户手动滚动,跳过自动滚动到文本'); | |
|                     return; | |
|                 } | |
|                 this.performScrollToText(scrollData); | |
|             }, 50); // 减少防抖延迟,提高响应性 | |
|         }, | |
| 
 | |
|         // 执行滚动到高亮文本的具体逻辑 | |
|         performScrollToText(scrollData) { | |
|             // 最终检查:如果用户正在手动操作,直接返回 | |
|             if (this.shouldPreventAutoScroll()) { | |
|                 console.log('🚫 执行滚动前检测到用户手动操作,取消自动滚动'); | |
|                 return; | |
|             } | |
| 
 | |
|             // 确保在任何情况下都能重置滚动状态 | |
|             const resetScrollingState = () => { | |
|                 this.isScrolling = false; | |
|                 console.log('🔄 滚动状态已重置'); | |
|             }; | |
| 
 | |
|             // 设置安全超时,确保状态不会永久卡住 | |
|             const safetyTimeout = setTimeout(() => { | |
|                 if (this.isScrolling) { | |
|                     console.warn('⚠️ 滚动状态安全超时,强制重置'); | |
|                     resetScrollingState(); | |
|                 } | |
|             }, 2000); // 2秒安全超时 | |
|  | |
|             if (!scrollData || typeof scrollData.highlightIndex !== 'number' || scrollData.highlightIndex < 0) { | |
|                 console.warn('滚动数据无效:', scrollData); | |
|                 clearTimeout(safetyTimeout); | |
|                 return; | |
|             } | |
| 
 | |
|             // 确保在当前页面 | |
|             if (scrollData.currentPage && scrollData.currentPage !== this.currentPage) { | |
|                 console.warn('页面不匹配,跳过滚动:', { | |
|                     scrollDataPage: scrollData.currentPage, | |
|                     currentPage: this.currentPage | |
|                 }); | |
|                 clearTimeout(safetyTimeout); | |
|                 return; | |
|             } | |
| 
 | |
|             // 如果正在滚动中,跳过本次滚动 | |
|             if (this.isScrolling) { | |
|                 console.warn('正在滚动中,跳过本次滚动'); | |
|                 clearTimeout(safetyTimeout); | |
|                 return; | |
|             } | |
| 
 | |
|             // 构建元素选择器 | |
|             let selector = ''; | |
|             if (scrollData.isSegmented && typeof scrollData.segmentIndex === 'number') { | |
|                 // 分段音频:使用分段索引 | |
|                 selector = `#text-segment-${scrollData.segmentIndex}`; | |
|             } else { | |
|                 // 普通音频:需要找到对应的文本元素 | |
|                 // originalTextIndex是指向原始页面数据中的索引,需要映射到实际的DOM元素 | |
|                 const targetItemIndex = this.findTextItemIndex(scrollData.highlightIndex); | |
|                 if (targetItemIndex !== -1) { | |
|                     selector = `#text-${targetItemIndex}`; | |
|                 } else { | |
|                     console.warn('无法找到对应的文本元素索引:', scrollData.highlightIndex); | |
|                     selector = `#text-${scrollData.highlightIndex}`; // 备用方案 | |
|                 } | |
|             } | |
| 
 | |
|             console.log('开始滚动到文本:', { selector, scrollData }); | |
| 
 | |
|             // 标记正在滚动 | |
|             this.isScrolling = true; | |
| 
 | |
|             // 等待DOM更新后再查找元素 | |
|             this.$nextTick(async () => { | |
|                 try { | |
|                     // 获取所有元素的真实位置信息 | |
|                     const elementPositions = await this.getAllElementPositions(); | |
|                     console.log('📏 获取到的元素位置信息:', elementPositions); | |
| 
 | |
|                     // 计算精确的滚动位置 | |
|                     const preciseScrollTop = await this.calculatePreciseScrollPosition(scrollData, elementPositions); | |
|                      | |
|                     if (preciseScrollTop !== null) { | |
|                         // 检查是否需要滚动(避免不必要的滚动) | |
|                         const currentScroll = this.scrollTops[this.currentPage - 1] || 0; | |
|                         const scrollDifference = Math.abs(preciseScrollTop - currentScroll); | |
| 
 | |
|                         if (scrollDifference > 20) { | |
|                             this.$set(this.scrollTops, this.currentPage - 1, preciseScrollTop); | |
|                             console.log('✅ 使用精确位置滚动:', { | |
|                                 selector, | |
|                                 preciseScrollTop, | |
|                                 currentPage: this.currentPage, | |
|                                 scrollDifference | |
|                             }); | |
| 
 | |
|                             // 滚动完成后重置状态 | |
|                             setTimeout(() => { | |
|                                 resetScrollingState(); | |
|                             }, 200); | |
|                         } else { | |
|                             resetScrollingState(); | |
|                             console.log('📍 目标已在视野内,无需滚动'); | |
|                         } | |
|                     } else { | |
|                         // 精确计算失败,使用原有的查询方法作为备用 | |
|                         console.log('🔄 精确计算失败,使用备用查询方法'); | |
|                         this.fallbackScrollToText(selector, resetScrollingState, safetyTimeout); | |
|                     } | |
|                 } catch (error) { | |
|                     console.error('❌ 精确滚动计算失败:', error); | |
|                     // 使用原有的查询方法作为备用 | |
|                     this.fallbackScrollToText(selector, resetScrollingState, safetyTimeout); | |
|                 } | |
|             }); | |
|         }, | |
| 
 | |
|         // 备用滚动方法(原有的查询方式) | |
|         fallbackScrollToText(selector, resetScrollingState, safetyTimeout) { | |
|                 // 使用uni.createSelectorQuery获取元素位置 | |
|                 const query = uni.createSelectorQuery().in(this); | |
| 
 | |
|                 // 获取scroll-view容器的位置信息 | |
|                 query.select('.scroll-container').boundingClientRect(); | |
|                 // 获取目标元素的位置信息 | |
|                 query.select(selector).boundingClientRect(); | |
| 
 | |
|                 query.exec((res) => { | |
|                     // 清除安全超时 | |
|                     clearTimeout(safetyTimeout); | |
| 
 | |
|                     const scrollViewRect = res[0]; | |
|                     const targetRect = res[1]; | |
| 
 | |
|                     console.log('查询结果:', { | |
|                         scrollViewRect: scrollViewRect ? '找到' : '未找到', | |
|                         targetRect: targetRect ? '找到' : '未找到', | |
|                         selector | |
|                     }); | |
| 
 | |
|                     if (scrollViewRect && targetRect) { | |
|                         // 计算目标元素相对于scroll-view的位置 | |
|                         const currentScrollTop = this.scrollTops[this.currentPage - 1] || 0; | |
|                         const targetOffsetTop = targetRect.top - scrollViewRect.top + currentScrollTop; | |
| 
 | |
|                         // 计算滚动位置,让目标元素在屏幕上方1/4处(更好的阅读体验) | |
|                         const screenHeight = uni.getSystemInfoSync().windowHeight; | |
|                         const targetScrollTop = targetOffsetTop - screenHeight / 4; | |
| 
 | |
|                         // 更新scroll-view的滚动位置 | |
|                         const finalScrollTop = Math.max(0, targetScrollTop); | |
| 
 | |
|                         // 检查是否需要滚动(避免不必要的滚动) | |
|                         const currentScroll = this.scrollTops[this.currentPage - 1] || 0; | |
|                         const scrollDifference = Math.abs(finalScrollTop - currentScroll); | |
| 
 | |
|                         if (scrollDifference > 20) { // 进一步降低滚动阈值,提高响应性 | |
|                             this.$set(this.scrollTops, this.currentPage - 1, finalScrollTop); | |
| 
 | |
|                             // 滚动完成后重置状态 | |
|                             setTimeout(() => { | |
|                                 resetScrollingState(); | |
|                             }, 200); // 减少等待时间 | |
|  | |
|                             console.log('✅ 滚动到高亮文本:', { | |
|                                 selector, | |
|                                 targetOffsetTop, | |
|                                 finalScrollTop, | |
|                                 currentPage: this.currentPage, | |
|                                 scrollDifference | |
|                             }); | |
|                         } else { | |
|                             // 不需要滚动,立即重置状态 | |
|                             resetScrollingState(); | |
|                             console.log('📍 目标已在视野内,无需滚动'); | |
|                         } | |
|                     } else { | |
|                         console.error('❌ 未找到目标元素或scroll-view:', { | |
|                             selector, | |
|                             scrollViewFound: !!scrollViewRect, | |
|                             targetFound: !!targetRect, | |
|                             currentPage: this.currentPage, | |
|                             highlightIndex: scrollData.highlightIndex | |
|                         }); | |
| 
 | |
|                         // 尝试备用方案:直接滚动到页面顶部附近 | |
|                         if (!targetRect) { | |
|                             console.log('🔄 尝试备用滚动方案'); | |
|                             // 改进备用方案:基于highlightIndex计算更准确的位置 | |
|                             const estimatedPosition = this.calculateEstimatedScrollPosition(scrollData.highlightIndex); | |
|                             this.$set(this.scrollTops, this.currentPage - 1, estimatedPosition); | |
|                             setTimeout(() => { | |
|                                 resetScrollingState(); | |
|                             }, 200); | |
|                         } else { | |
|                             // 立即重置状态 | |
|                             resetScrollingState(); | |
|                         } | |
|                     } | |
|                 }); | |
|         }, | |
| 
 | |
|         // 查找文本元素在页面中的实际索引 | |
|         findTextItemIndex(originalTextIndex) { | |
|             const currentPageData = this.bookPages[this.currentPage - 1]; | |
|             if (!currentPageData || !Array.isArray(currentPageData)) { | |
|                 return -1; | |
|             } | |
| 
 | |
|             let textCount = 0; | |
|             for (let i = 0; i < currentPageData.length; i++) { | |
|                 const item = currentPageData[i]; | |
|                 if (item && item.type === 'text' && item.content) { | |
|                     if (textCount === originalTextIndex) { | |
|                         return i; // 返回在页面数组中的实际索引 | |
|                     } | |
|                     textCount++; | |
|                 } | |
|             } | |
| 
 | |
|             return -1; // 未找到 | |
|         }, | |
| 
 | |
|         // 获取所有页面元素的位置信息 | |
|         async getAllElementPositions() { | |
|             return new Promise((resolve) => { | |
|                 const currentPageData = this.bookPages[this.currentPage - 1]; | |
|                 if (!currentPageData || !Array.isArray(currentPageData)) { | |
|                     resolve([]); | |
|                     return; | |
|                 } | |
| 
 | |
|                 const query = uni.createSelectorQuery().in(this); | |
|                 const elementPositions = []; | |
| 
 | |
|                 // 获取scroll-container的位置作为基准 | |
|                 query.select('.scroll-container').boundingClientRect(); | |
| 
 | |
|                 // 为每个元素添加查询 | |
|                 currentPageData.forEach((item, index) => { | |
|                     if (item && (item.type === 'text' || item.type === 'image' || item.type === 'video')) { | |
|                         if (item.type === 'text') { | |
|                             query.select(`#text-${index}`).boundingClientRect(); | |
|                         } else if (item.type === 'image') { | |
|                             query.select(`.image-container`).boundingClientRect(); | |
|                         } else if (item.type === 'video') { | |
|                             query.select(`.video-content`).boundingClientRect(); | |
|                         } | |
|                     } | |
|                 }); | |
| 
 | |
|                 query.exec((res) => { | |
|                     const containerRect = res[0]; | |
|                     if (!containerRect) { | |
|                         resolve([]); | |
|                         return; | |
|                     } | |
| 
 | |
|                     // 处理查询结果 | |
|                     let resultIndex = 1; // 跳过第一个容器结果 | |
|                     currentPageData.forEach((item, index) => { | |
|                         if (item && (item.type === 'text' || item.type === 'image' || item.type === 'video')) { | |
|                             const elementRect = res[resultIndex]; | |
|                             if (elementRect) { | |
|                                 elementPositions.push({ | |
|                                     index: index, | |
|                                     type: item.type, | |
|                                     top: elementRect.top - containerRect.top, | |
|                                     height: elementRect.height, | |
|                                     bottom: elementRect.top - containerRect.top + elementRect.height | |
|                                 }); | |
|                             } | |
|                             resultIndex++; | |
|                         } | |
|                     }); | |
| 
 | |
|                     resolve(elementPositions); | |
|                 }); | |
|             }); | |
|         }, | |
| 
 | |
|         // 计算精确的滚动位置 | |
|         async calculatePreciseScrollPosition(scrollData, elementPositions) { | |
|             if (!elementPositions || elementPositions.length === 0) { | |
|                 return null; | |
|             } | |
| 
 | |
|             let targetElementIndex = -1; | |
|              | |
|             if (scrollData.segmentIndex !== undefined) { | |
|                 // 分段音频情况 | |
|                 targetElementIndex = scrollData.segmentIndex; | |
|             } else if (scrollData.highlightIndex !== undefined) { | |
|                 // 普通音频情况,需要找到对应的文本元素 | |
|                 targetElementIndex = this.findTextItemIndex(scrollData.highlightIndex); | |
|             } | |
| 
 | |
|             if (targetElementIndex === -1) { | |
|                 return null; | |
|             } | |
| 
 | |
|             // 查找目标元素的位置信息 | |
|             const targetElement = elementPositions.find(pos => pos.index === targetElementIndex && pos.type === 'text'); | |
|              | |
|             if (!targetElement) { | |
|                 console.warn('未找到目标元素位置信息:', targetElementIndex); | |
|                 return null; | |
|             } | |
| 
 | |
|             // 计算滚动位置:让目标元素显示在屏幕上方1/4处 | |
|             const screenHeight = uni.getSystemInfoSync().windowHeight; | |
|             const offsetFromTop = screenHeight * 0.25; | |
|              | |
|             // 目标滚动位置 = 目标元素顶部位置 - 偏移量 | |
|             const targetScrollTop = Math.max(0, targetElement.top - offsetFromTop); | |
| 
 | |
|             console.log('🎯 精确滚动位置计算:', { | |
|                 targetElementIndex, | |
|                 targetElement, | |
|                 screenHeight, | |
|                 offsetFromTop, | |
|                 targetScrollTop | |
|             }); | |
| 
 | |
|             return targetScrollTop; | |
|          }, | |
| 
 | |
|          // 计算估算的滚动位置(备用方案) | |
|          calculateEstimatedScrollPosition(highlightIndex) { | |
|              const currentPageData = this.bookPages[this.currentPage - 1]; | |
|              if (!currentPageData || !Array.isArray(currentPageData)) { | |
|                  return highlightIndex * 80; // 基础估算 | |
|              } | |
| 
 | |
|              // 基于页面内容计算更准确的位置 | |
|              let estimatedHeight = 0; | |
|              let textCount = 0; | |
|               | |
|              for (let i = 0; i < currentPageData.length && textCount <= highlightIndex; i++) { | |
|                  const item = currentPageData[i]; | |
|                  if (item && item.type === 'text' && item.content) { | |
|                      if (textCount === highlightIndex) { | |
|                          break; | |
|                      } | |
|                      // 根据内容长度估算高度 | |
|                      const contentLength = item.content.length; | |
|                      estimatedHeight += Math.max(60, contentLength * 1.2); // 基础高度 + 内容长度因子 | |
|                      textCount++; | |
|                  } else if (item && item.type === 'image') { | |
|                      estimatedHeight += 200; // 图片估算高度 | |
|                  } else if (item && item.type === 'video') { | |
|                      estimatedHeight += 300; // 视频估算高度 | |
|                  } | |
|              } | |
| 
 | |
|              return Math.max(0, estimatedHeight - 100); // 留一些上边距 | |
|          }, | |
| 
 | |
|         // 获取音色列表 拿第一个做默认的音色id | |
|         async getVoiceList() { | |
|             const voiceRes = await this.$api.music.list() | |
|             if (voiceRes.code === 200) { | |
|                 // console.log('音色列表API返回:', voiceRes.result); | |
|                 // console.log('第一个音色数据:', voiceRes.result[0]); | |
|                 this.voiceId = Number(voiceRes.result[0].voiceType) | |
|                 // console.log('获取默认音色ID:', this.voiceId, '类型:', typeof this.voiceId); | |
|  | |
|                 // 同步默认音色设置到audioManager | |
|                 audioManager.setGlobalVoiceId(this.voiceId); | |
|             } | |
|         }, | |
|         toggleNavbar() { | |
|             this.showNavbar = !this.showNavbar | |
|         }, | |
|         goBack() { | |
|             uni.navigateBack() | |
|         }, | |
|         toggleCoursePopup() { | |
|             if (this.$refs.coursePopup) { | |
|                 this.$refs.coursePopup.open() | |
|             } | |
|             // console.log('123123123'); | |
|  | |
|         }, | |
|         toggleSort() { | |
|             this.isReversed = !this.isReversed | |
|         }, | |
|         selectCourse(courseId) { | |
|             this.currentCourse = courseId | |
|             this.courseId = courseId  // 同时更新 courseId | |
|             // 这里可以添加切换课程的逻辑 | |
|             // console.log('选择课程:', courseId) | |
|             this.getCourseList(courseId) | |
|         }, | |
|         showWordMeaning() { | |
|             if (this.$refs.meaningPopup) { | |
|                 this.$refs.meaningPopup.open() | |
|             } | |
|         }, | |
|         closeMeaningPopup() { | |
| 
 | |
|             this.currentWordMeaning = null; | |
| 
 | |
|             // 重置音频播放状态,确保后续句子点击能正常播放 | |
|  | |
| 
 | |
|             if (this.isWordAudioPlaying) { | |
| 
 | |
|                 this.isWordAudioPlaying = false; | |
| 
 | |
|                 // 如果有正在播放的音频,停止它 | |
|                 if (this.currentWordAudio) { | |
| 
 | |
|                     try { | |
|                         this.currentWordAudio.pause(); | |
|                         this.currentWordAudio.destroy(); | |
| 
 | |
|                     } catch (error) { | |
| 
 | |
|                     } | |
|                     this.currentWordAudio = null; | |
|                 } | |
|             } | |
|         }, | |
| 
 | |
|         // 将英文句子分割成单词数组 | |
|         splitEnglishSentence(sentence) { | |
|             // 使用正则表达式分割句子,保留标点符号 | |
|             const tokens = sentence.match(/\b\w+\b|[^\w\s]/g) || []; | |
|             return tokens.map((token, index) => ({ | |
|                 text: token, | |
|                 index: index, | |
|                 isWord: /\b\w+\b/.test(token), // 判断是否为单词 | |
|                 hasDefinition: false // 是否有释义,稍后会设置 | |
|             })); | |
|         }, | |
| 
 | |
|         // 查找单词释义 | |
|         findWordDefinition(word) { | |
|             const currentPageWords = this.pageWords[this.currentPage - 1] || []; | |
|             // 不区分大小写匹配 | |
|             return currentPageWords.find(wordData => | |
|                 wordData.word.toLowerCase() === word.toLowerCase() | |
|             ); | |
|         }, | |
| 
 | |
|         // 处理中文文本,标记重点词汇 | |
|         processChineseText(text) { | |
|             const currentPageWords = this.pageWords[this.currentPage - 1] || []; | |
| 
 | |
|             if (!text || currentPageWords.length === 0) { | |
|                 return [{ text: text, isKeyword: false, keywordData: null }]; | |
|             } | |
| 
 | |
|             // 创建一个数组来存储处理后的文本片段 | |
|             const segments = []; | |
|             let currentIndex = 0; | |
| 
 | |
|             // 按照重点词汇的长度排序,优先匹配较长的词汇 | |
|             const sortedWords = [...currentPageWords].sort((a, b) => b.word.length - a.word.length); | |
| 
 | |
|             while (currentIndex < text.length) { | |
|                 let matched = false; | |
| 
 | |
|                 // 尝试匹配重点词汇 | |
|                 for (const wordData of sortedWords) { | |
|                     const keyword = wordData.word; | |
|                     if (text.substr(currentIndex, keyword.length) === keyword) { | |
|                         // 找到匹配的重点词汇 | |
|                         segments.push({ | |
|                             text: keyword, | |
|                             isKeyword: true, | |
|                             keywordData: wordData | |
|                         }); | |
|                         currentIndex += keyword.length; | |
|                         matched = true; | |
|                         break; | |
|                     } | |
|                 } | |
| 
 | |
|                 if (!matched) { | |
|                     // 没有匹配到重点词汇,添加单个字符 | |
|                     segments.push({ | |
|                         text: text[currentIndex], | |
|                         isKeyword: false, | |
|                         keywordData: null | |
|                     }); | |
|                     currentIndex++; | |
|                 } | |
|             } | |
| 
 | |
|             // 合并相邻的非重点词汇片段 | |
|             const mergedSegments = []; | |
|             let currentSegment = null; | |
| 
 | |
|             for (const segment of segments) { | |
|                 if (segment.isKeyword) { | |
|                     // 如果当前有未完成的非重点词汇片段,先添加它 | |
|                     if (currentSegment) { | |
|                         mergedSegments.push(currentSegment); | |
|                         currentSegment = null; | |
|                     } | |
|                     // 添加重点词汇 | |
|                     mergedSegments.push(segment); | |
|                 } else { | |
|                     // 非重点词汇,合并到当前片段 | |
|                     if (currentSegment) { | |
|                         currentSegment.text += segment.text; | |
|                     } else { | |
|                         currentSegment = { ...segment }; | |
|                     } | |
|                 } | |
|             } | |
| 
 | |
|             // 添加最后的非重点词汇片段 | |
|             if (currentSegment) { | |
|                 mergedSegments.push(currentSegment); | |
|             } | |
| 
 | |
|             return mergedSegments; | |
|         }, | |
| 
 | |
|         // 初始化audioManager事件监听 | |
|         initAudioManagerListeners() { | |
|             // 监听音频播放状态变化 | |
|             audioManager.on('play', (data) => { | |
|                 if (data?.audioType === 'word') { | |
|                     this.isWordAudioPlaying = true; | |
|                 } | |
|             }); | |
| 
 | |
|             audioManager.on('pause', (data) => { | |
|                 if (data?.audioType === 'word') { | |
|                     this.isWordAudioPlaying = false; | |
|                 } | |
|             }); | |
| 
 | |
|             audioManager.on('ended', (data) => { | |
|                 if (data?.audioType === 'word') { | |
|                     this.isWordAudioPlaying = false; | |
|                 } | |
|             }); | |
| 
 | |
|             audioManager.on('error', (data) => { | |
|                 if (data?.audioType === 'word') { | |
|                     this.isWordAudioPlaying = false; | |
|                     uni.showToast({ | |
|                         title: '語音播放失敗', | |
|                         icon: 'none' | |
|                     }); | |
|                 } | |
|             }); | |
|         }, | |
| 
 | |
|         async playWordAudio(word) { | |
|             try { | |
|                 console.log('🎵 开始播放单词音频:', word); | |
| 
 | |
|                 // 🎯 使用audioManager的全局音色设置 | |
|                 const globalVoiceId = audioManager.getGlobalVoiceId(); | |
|                 const voiceIdToUse = globalVoiceId || this.voiceId; | |
| 
 | |
|                 if (!voiceIdToUse || voiceIdToUse === '' || voiceIdToUse === null || voiceIdToUse === undefined) { | |
|                     console.warn('⚠️ 音色ID未设置,无法播放音频'); | |
|                     uni.showToast({ | |
|                         title: '音色未加载,请稍后重试', | |
|                         icon: 'none', | |
|                         duration: 2000 | |
|                     }); | |
|                     return; | |
|                 } | |
| 
 | |
|                 console.log('🎵 使用音色ID:', voiceIdToUse, '播放文本:', word); | |
| 
 | |
|                 // 調用語音轉換API | |
|                 const audioRes = await this.$api.music.textToVoice({ | |
|                     text: word, | |
|                     voiceType: voiceIdToUse | |
|                 }); | |
| 
 | |
|                 console.log('🎵 API响应:', audioRes); | |
| 
 | |
|                 // 檢查響應並播放音頻 | |
|                 if (audioRes && audioRes.result && audioRes.result.url) { | |
|                     console.log('✅ 获取到音频URL:', audioRes.result.url); | |
| 
 | |
|                     // 使用audioManager播放音频,应用全局语速设置 | |
|                     await audioManager.playAudio(audioRes.result.url, 'word', { | |
|                         playbackRate: audioManager.getGlobalPlaybackRate() | |
|                     }); | |
|                 } else { | |
|                     console.error('❌ API响应无效:', audioRes); | |
|                     uni.showToast({ | |
|                         title: '語音播放失敗', | |
|                         icon: 'none' | |
|                     }); | |
|                 } | |
|             } catch (error) { | |
|                 console.error('❌ 播放单词语音异常:', error); | |
|                 uni.showToast({ | |
|                     title: '語音播放失敗', | |
|                     icon: 'none' | |
|                 }); | |
|             } | |
|         }, | |
| 
 | |
| 
 | |
| 
 | |
|         // 重複播放單詞語音(用於釋義彈窗中的揚聲器圖標) | |
|         repeatWordAudio() { | |
|             if (this.currentWordMeaning && this.currentWordMeaning.word) { | |
|                 // 将单词和解释合并后播放音频 | |
|                 const combinedText = `${this.currentWordMeaning.word}。${this.currentWordMeaning.meaning || ''}`; | |
| 
 | |
|                 this.playWordAudio(combinedText); | |
|             } else { | |
|                 console.warn('沒有當前單詞可以播放'); | |
|             } | |
|         }, | |
| 
 | |
|         // 处理单词点击事件 | |
|         handleWordClick(word) { | |
| 
 | |
|             const definition = this.findWordDefinition(word); | |
| 
 | |
|             if (definition) { | |
| 
 | |
|                 this.currentWordMeaning = { | |
|                     word: definition.word, | |
|                     phonetic: definition.soundmark || '', | |
|                     partOfSpeech: '', // 可以根据需要添加词性 | |
|                     meaning: definition.paraphrase || '', | |
|                     knowledgeGain: definition.knowledge || '', | |
|                     image: definition.image || '' | |
|                 }; | |
| 
 | |
| 
 | |
|                 // 将单词和解释合并后播放音频 | |
|                 const combinedText = `${word}。${definition.paraphrase || ''}`; | |
| 
 | |
|                 this.playWordAudio(combinedText); | |
| 
 | |
| 
 | |
|                 this.showWordMeaning(); | |
|             } else { | |
| 
 | |
| 
 | |
|                 // 如果没有释义,只播放单词 | |
|                 if (word) { | |
|                     this.playWordAudio(word); | |
|                 } else { | |
| 
 | |
|                 } | |
|             } | |
| 
 | |
|         }, | |
| 
 | |
|         // 处理中文重点词汇点击事件 | |
|         handleChineseKeywordClick(keywordData) { | |
| 
 | |
| 
 | |
|             if (keywordData) { | |
| 
 | |
|                 this.currentWordMeaning = { | |
|                     word: keywordData.word, | |
|                     phonetic: keywordData.soundmark || '', | |
|                     partOfSpeech: '', // 可以根据需要添加词性 | |
|                     meaning: keywordData.paraphrase || '', | |
|                     knowledgeGain: keywordData.knowledge || '', | |
|                     image: keywordData.image || '' | |
|                 }; | |
| 
 | |
| 
 | |
|                 // 将词汇和解释合并后播放音频 | |
|                 const combinedText = `${keywordData.word}。${keywordData.paraphrase || ''}`; | |
| 
 | |
|                 this.playWordAudio(combinedText); | |
| 
 | |
| 
 | |
|                 this.showWordMeaning(); | |
|             } else { | |
| 
 | |
|             } | |
| 
 | |
|         }, | |
| 
 | |
|         // 计算音频总时长 | |
|         async calculateTotalDuration() { | |
|             let totalDuration = 0; | |
|             for (let i = 0; i < this.currentPageAudios.length; i++) { | |
|                 const audio = this.currentPageAudios[i]; | |
| 
 | |
|                 // 優先使用API返回的時長信息 | |
|                 if (audio.duration && audio.duration > 0) { | |
| 
 | |
|                     totalDuration += audio.duration; | |
|                     continue; | |
|                 } | |
| 
 | |
|                 // 如果沒有API時長信息,嘗試獲取音頻時長 | |
|                 try { | |
|                     const duration = await this.getAudioDuration(audio.url); | |
|                     audio.duration = duration; | |
|                     totalDuration += duration; | |
| 
 | |
|                 } catch (error) { | |
|                     console.error('获取音频时长失败:', error); | |
|                     // 如果无法获取时长,根據文字長度估算(更精確的估算) | |
|                     const textLength = audio.text.length; | |
|                     // 假設每分鐘可以讀150-200個字符,這裡用180作為平均值 | |
|                     const estimatedDuration = Math.max(2, textLength / 3); // 每3個字符約1秒 | |
|                     audio.duration = estimatedDuration; | |
|                     totalDuration += estimatedDuration; | |
|                     console.log(`估算音頻時長 ${i + 1}:`, estimatedDuration, '秒 (文字長度:', textLength, ')'); | |
|                 } | |
|             } | |
|             this.totalTime = totalDuration; | |
| 
 | |
|         }, | |
| 
 | |
|         // 获取音频时长 | |
|         getAudioDuration(audioUrl) { | |
|             return new Promise((resolve, reject) => { | |
|                 const audio = uni.createInnerAudioContext(); | |
|                 audio.src = audioUrl; | |
| 
 | |
|                 let resolved = false; | |
| 
 | |
|                 // 监听音频加载完成事件 | |
|                 audio.onCanplay(() => { | |
| 
 | |
|                     if (!resolved && audio.duration && audio.duration > 0) { | |
|                         resolved = true; | |
|                         resolve(audio.duration); | |
|                         audio.destroy(); | |
|                     } | |
|                 }); | |
| 
 | |
|                 // 监听音频元数据加载完成事件 | |
|                 audio.onLoadedmetadata = () => { | |
| 
 | |
|                     if (!resolved && audio.duration && audio.duration > 0) { | |
|                         resolved = true; | |
|                         resolve(audio.duration); | |
|                         audio.destroy(); | |
|                     } | |
|                 }; | |
| 
 | |
|                 // 监听音频时长更新事件 | |
|                 audio.onDurationChange = () => { | |
| 
 | |
|                     if (!resolved && audio.duration && audio.duration > 0) { | |
|                         resolved = true; | |
|                         resolve(audio.duration); | |
|                         audio.destroy(); | |
|                     } | |
|                 }; | |
| 
 | |
|                 // 如果以上方法都無法獲取時長,嘗試播放一小段來獲取時長 | |
|                 audio.onPlay(() => { | |
| 
 | |
|                     if (!resolved) { | |
|                         setTimeout(() => { | |
|                             if (!resolved && audio.duration && audio.duration > 0) { | |
|                                 resolved = true; | |
|                                 resolve(audio.duration); | |
|                                 audio.destroy(); | |
|                             } | |
|                         }, 100); // 播放100ms後檢查時長 | |
|                     } | |
|                 }); | |
| 
 | |
|                 audio.onError((error) => { | |
|                     console.error('音频加载失败:', error); | |
|                     if (!resolved) { | |
|                         resolved = true; | |
|                         reject(error); | |
|                         audio.destroy(); | |
|                     } | |
|                 }); | |
| 
 | |
|                 // 設置較長的超時時間,並在超時前嘗試播放 | |
|                 setTimeout(() => { | |
|                     if (!resolved) { | |
| 
 | |
|                         audio.play(); | |
|                     } | |
|                 }, 1000); | |
| 
 | |
|                 // 最終超時處理 | |
|                 setTimeout(() => { | |
|                     if (!resolved) { | |
|                         console.warn('獲取音頻時長超時,使用默認值'); | |
|                         resolved = true; | |
|                         reject(new Error('获取音频时长超时')); | |
|                         audio.destroy(); | |
|                     } | |
|                 }, 5000); | |
|             }); | |
|         }, | |
| 
 | |
|         // 音频控制方法 - 这些方法现在由AudioControls组件处理 | |
|         // 保留一些简单的接口方法用于与AudioControls组件通信 | |
|  | |
|         // 判断当前文本是否应该高亮 - 这个方法需要保留,因为它用于模板渲染 | |
|         isTextHighlighted(page, index) { | |
|             // 只有当前页面且是文本类型才可能高亮 | |
|             if (page !== this.bookPages[this.currentPage - 1]) return false; | |
| 
 | |
|             // 计算当前页面中text类型元素的索引 | |
|             let textIndex = 0; | |
|             for (let i = 0; i <= index; i++) { | |
|                 if (page[i].type === 'text') { | |
|                     if (i === index) { | |
|                         const shouldHighlight = textIndex === this.currentHighlightIndex; | |
|                         if (shouldHighlight) { | |
| 
 | |
|                         } | |
|                         return shouldHighlight; | |
|                     } | |
|                     textIndex++; | |
|                 } | |
|             } | |
|             return false; | |
|         }, | |
| 
 | |
|         // 判断划线重点卡片中的文本是否应该高亮 | |
|         isCardTextHighlighted(page, index) { | |
|             // 只有当前页面且是文本类型才可能高亮 | |
|             if (page !== this.bookPages[this.currentPage - 1]) return false; | |
| 
 | |
|             // 计算当前页面中text类型元素的索引 | |
|             let textIndex = 0; | |
|             for (let i = 0; i <= index; i++) { | |
|                 if (page[i].type === 'text') { | |
|                     if (i === index) { | |
|                         return textIndex === this.currentHighlightIndex; | |
|                     } | |
|                     textIndex++; | |
|                 } | |
|             } | |
|             return false; | |
|         }, | |
| 
 | |
|         async previousPage() { | |
|             if (this.currentPage > 1) { | |
|                 this.currentPage--; | |
|                 // 获取对应页面的数据(如果还没有获取过) | |
|                 if (this.courseIdList[this.currentPage - 1] && this.bookPages[this.currentPage - 1].length === 0) { | |
|                     await this.getBookPages(this.courseIdList[this.currentPage - 1]); | |
|                 } | |
|             } | |
|         }, | |
|         async nextPage() { | |
|             if (this.currentPage < this.bookPages.length) { | |
|                 this.currentPage++; | |
|                 // 获取对应页面的数据(如果还没有获取过) | |
|                 if (this.courseIdList[this.currentPage - 1] && this.bookPages[this.currentPage - 1].length === 0) { | |
|                     await this.getBookPages(this.courseIdList[this.currentPage - 1]); | |
|                 } | |
|             } | |
|         }, | |
|         toggleSound() { | |
|             // 检查是否正在加载音频,如果是则阻止音色切换 | |
|             if (this.isAudioLoading) { | |
|                 uni.showToast({ | |
|                     title: '音频加载中,请稍后再试', | |
|                     icon: 'none', | |
|                     duration: 2000 | |
|                 }); | |
|                 return; | |
|             } | |
| 
 | |
|             // 检查AudioControls组件是否正在加载音频 | |
|             if (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls && this.$refs.customTabbar.$refs.audioControls.isAudioLoading) { | |
|                 uni.showToast({ | |
|                     title: '音频加载中,请稍后再试', | |
|                     icon: 'none', | |
|                     duration: 2000 | |
|                 }); | |
|                 return; | |
|             } | |
| 
 | |
|             console.log('音色切换') | |
|             uni.navigateTo({ | |
|                 url: '/subPages/home/music?voiceId=' + this.voiceId | |
|             }) | |
|         }, | |
|         unlockBook() { | |
|             console.log('解锁全书') | |
|             // 这里可以跳转到会员页面或者调用解锁接口 | |
|             uni.navigateTo({ | |
|                 url: '/subPages/member/recharge' | |
|             }) | |
|         }, | |
| 
 | |
|         // 跳转到下一课 | |
|         async goToNextCourse() { | |
|             if (!this.hasNextCourse) { | |
|                 uni.showToast({ | |
|                     title: '已经是最后一课了', | |
|                     icon: 'none', | |
|                     duration: 2000 | |
|                 }); | |
|                 return; | |
|             } | |
| 
 | |
|             try { | |
|                 // 找到当前课程在课程列表中的索引 | |
|                 const currentCourseIndex = this.courseList.findIndex(course => course.id == this.courseId); | |
|                 if (currentCourseIndex >= 0 && currentCourseIndex < this.courseList.length - 1) { | |
|                     // 获取下一课的ID | |
|                     const nextCourse = this.courseList[currentCourseIndex + 1]; | |
|                     console.log('跳转到下一课:', nextCourse); | |
|                      | |
|                     // 切换到下一课 | |
|                     await this.selectCourse(nextCourse.id); | |
|                      | |
|                     uni.showToast({ | |
|                         title: `已切换到第${currentCourseIndex + 2}课`, | |
|                         icon: 'success', | |
|                         duration: 2000 | |
|                     }); | |
|                 } | |
|             } catch (error) { | |
|                 console.error('跳转下一课失败:', error); | |
|                 uni.showToast({ | |
|                     title: '跳转失败,请重试', | |
|                     icon: 'none', | |
|                     duration: 2000 | |
|                 }); | |
|             } | |
|         }, | |
| 
 | |
|         // 回到开始(当前课程的第一页) | |
|         async backToStart() { | |
|             try { | |
|                 // 回到当前课程的第一页 | |
|                 this.currentPage = 1; | |
|                 console.log('回到开始,跳转到第一页'); | |
|                  | |
|                 // 获取第一页的数据(如果还没有获取过) | |
|                 if (this.courseIdList[0] && this.bookPages[0].length === 0) { | |
|                     await this.getBookPages(this.courseIdList[0]); | |
|                 } | |
|                  | |
|                 uni.showToast({ | |
|                     title: '已回到第一页', | |
|                     icon: 'success', | |
|                     duration: 2000 | |
|                 }); | |
|             } catch (error) { | |
|                 console.error('回到开始失败:', error); | |
|                 uni.showToast({ | |
|                     title: '操作失败,请重试', | |
|                     icon: 'none', | |
|                     duration: 2000 | |
|                 }); | |
|             } | |
|         }, | |
| 
 | |
|         async goToPage(page) { | |
|             this.currentPage = page | |
|             console.log('跳转到页面:', page) | |
|             // 获取对应页面的数据(如果还没有获取过) | |
|             if (this.courseIdList[this.currentPage - 1] && this.bookPages[this.currentPage - 1].length === 0) { | |
|                 await this.getBookPages(this.courseIdList[this.currentPage - 1]); | |
|             } | |
|         }, | |
|         async onSwiperChange(e) { | |
|             this.currentPage = e.detail.current + 1 | |
|             // 获取对应页面的数据(如果还没有获取过) | |
|             if (this.courseIdList[this.currentPage - 1] && this.bookPages[this.currentPage - 1].length === 0) { | |
|                 await this.getBookPages(this.courseIdList[this.currentPage - 1]); | |
|             } | |
|         }, | |
|         async getCourseList(id) { | |
|             const res = await this.$api.book.coursePage({ | |
|                 id: id | |
|             }) | |
|             if (res.code === 200) { | |
|                 // 课程切换时,先清理音频控制组件的所有数据 | |
|                 if (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls) { | |
|                     this.$refs.customTabbar.$refs.audioControls.resetForCourseChange(); | |
|                 } | |
| 
 | |
|                 // 清空当前页面相关数据 | |
|                 this.currentPage = 1; // 重置到第一页 | |
|                 this.currentCourse = 1; // 重置当前课程索引 | |
|                 this.currentWordMeaning = null; // 清空单词释义 | |
|                 this.currentWordAudio = null; // 清空单词音频 | |
|                 this.currentHighlightIndex = -1; // 清空高亮索引 | |
|  | |
|                 // 清理单词音频缓存 | |
|                 this.clearWordAudioCache(); | |
| 
 | |
|                 // 重新初始化课程数据 | |
|                 this.courseIdList = res.result.map(item => item.id) | |
|                 // 初始化二维数组 换一种方式 | |
|                 this.bookPages = this.courseIdList.map(() => []) | |
|                 // 初始化标题数组 | |
|                 this.pageTitles = this.courseIdList.map(() => '') | |
|                 // 初始化页面类型数组 | |
|                 this.pageTypes = this.courseIdList.map(() => '') | |
|                 // 初始化页面单词数组 | |
|                 this.pageWords = this.courseIdList.map(() => []) | |
|                 // 初始化滚动位置数组 | |
|                 this.scrollTops = this.courseIdList.map(() => 0) | |
| 
 | |
| 
 | |
| 
 | |
|                 // 初始化第一页 | |
|                 if (this.courseIdList.length > 0) { | |
|                     await this.getBookPages(this.courseIdList[0]) | |
| 
 | |
|                     // 课程切换后,确保音频控件能正确加载新课程的音频 | |
|  | |
| 
 | |
|                     // 使用$nextTick确保DOM和数据都已更新 | |
|                     this.$nextTick(async () => { | |
|                         if (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls) { | |
| 
 | |
|                             try { | |
|                                 // 直接调用getCurrentPageAudio方法,更可靠 | |
|                                 await this.$refs.customTabbar.$refs.audioControls.getCurrentPageAudio(); | |
| 
 | |
|                             } catch (error) { | |
|                                 console.error('课程切换后音频加载失败:', error); | |
|                             } | |
|                         } | |
|                     }); | |
| 
 | |
|                     // 预加载后续几页的内容(异步执行,不阻塞当前页面显示) | |
|                     this.preloadNextPages() | |
|                 } | |
|             } | |
|         }, | |
|         async getBookPages(id) { | |
|             const res = await this.$api.book.coursesPageDetail({ | |
|                 id: id | |
|             }) | |
|             if (res.code === 200) { | |
|                 // 使用$set确保响应式更新 | |
|                 const rawPageData = JSON.parse(res.result.content) | |
|                 console.log('获取到的原始页面数据:', rawPageData) | |
| 
 | |
|                 // 过滤掉无效的数据项 | |
|                 const filteredPageData = rawPageData.filter(item => { | |
|                     return item && typeof item === 'object' && (item.type || item.content) | |
|                 }) | |
|                 console.log('过滤后的页面数据:', filteredPageData) | |
| 
 | |
|                 // 确保当前页面存在 | |
|                 if (this.currentPage - 1 < this.bookPages.length) { | |
|                     this.$set(this.bookPages, this.currentPage - 1, filteredPageData) | |
|                     // 保存页面标题 | |
|                     this.$set(this.pageTitles, this.currentPage - 1, res.result.title || '') | |
|                     // 保存页面类型 | |
|                     this.$set(this.pageTypes, this.currentPage - 1, res.result.type || '') | |
|                     // 保存页面单词释义数据 | |
|                     this.$set(this.pageWords, this.currentPage - 1, res.result.words || []) | |
|                     // 保存页面付费状态 | |
|                     this.$set(this.pagePay, this.currentPage - 1, res.result.pay || 'N') | |
|                 } | |
|             } | |
|         }, | |
|         // 获取课程列表 | |
|         async getCoursePageList(bookId) { | |
|             const res = await this.$api.book.course({ | |
|                 id: bookId | |
|             }) | |
|             if (res.code === 200) { | |
|                 this.courseList = res.result.records | |
|                 // 打上序列号 | |
|                 this.courseList = this.courseList.map((item, index) => ({ | |
|                     ...item, | |
|                     index, | |
|                 })) | |
|             } | |
|         }, | |
| 
 | |
|         // 清理音频缓存 | |
|         clearAudioCache() { | |
|             this.audioCache = {}; | |
| 
 | |
|         }, | |
| 
 | |
|         // 清理單詞語音緩存 | |
|         clearWordAudioCache() { | |
|             this.wordAudioCache = {}; | |
| 
 | |
|         }, | |
| 
 | |
|         // 限制缓存大小,保留最近访问的页面 | |
|         limitCacheSize(maxSize = 10) { | |
|             const cacheKeys = Object.keys(this.audioCache); | |
|             if (cacheKeys.length > maxSize) { | |
|                 // 删除最旧的缓存项 | |
|                 const keysToDelete = cacheKeys.slice(0, cacheKeys.length - maxSize); | |
|                 keysToDelete.forEach(key => { | |
|                     delete this.audioCache[key]; | |
|                 }); | |
| 
 | |
|             } | |
|         }, | |
| 
 | |
|         // 预加载后续页面内容 | |
|         async preloadNextPages() { | |
|             try { | |
| 
 | |
| 
 | |
|                 // 优化策略:只预加载接下来的2-3页内容,避免过多请求 | |
|                 const preloadCount = Math.min(3, this.courseIdList.length - 1); // 预加载3页或剩余页数 | |
|  | |
|                 // 串行预加载,避免并发请求过多 | |
|                 for (let i = 1; i <= preloadCount; i++) { | |
|                     if (i < this.courseIdList.length && this.bookPages[i].length === 0) { | |
|                         try { | |
| 
 | |
|                             await this.preloadSinglePage(this.courseIdList[i], i); | |
| 
 | |
|                             // 每页之间间隔800ms,给服务器更多缓冲时间 | |
|                             if (i < preloadCount) { | |
|                                 await new Promise(resolve => setTimeout(resolve, 800)); | |
|                             } | |
|                         } catch (error) { | |
|                             console.error(`预加载第${i + 1}页失败:`, error); | |
|                             // 继续预加载下一页 | |
|                         } | |
|                     } | |
|                 } | |
| 
 | |
| 
 | |
| 
 | |
|                 // 延迟1.5秒后再通知AudioControls组件开始预加载音频,避免接口冲突 | |
|                 setTimeout(() => { | |
|                     if (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls) { | |
|                         this.$refs.customTabbar.$refs.audioControls.startPreloadAudio(); | |
|                     } | |
|                 }, 1500); | |
| 
 | |
|             } catch (error) { | |
|                 console.error('预加载页面内容失败:', error); | |
|             } | |
|         }, | |
| 
 | |
|         // 预加载单个页面 | |
|         async preloadSinglePage(courseId, pageIndex) { | |
|             try { | |
|                 const res = await this.$api.book.coursesPageDetail({ | |
|                     id: courseId | |
|                 }); | |
| 
 | |
|                 if (res.code === 200) { | |
|                     const rawPageData = JSON.parse(res.result.content); | |
|                     const filteredPageData = rawPageData.filter(item => { | |
|                         return item && typeof item === 'object' && (item.type || item.content); | |
|                     }); | |
| 
 | |
|                     // 使用$set确保响应式更新 | |
|                     this.$set(this.bookPages, pageIndex, filteredPageData); | |
|                     this.$set(this.pageTitles, pageIndex, res.result.title || ''); | |
|                     this.$set(this.pageTypes, pageIndex, res.result.type || ''); | |
|                     this.$set(this.pageWords, pageIndex, res.result.words || []); | |
|                     this.$set(this.pagePay, pageIndex, res.result.pay || 'N'); | |
| 
 | |
| 
 | |
|                 } | |
|             } catch (error) { | |
|                 console.error(`预加载第${pageIndex + 1}页失败:`, error); | |
|                 throw error; | |
|             } | |
|         }, | |
| 
 | |
|         // 自動加載第一頁音頻並播放 | |
|         async autoLoadAndPlayFirstPage() { | |
|             try { | |
| 
 | |
| 
 | |
|                 // 確保當前是第一頁且需要加載音頻 | |
|                 if (this.currentPage === 1 && this.shouldLoadAudio) { | |
| 
 | |
| 
 | |
|                     // 加載音頻 | |
|                     await this.getCurrentPageAudio(); | |
| 
 | |
|                     // 檢查是否成功加載音頻 | |
|                     if (this.currentPageAudios && this.currentPageAudios.length > 0) { | |
| 
 | |
|                         // getCurrentPageAudio方法已經處理了第一個音頻的播放,這裡不需要再次調用playAudio | |
|                     } else { | |
| 
 | |
|                     } | |
|                 } else { | |
| 
 | |
|                 } | |
|             } catch (error) { | |
|                 console.error('自動加載和播放音頻失敗:', error); | |
|             } | |
|         }, | |
|     }, | |
|     async onLoad(args) { | |
|         this.$scrollTo('imageRef') | |
| 
 | |
|         // 初始化audioManager事件监听 | |
|         this.initAudioManagerListeners(); | |
| 
 | |
|         // 监听音色切换事件,传递给AudioControls组件处理 | |
|         uni.$on('selectVoice', async (voiceId) => { | |
|             if (this.voiceId === voiceId) { | |
| 
 | |
|                 return; | |
|             } | |
| 
 | |
|             // 检查是否正在加载音频,如果是则阻止音色切换 | |
|             if (this.isAudioLoading || (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls && this.$refs.customTabbar.$refs.audioControls.isAudioLoading)) { | |
| 
 | |
|                 uni.showToast({ | |
|                     title: '音频加载中,请稍后再试', | |
|                     icon: 'none', | |
|                     duration: 2000 | |
|                 }); | |
|                 return; | |
|             } | |
| 
 | |
|             // 更新本地音色ID | |
|             this.voiceId = voiceId; | |
| 
 | |
|             // 同步音色设置到audioManager | |
|             audioManager.setGlobalVoiceId(voiceId); | |
| 
 | |
|             // 清理單詞語音資源 | |
|             this.clearWordAudioCache(); | |
|             // 停止当前播放的音频(现在由audioManager统一管理) | |
|             audioManager.stopCurrentAudio(); | |
| 
 | |
|             // 通知AudioControls组件处理音色切换 | |
|             if (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls) { | |
|                 try { | |
| 
 | |
|                     // 传入选项:preloadAllPages: true 表示要预加载所有页面的音频 | |
|                     await this.$refs.customTabbar.$refs.audioControls.handleVoiceChange(voiceId, { | |
|                         preloadAllPages: true | |
|                     }); | |
| 
 | |
|                 } catch (error) { | |
|                     console.error('音色切换处理失败:', error); | |
|                 } | |
|             } | |
|         }) | |
| 
 | |
|         this.courseId = args.courseId | |
|         this.currentCourse = args.courseId  // 同时设置 currentCourse | |
|         this.memberId = args.memberId | |
| 
 | |
|         // 先获取点进来的课程的页面列表 | |
|         await Promise.all([this.getVoiceList(), this.getMemberInfo(), this.getCourseList(this.courseId), this.getCoursePageList(args.bookId)]) | |
| 
 | |
|         // 页面加载完成后,通知AudioControls组件自动加载第一页音频 | |
|         this.$nextTick(() => { | |
|             if (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls) { | |
|                 this.$refs.customTabbar.$refs.audioControls.autoLoadAndPlayFirstPage(); | |
|             } | |
|         }); | |
| 
 | |
|     }, | |
|     // 页面卸载时清理资源 | |
|     onUnload() { | |
| 
 | |
| 
 | |
|         uni.$off('selectVoice') | |
| 
 | |
|         // 0. 清理滚动防抖定时器 | |
|         if (this.scrollDebounceTimer) { | |
|             clearTimeout(this.scrollDebounceTimer); | |
|             this.scrollDebounceTimer = null; | |
|         } | |
| 
 | |
|         // 1. 清理单词语音资源 | |
|         if (this.currentWordAudio) { | |
| 
 | |
|             try { | |
|                 this.currentWordAudio.destroy(); | |
|             } catch (error) { | |
|                 console.error('销毁单词音频实例失败:', error); | |
|             } | |
|             this.currentWordAudio = null; | |
|         } | |
| 
 | |
|         // 2. 清理单词语音缓存 | |
|         this.clearWordAudioCache(); | |
| 
 | |
|         // 3. 停止单词音频播放状态 | |
|         this.isWordAudioPlaying = false; | |
| 
 | |
|         // 4. 通知AudioControls组件清理资源 | |
|         if (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls) { | |
| 
 | |
|             this.$refs.customTabbar.$refs.audioControls.destroyAudio(); | |
|         } | |
| 
 | |
|         // 5. 清理全局音频实例(防止遗漏) | |
|         try { | |
|             // 获取所有可能的音频上下文并销毁 | |
|             if (typeof wx !== 'undefined' && wx.getBackgroundAudioManager) { | |
|                 const bgAudio = wx.getBackgroundAudioManager(); | |
|                 if (bgAudio) { | |
|                     bgAudio.stop(); | |
|                 } | |
|             } | |
|         } catch (error) { | |
|             console.error('清理背景音频失败:', error); | |
|         } | |
| 
 | |
| 
 | |
|     }, | |
| 
 | |
|     // 页面隐藏时暂停音频 | |
|     onHide() { | |
| 
 | |
| 
 | |
|         // 1. 暂停单词音频 | |
|         if (this.currentWordAudio && this.isWordAudioPlaying) { | |
| 
 | |
|             try { | |
|                 this.currentWordAudio.pause(); | |
|                 this.isWordAudioPlaying = false; | |
|             } catch (error) { | |
|                 console.error('暂停单词音频失败:', error); | |
|             } | |
|         } | |
| 
 | |
|         // 2. 通知AudioControls组件暂停音频 | |
|         if (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls) { | |
| 
 | |
|             this.$refs.customTabbar.$refs.audioControls.pauseOnHide(); | |
|         } | |
| 
 | |
| 
 | |
|     } | |
| } | |
| </script> | |
| 
 | |
| <style lang="scss" scoped> | |
| .book-container { | |
|     width: 100%; | |
|     min-height: 100vh; | |
|     background-color: #F8F8F8; | |
|     position: relative; | |
|     overflow: hidden; | |
| } | |
| 
 | |
| .custom-navbar { | |
|     position: fixed; | |
|     top: 0; | |
|     left: 0; | |
|     right: 0; | |
|     background-color: #F8F8F8; | |
|     z-index: 1000; | |
|     transition: transform 0.3s ease; | |
| 
 | |
|     &.navbar-hidden { | |
|         transform: translateY(-100%); | |
|     } | |
| } | |
| 
 | |
| .navbar-content { | |
|     display: flex; | |
|     align-items: center; | |
|     justify-content: space-between; | |
|     padding: 20rpx 32rpx; | |
|     // padding-top: calc(20rpx + var(--status-bar-height, 0)); | |
|     height: 60rpx; | |
| } | |
| 
 | |
| .navbar-left, | |
| .navbar-right { | |
|     width: 80rpx; | |
|     display: flex; | |
|     align-items: center; | |
| } | |
| 
 | |
| .navbar-right { | |
| 
 | |
|     justify-content: flex-end; | |
|     flex: 1; | |
| } | |
| 
 | |
| .navbar-title { | |
|     transform: translateX(-50rpx); | |
|     flex: 1; | |
|     text-align: center; | |
|     font-family: PingFang SC; | |
|     font-weight: 500; | |
|     font-size: 32rpx; | |
|     color: #262626; | |
|     line-height: 48rpx; | |
| } | |
| 
 | |
| .content-swiper { | |
|     flex: 1; | |
|     // min-height: calc(100vh - 100rpx); | |
|     // margin-top: 100rpx; | |
|  | |
|     height: 100vh; | |
| } | |
| 
 | |
| .swiper-item { | |
|     min-height: 100vh; | |
|     // background-color: red; | |
|  | |
| } | |
| 
 | |
| .content-area { | |
|     flex: 1; | |
|     padding: 30rpx 40rpx 100rpx; | |
|     /* #ifndef H5 */ | |
|     padding: 100rpx 40rpx; | |
|     /* #endif */ | |
|     // padding-top: ; | |
|     // background: linear-gradient(180deg, #DEFFFF 0%, #FBFEFF 22.65%, #F0FBFF 100%); | |
|     min-height: 100%; | |
|     box-sizing: border-box; | |
|     overflow-y: auto; | |
| 
 | |
|     .title { | |
|         font-family: PingFang SC; | |
|         font-weight: 500; | |
|         font-size: 34rpx; | |
|         text-align: center; | |
|         color: #181818; | |
|         line-height: 48rpx; | |
|         margin-bottom: 32rpx; | |
|     } | |
| 
 | |
|     .image-container { | |
|         width: 100%; | |
|         display: flex; | |
|         justify-content: center; | |
|         align-items: center; | |
|         margin: 30rpx 0; | |
| 
 | |
|         /* 平板设备适配 */ | |
|         @media screen and (min-width: 768px) { | |
|             margin: 40rpx 0; | |
|         } | |
|     } | |
| 
 | |
|     .content-image { | |
|         width: 100%; | |
|         height: auto; | |
|         //max-width: 600rpx; | |
|         /* 限制最大宽度,避免在大屏设备上过大 */ | |
|         display: block; | |
|         border-radius: 12rpx; | |
|         /* 添加圆角,提升视觉效果 */ | |
|         box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1); | |
|         /* 添加阴影,增强层次感 */ | |
| 
 | |
|         /* 平板设备适配 */ | |
|         // @media screen and (min-width: 768px) { | |
|         //     max-width: 500rpx; | |
|         // } | |
|     } | |
| 
 | |
|     .video-content { | |
|         width: 100%; | |
|         height: auto; | |
|         margin: 30rpx auto; | |
|         position: relative; | |
| 
 | |
|         .video-player { | |
|             // height: 100%; | |
|             width: 100%; | |
|             height: 60vw; | |
|             // margin: 0 auto; | |
|             // height: auto; | |
|         } | |
| 
 | |
|         .video-loading { | |
|             position: absolute; | |
|             top: 50%; | |
|             left: 50%; | |
|             transform: translate(-50%, -50%); | |
|             color: #666; | |
|             font-size: 28rpx; | |
|         } | |
|     } | |
| } | |
| 
 | |
| .card-content { | |
|     background: linear-gradient(180deg, #DEFFFF 0%, #FBFEFF 22.65%, #F0FBFF 100%); | |
|     display: flex; | |
|     flex-direction: column; | |
|     gap: 32rpx; | |
|     min-height: 1172rpx; | |
|     margin-top: 20rpx; | |
|     border-radius: 32rpx; | |
|     // height: 100%; | |
|     padding: 20rpx; | |
|     padding-bottom: 100rpx; | |
|     // margin: 0  | |
|     border: 1px solid #FFFFFF; | |
|     box-sizing: border-box; | |
| 
 | |
|     .card-line { | |
|         display: flex; | |
|         align-items: center; | |
|         // margin-bottom: 20rpx; | |
|         padding: 20rpx; | |
|         padding-bottom: 0; | |
|     } | |
| 
 | |
|     .card-line-image { | |
|         width: 48rpx; | |
|         height: 48rpx; | |
|         margin-right: 16rpx; | |
|     } | |
| 
 | |
|     .card-line-text { | |
|         font-family: PingFang SC; | |
|         font-weight: 600; | |
|         font-size: 30rpx; | |
|         line-height: 48rpx; | |
|         color: #3B3D3D; | |
| 
 | |
|     } | |
| 
 | |
|     .card-image { | |
|         // width: 590rpx; | |
|         width: 100%; | |
|         height: 268rpx; | |
|         border-radius: 24rpx; | |
|         margin: 30rpx auto; | |
|         // margin-bottom: 20rpx; | |
|     } | |
| 
 | |
|     // .english-text { | |
|     //     display: block; | |
|     //     font-family: PingFang SC; | |
|     //     font-weight: 600; | |
|     //     font-size: 32rpx; | |
|     //     line-height: 48rpx; | |
|     //     color: #3B3D3D; | |
|     //     // margin-bottom: 16rpx; | |
|     // } | |
|  | |
|     // .english-text-container { | |
|     //     display: flex; | |
|     //     flex-wrap: wrap; | |
|     //     align-items: baseline; | |
|     // } | |
|  | |
|     // .english-token { | |
|     //     font-family: PingFang SC; | |
|     //     font-weight: 600; | |
|     //     font-size: 32rpx; | |
|     //     line-height: 48rpx; | |
|     //     color: #3B3D3D; | |
|     //     margin-right: 10rpx; | |
|     // } | |
|  | |
|     .clickable-word { | |
|         background: $primary-color; | |
|         text-decoration: underline; | |
|         cursor: pointer; | |
|         transition: all 0.2s ease; | |
|         padding: 0 20rpx; | |
|     } | |
| 
 | |
|     .clickable-word:hover { | |
|         background-color: rgba(0, 122, 255, 0.1); | |
|         border-radius: 4rpx; | |
|     } | |
| 
 | |
|     .chinese-segment { | |
|         font-family: PingFang SC; | |
|         font-weight: 400; | |
|         font-size: 28rpx; | |
|         line-height: 48rpx; | |
|         color: #3B3D3D; | |
|     } | |
| 
 | |
|     .clickable-keyword { | |
|         background: $primary-color; | |
|         text-decoration: underline; | |
|         cursor: pointer; | |
|         color: #fff !important; | |
|         transition: all 0.2s ease; | |
|         border-radius: 4rpx; | |
|         padding: 4rpx; | |
|     } | |
| 
 | |
|     .clickable-keyword:hover { | |
|         background-color: rgba(0, 122, 255, 0.1); | |
|     } | |
| 
 | |
|     .chinese-text { | |
|         display: block; | |
|         font-family: PingFang SC; | |
|         font-weight: 400; | |
|         font-size: 28rpx; | |
|         line-height: 48rpx; | |
|         color: #4F4F4F; | |
|     } | |
| } | |
| 
 | |
| /* 会员限制页面样式 */ | |
| .member-content { | |
|     display: flex; | |
|     flex-direction: column; | |
|     align-items: center; | |
|     justify-content: center; | |
|     height: 90%; | |
|     background-color: #F8F8F8; | |
|     padding: 40rpx; | |
|     margin: -40rpx; | |
|     box-sizing: border-box; | |
| } | |
| 
 | |
| .member-title { | |
|     font-family: PingFang SC; | |
|     font-weight: 500; | |
|     font-size: 40rpx; | |
|     line-height: 1; | |
|     color: #6f6f6f; | |
|     text-align: center; | |
|     margin-bottom: 48rpx; | |
| } | |
| 
 | |
| .member-button { | |
|     width: 670rpx; | |
|     height: 72rpx; | |
|     background: #06DADC; | |
|     border-radius: 200rpx; | |
|     display: flex; | |
|     align-items: center; | |
|     justify-content: center; | |
| } | |
| 
 | |
| .member-button-text { | |
|     font-family: PingFang SC; | |
|     font-weight: 400; | |
|     font-size: 30rpx; | |
|     color: #FFFFFF; | |
| } | |
| 
 | |
| // .video-content { | |
| //   width: 100%; | |
| //   height: auto; | |
| //   // margin: 200rpx -40rpx 0; | |
| //   // height: 500rpx; | |
| //   background-color: #FFFFFF; | |
| //   // padding: 40rpx; | |
| //   border-radius: 24rpx; | |
| //   display: flex; | |
| //   align-items: center; | |
| //   justify-content: center; | |
| //   .video-player{ | |
| //     width: 100%; | |
| //     margin: 0 auto; | |
| //     height: auto; | |
| //   } | |
| // } | |
|  | |
| 
 | |
| 
 | |
| .text-content { | |
|     // background-color: #F6F6F6; | |
|     box-sizing: border-box; | |
| 
 | |
|     &>view { | |
|         padding: 20rpx; | |
|     } | |
| } | |
| 
 | |
| .content-text { | |
|     font-family: PingFang SC; | |
|     // font-weight: 400; | |
|     font-size: 28rpx; | |
|     color: #3B3D3D; | |
|     line-height: 48rpx; | |
|     letter-spacing: 0; | |
|     text-align: justify; | |
|     word-break: break-all; | |
|     transition: all 0.3s ease; | |
| } | |
| 
 | |
| .clickable-text { | |
|     cursor: pointer; | |
| 
 | |
| 
 | |
|     &:active { | |
|         background-color: rgba(6, 218, 220, 0.1); | |
|         border-radius: 4rpx; | |
|     } | |
| } | |
| 
 | |
| .lead-text { | |
|     background: #06dadc12; | |
|     // background: #fffbe6;#06dadc | |
|     /* 柔和的提示背景 */ | |
|     //   border: 1px solid #ffe58f; | |
|     border-radius: 8px; | |
|     //   padding: 10rpx 20rpx; | |
|  | |
|     /* 添加平滑过渡动画 */ | |
|     transition: all 0.3s ease; | |
| } | |
| .introduction-text { | |
|     background: #fffbe6; | |
|     border: 1px solid #ffe58f; | |
|     border-radius: 8px; | |
|     padding: 10rpx 20rpx; | |
| } | |
| 
 | |
| .text-highlight { | |
|     background-color: rgba(255, 248, 220, 0.8); | |
|     /* 温暖的米黄色,对眼睛友好 */ | |
|     border-left: 4rpx solid #ffd700; | |
|     /* 左侧金色边框作为朗读指示 */ | |
|     padding: 4rpx 8rpx; | |
|     border-radius: 6rpx; | |
|     box-shadow: 0 2rpx 6rpx rgba(255, 215, 0, 0.15); | |
|     /* 柔和的阴影 */ | |
| 
 | |
|     /* 添加平滑过渡动画 */ | |
|     transition: all 0.3s ease; | |
| } | |
| </style>
 |