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

2559 lines
94 KiB

1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
1 month ago
  1. <template>
  2. <view class="book-container">
  3. <!-- 条件编译 -->
  4. <!-- #ifndef H5 -->
  5. <uv-status-bar></uv-status-bar>
  6. <!-- 自定义顶部导航栏 -->
  7. <view class="custom-navbar" :class="{ 'navbar-hidden': !showNavbar }">
  8. <uv-status-bar></uv-status-bar>
  9. <view class="navbar-content">
  10. <view class="navbar-left" @click="goBack">
  11. <uv-icon name="arrow-left" size="20" color="#262626"></uv-icon>
  12. </view>
  13. <view class="navbar-title">{{ currentPageTitle }}</view>
  14. </view>
  15. </view>
  16. <!-- #endif -->
  17. <!-- Swiper内容区域 -->
  18. <swiper class="content-swiper" :current="currentPage - 1" @change="onSwiperChange">
  19. <swiper-item v-for="(page, index) in bookPages" :key="index" class="swiper-item">
  20. <scroll-view scroll-y :scroll-top="scrollTops[index] || 0" :scroll-with-animation="true"
  21. style="height: 100vh;" class="scroll-container" @scroll="onScroll" @touchstart="onTouchStart"
  22. @touchmove="onTouchMove" @touchend="onTouchEnd">
  23. <view class="content-area" @click="toggleNavbar">
  24. <view class="title">{{ currentPageTitle }}</view>
  25. <!-- 会员限制页面 -->
  26. <view v-if="!isMember && pagePay[index] === 'Y' && userInfo.freeUser != 'Y'" class="member-content">
  27. <text class="member-title">{{ pageTitles[index] }}</text>
  28. <view class="member-button" @click.stop="unlockBook">
  29. <text class="member-button-text">升级会员解锁</text>
  30. </view>
  31. </view>
  32. <!-- 图片卡片页面 -->
  33. <view class="card-content" v-else-if="pageTypes[index] === '1'">
  34. <view class="card-line">
  35. <image :src="configParamContent('highlight_icon')" class="card-line-image"
  36. mode="aspectFill" />
  37. <text class="card-line-text">划线重点</text>
  38. </view>
  39. <view v-for="(item, itemIndex) in page" :key="itemIndex" class="text-content">
  40. <image class="card-image" v-if="item && item.type === 'image'" :src="item.imageUrl"
  41. mode="widthFix"></image>
  42. <!-- <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)" >
  43. <text
  44. v-for="(token, tokenIndex) in splitEnglishSentence(item.content)"
  45. :key="tokenIndex"
  46. :class="['english-token', { 'clickable-word': token.isWord && findWordDefinition(token.text) }]"
  47. @click.stop="token.isWord && findWordDefinition(token.text) ? handleWordClick(token.text) : null"
  48. user-select
  49. :style="item.style"
  50. >{{ token.text }}</text>
  51. </view> -->
  52. <!-- <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)"> -->
  53. <view :class="{
  54. 'lead-text': isCardTextHighlighted(page, itemIndex),
  55. 'introduction-text' : item.isLead,
  56. }"
  57. v-else-if="item && item.type === 'text' && item.content"
  58. @click.stop="handleTextClick(item.content, item, index)">
  59. <text v-for="(segment, segmentIndex) in processChineseText(item.content)"
  60. :key="segmentIndex"
  61. :class="['chinese-segment', { 'clickable-keyword': segment.isKeyword }]"
  62. @click.stop="segment.isKeyword ? handleChineseKeywordClick(segment.keywordData) : handleTextClick(item.content, item, index)"
  63. user-select :style="item.style" :id="`text-segment-${segmentIndex}`">{{
  64. segment.text }}</text>
  65. </view>
  66. </view>
  67. </view>
  68. <view v-else>
  69. <view v-for="(item, itemIndex) in page" :key="itemIndex">
  70. <!-- 文本页面 -->
  71. <view v-if="item && item.type === 'text' && item.content" class="text-content">
  72. <view :class="{
  73. 'lead-text': isTextHighlighted(page, itemIndex),
  74. 'introduction-text' : item.isLead,
  75. }"
  76. @click.stop="handleTextClick(item.content, item, index)"
  77. :ref="`textRef_${index}_${itemIndex}`" :id="`text-${itemIndex}`">
  78. <text class="content-text clickable-text"
  79. :style="item.style" user-select>
  80. {{ item.content }}
  81. </text>
  82. </view>
  83. </view>
  84. <!-- 图片页面 -->
  85. <view v-else-if="item.type === 'image'" class="image-container"
  86. :ref="`imageRef_${index}_${itemIndex}`">
  87. <image class="content-image" :src="item.imageUrl" mode="widthFix"></image>
  88. </view>
  89. <!-- 视频页面 -->
  90. <view v-else-if="item.type === 'video'" class="video-content" @click.stop>
  91. <!-- 视频加载状态 -->
  92. <view v-if="videoLoading" class="video-loading">
  93. <text class="loading-text">视频加载中...</text>
  94. </view>
  95. <!-- 视频播放器 -->
  96. <video v-else :src="item.url" class="video-player" controls :poster="item.coverUrl"
  97. @loadstart="onVideoLoadStart" @loadeddata="onVideoLoadStart"
  98. @error="onVideoError"></video>
  99. </view>
  100. </view>
  101. </view>
  102. </view>
  103. </scroll-view>
  104. </swiper-item>
  105. </swiper>
  106. <!-- 自定义底部控制栏 -->
  107. <CustomTabbar :show-navbar="showNavbar" :current-page="currentPage" :course-id="courseId" :voice-id="voiceId"
  108. :book-pages="bookPages" :is-text-page="isTextPage" :should-load-audio="shouldLoadAudio"
  109. :is-member="isMember" :current-page-requires-member="currentPageRequiresMember" :page-pay="pagePay"
  110. :is-word-audio-playing="isWordAudioPlaying" @toggle-course-popup="toggleCoursePopup"
  111. @toggle-sound="toggleSound" @go-to-page="goToPage" @previous-page="previousPage" @next-page="nextPage"
  112. @audio-state-change="onAudioStateChange" @highlight-change="onHighlightChange"
  113. @scroll-to-text="onScrollToText" @voice-change-complete="onVoiceChangeComplete"
  114. @voice-change-error="onVoiceChangeError" @page-data-needed="onPageDataNeeded" @loop-mode-change="onLoopModeChange" ref="customTabbar" />
  115. <!-- 课程选择弹出窗 -->
  116. <CoursePopup :style="{ zIndex: 10000 }" :course-list="courseList" :current-course="currentCourse"
  117. :is-reversed="isReversed" @toggle-sort="toggleSort" @select-course="selectCourse" ref="coursePopup" />
  118. <!-- 释义弹出窗 -->
  119. <MeaningPopup :style="{ zIndex: 10000 }" :current-word-meaning="currentWordMeaning"
  120. @close-meaning-popup="closeMeaningPopup" @repeat-word-audio="repeatWordAudio" ref="meaningPopup" />
  121. <!-- 悬浮按钮组件 -->
  122. <FloatingButtons
  123. :is-last-page="isLastPage"
  124. :has-next-course="hasNextCourse"
  125. @next-course="goToNextCourse"
  126. @back-to-start="backToStart"
  127. />
  128. </view>
  129. </template>
  130. <script>
  131. import AudioControls from './AudioControls.vue'
  132. import CustomTabbar from './components/CustomTabbar.vue'
  133. import CoursePopup from './components/CoursePopup.vue'
  134. import MeaningPopup from './components/MeaningPopup.vue'
  135. import FloatingButtons from './components/FloatingButtons.vue'
  136. import audioManager from '@/utils/audioManager.js'
  137. export default {
  138. components: {
  139. AudioControls,
  140. CustomTabbar,
  141. CoursePopup,
  142. MeaningPopup,
  143. FloatingButtons
  144. },
  145. data() {
  146. return {
  147. isMember: false,
  148. memberId: '',
  149. voiceId: null,
  150. courseId: '',
  151. showNavbar: true,
  152. currentPage: 1, // 当前页面索引
  153. currentCourse: 1, // 当前课程索引
  154. audioLoopMode: 'sequential', // 音频循环模式:顺序/单句/单页
  155. currentWordMeaning: null, // 当前显示的单词释义
  156. isReversed: false, // 是否倒序显示
  157. // 文本高亮相关 - 由AudioControls组件管理,这里只保留必要的接口
  158. currentHighlightIndex: -1, // 当前高亮的文本索引,用于模板渲染
  159. wordAudioCache: {}, // 單詞語音緩存
  160. // 注意:音频实例现在由audioManager统一管理,不再在组件中维护
  161. isWordAudioPlaying: false, // 是否有单词音频正在播放
  162. // 音频状态相关 - 这些状态现在由AudioControls组件管理
  163. // 保留这些属性用于与AudioControls组件的数据同步
  164. isAudioLoading: false, // 音频是否正在加载
  165. hasAudioData: false, // 是否有音频数据
  166. audioLoadFailed: false, // 音频加载是否失败
  167. // 视频状态相关
  168. videoLoading: false, // 视频是否正在加载
  169. // 滚动相关
  170. scrollTops: [], // 每个页面的scroll-view滚动位置数组
  171. scrollDebounceTimer: null, // 滚动防抖定时器
  172. isScrolling: false, // 是否正在滚动中
  173. // 手动滚动检测相关
  174. isUserTouching: false, // 用户是否正在触摸屏幕
  175. touchStartTime: 0, // 触摸开始时间
  176. touchStartY: 0, // 触摸开始Y坐标
  177. userScrollTimer: null, // 用户滚动检测定时器
  178. lastUserScrollTime: 0, // 最后一次用户滚动时间
  179. courseIdList: [],
  180. bookTitle: '',
  181. courseList: [
  182. ],
  183. // 二维数组 代表每个页面
  184. bookPages: [
  185. ],
  186. // 存储每个页面的标题
  187. pageTitles: [],
  188. // 存储每个页面的type信息
  189. pageTypes: [],
  190. // 存储每个页面的单词释义数据
  191. pageWords: [],
  192. // 存储每个页面的付费状态
  193. pagePay: [],
  194. }
  195. },
  196. onShow() {
  197. if (uni.getStorageSync('token')) {
  198. this.$store.dispatch('getUserInfo');
  199. }
  200. },
  201. computed: {
  202. displayCourseList() {
  203. return this.isReversed ? [...this.courseList].reverse() : this.courseList;
  204. },
  205. // 从Vuex获取音色列表
  206. voiceList() {
  207. return this.$store.state.voiceList;
  208. },
  209. // 从Vuex获取默认音色ID
  210. defaultVoiceId() {
  211. return this.$store.state.defaultVoiceId;
  212. },
  213. // 判断当前页面是否为文字类型
  214. isTextPage() {
  215. // 如果是卡片页面(type为'1'),不显示音频控制栏
  216. if (this.currentPageType === '1') {
  217. return true;
  218. }
  219. const currentPageData = this.bookPages[this.currentPage - 1];
  220. // currentPageData是一个数组 其中的一个元素的type是text就会返回true
  221. return currentPageData && currentPageData.some(item => item.type === 'text');
  222. },
  223. // 判断当前页面是否需要加载音频(包括文本页面和卡片页面)
  224. shouldLoadAudio() {
  225. // 文本页面需要加载音频
  226. if (this.isTextPage) {
  227. return true;
  228. }
  229. // 卡片页面(type为'1')也需要加载音频以支持点击播放
  230. if (this.currentPageType === '1') {
  231. return true;
  232. }
  233. return false;
  234. },
  235. // 动态页面标题
  236. currentPageTitle() {
  237. return this.pageTitles[this.currentPage - 1] || this.bookTitle;
  238. },
  239. // 当前页面类型
  240. currentPageType() {
  241. return this.pageTypes[this.currentPage - 1] || '';
  242. },
  243. // 当前页面的单词释义数据
  244. currentPageWords() {
  245. return this.pageWords[this.currentPage - 1] || [];
  246. },
  247. // 当前页面是否需要会员
  248. currentPageRequiresMember() {
  249. // 免费用户不受会员限制
  250. if (this.userInfo && this.userInfo.freeUser === 'Y') {
  251. return false;
  252. }
  253. return this.pagePay[this.currentPage - 1] === 'Y';
  254. },
  255. // 判断是否为当前课程的最后一页
  256. isLastPage() {
  257. return this.currentPage === this.bookPages.length;
  258. },
  259. // 判断是否有下一课
  260. hasNextCourse() {
  261. if (!this.courseList || this.courseList.length === 0) return false;
  262. // 使用 courseId 而不是 currentCourse,因为 courseId 是当前正在学习的课程ID
  263. const currentCourseIndex = this.courseList.findIndex(course => course.id == this.courseId);
  264. return currentCourseIndex >= 0 && currentCourseIndex < this.courseList.length - 1;
  265. }
  266. },
  267. // watch: {
  268. // scrollTops: {
  269. // handler(newVal, oldVal) {
  270. // console.log('📊 scrollTops变化:', {
  271. // currentPage: this.currentPage,
  272. // newScrollTops: newVal,
  273. // currentPageScrollTop: newVal[this.currentPage - 1]
  274. // });
  275. // },
  276. // deep: true
  277. // }
  278. // },
  279. methods: {
  280. // 触摸开始事件 - 检测用户开始触摸
  281. onTouchStart(e) {
  282. this.isUserTouching = true;
  283. this.touchStartTime = Date.now();
  284. this.touchStartY = e.touches[0].pageY;
  285. // 清除之前的用户滚动定时器
  286. if (this.userScrollTimer) {
  287. clearTimeout(this.userScrollTimer);
  288. this.userScrollTimer = null;
  289. }
  290. console.log('👆 用户开始触摸屏幕');
  291. },
  292. // 触摸移动事件 - 检测用户滚动操作
  293. onTouchMove(e) {
  294. if (!this.isUserTouching) return;
  295. const currentY = e.touches[0].pageY;
  296. const deltaY = Math.abs(currentY - this.touchStartY);
  297. // 如果移动距离超过阈值,认为是滚动操作
  298. if (deltaY > 10) {
  299. // 记录用户滚动时间
  300. this.lastUserScrollTime = Date.now();
  301. // 如果当前正在自动滚动,立即停止
  302. if (this.isScrolling) {
  303. console.log('🛑 检测到用户手动滚动,停止自动滚动');
  304. this.isScrolling = false;
  305. // 清除滚动防抖定时器
  306. if (this.scrollDebounceTimer) {
  307. clearTimeout(this.scrollDebounceTimer);
  308. this.scrollDebounceTimer = null;
  309. }
  310. }
  311. }
  312. },
  313. // 触摸结束事件 - 用户停止触摸
  314. onTouchEnd(e) {
  315. this.isUserTouching = false;
  316. // 设置一个短暂的延迟,在用户停止触摸后的一段时间内仍然阻止自动滚动
  317. // 这样可以避免用户刚停止滚动就立即触发自动滚动
  318. this.userScrollTimer = setTimeout(() => {
  319. console.log('✋ 用户滚动操作结束,允许自动滚动');
  320. this.userScrollTimer = null;
  321. }, 500); // 减少到500ms,提高响应性
  322. console.log('👆 用户停止触摸屏幕');
  323. },
  324. // 检查是否应该阻止自动滚动
  325. shouldPreventAutoScroll() {
  326. // 降低敏感度:只有在用户正在触摸且最近有滚动行为时才阻止
  327. const now = Date.now();
  328. const recentUserScroll = this.userScrollTimer !== null && (now - this.lastUserScrollTime) < 1000;
  329. return this.isUserTouching && recentUserScroll;
  330. },
  331. // 处理scroll-view滚动事件
  332. onScroll(e) {
  333. // 更新当前页面的滚动位置
  334. const scrollTop = e.detail.scrollTop;
  335. const currentPageIndex = this.currentPage - 1;
  336. const previousScrollTop = this.scrollTops[currentPageIndex] || 0;
  337. // 只有当滚动位置发生显著变化时才更新
  338. if (Math.abs(previousScrollTop - scrollTop) > 5) {
  339. // 检测是否为手动滚动(如果正在自动滚动中,但滚动位置与预期不符,则认为是手动滚动)
  340. if (this.isScrolling) {
  341. // 提高手动滚动检测阈值,减少误判
  342. const scrollDifference = Math.abs(previousScrollTop - scrollTop);
  343. if (scrollDifference > 80) { // 从50提高到80,减少误判
  344. console.log('🖐️ 检测到手动滚动,中断自动滚动状态');
  345. this.isScrolling = false;
  346. this.lastUserScrollTime = Date.now(); // 记录手动滚动时间
  347. }
  348. }
  349. this.$set(this.scrollTops, currentPageIndex, scrollTop);
  350. }
  351. },
  352. // 视频事件处理方法
  353. onVideoLoadStart() {
  354. this.videoLoading = true;
  355. },
  356. onVideoCanPlay() {
  357. this.videoLoading = false;
  358. },
  359. onVideoError() {
  360. this.videoLoading = false;
  361. uni.showToast({
  362. title: '视频加载失败',
  363. icon: 'none',
  364. duration: 2000
  365. });
  366. },
  367. // 獲取用戶會員信息 判斷是否和傳參傳過來的會員id相同
  368. async getMemberInfo() {
  369. // 检查是否为免费用户
  370. if (this.userInfo && this.userInfo.freeUser === 'Y') {
  371. this.isMember = true; // 免费用户享有会员权限
  372. return;
  373. }
  374. const memberRes = await this.$api.member.getUserMemberInfo()
  375. if (memberRes.code === 200) {
  376. this.isMember = memberRes.result.map(item => item.memberId).includes(this.memberId)
  377. }
  378. },
  379. // 处理AudioControls组件的事件
  380. onAudioStateChange(audioState) {
  381. // 更新高亮状态
  382. this.currentHighlightIndex = audioState.currentHighlightIndex;
  383. // 更新音频加载状态(用于控制UI显示)
  384. if (audioState.hasOwnProperty('isLoading')) {
  385. this.isAudioLoading = audioState.isLoading;
  386. }
  387. // 更新音频数据状态
  388. if (audioState.hasOwnProperty('hasAudioData')) {
  389. this.hasAudioData = audioState.hasAudioData;
  390. }
  391. // 更新音频加载失败状态
  392. if (audioState.hasOwnProperty('audioLoadFailed')) {
  393. this.audioLoadFailed = audioState.audioLoadFailed;
  394. }
  395. },
  396. // 处理页面数据需要重新加载的事件
  397. async onPageDataNeeded(pageNumber) {
  398. console.log('收到页面数据需要重新加载的请求,页面:', pageNumber);
  399. // 如果页面数据不存在或为空,重新获取
  400. if (!this.bookPages || this.bookPages.length === 0 || !this.bookPages[pageNumber - 1]) {
  401. console.log('页面数据不存在,重新获取页面数据');
  402. try {
  403. await this.getBookPages();
  404. console.log('页面数据重新获取完成');
  405. // 页面数据更新后,AudioControls组件的bookPages监听器会自动触发音频获取
  406. // 无需手动调用getCurrentPageAudio,避免重复调用
  407. } catch (error) {
  408. console.error('重新获取页面数据失败:', error);
  409. }
  410. } else {
  411. console.log('页面数据已存在,无需重新获取');
  412. }
  413. },
  414. // 接收循环模式变化事件
  415. onLoopModeChange(mode) {
  416. this.audioLoopMode = mode;
  417. const labels = { sequential: '顺序', sentence: '单句', page: '单页' };
  418. const label = labels[mode] || '顺序';
  419. // 可选:提示当前播放模式
  420. uni.showToast({ title: `播放模式:${label}`, icon: 'none', duration: 800 });
  421. },
  422. // 处理音色切换完成事件
  423. onVoiceChangeComplete(data) {
  424. // 可以在这里添加一些UI反馈,比如显示切换成功的提示
  425. if (data.hasAudioData) {
  426. } else {
  427. }
  428. // 如果启用了预加载所有页面
  429. if (data.preloadAllPages) {
  430. // 可以显示一个提示,告诉用户正在后台加载
  431. uni.showToast({
  432. title: '正在加载新音色...',
  433. icon: 'loading',
  434. duration: 2000
  435. });
  436. }
  437. },
  438. // 处理音色切换错误事件
  439. onVoiceChangeError(error) {
  440. console.error('音色切换失败:', error);
  441. // 可以在这里显示错误提示给用户
  442. uni.showToast({
  443. title: '音色切换失败,请重试',
  444. icon: 'none',
  445. duration: 2000
  446. });
  447. },
  448. // 处理音频切换时的自动滚动
  449. // onScrollToText(refName) {
  450. // try {
  451. // console.log('🎯 onScrollToText 被调用:', refName);
  452. //
  453. // // 调用scrollTo插件
  454. // this.$scrollTo(refName);
  455. //
  456. // } catch (error) {
  457. // console.error('❌ onScrollToText 执行失败:', error);
  458. // }
  459. // },
  460. // 处理文本点击事件
  461. handleTextClick(textContent, item, pageIndex) {
  462. if (this.isWordAudioPlaying) {
  463. this.isWordAudioPlaying = false;
  464. }
  465. // 检查是否点击的是当前页面
  466. if (pageIndex !== undefined && pageIndex !== this.currentPage - 1) {
  467. console.warn('⚠️ 点击的不是当前页面,忽略点击事件');
  468. return;
  469. }
  470. // 验证参数有效性
  471. if (!item) {
  472. console.error('❌ handleTextClick: item参数为空');
  473. uni.showToast({
  474. title: '数据错误,请刷新页面',
  475. icon: 'none'
  476. });
  477. return;
  478. }
  479. // 如果textContent为undefined,尝试从item中获取
  480. if (!textContent && item && item.content) {
  481. textContent = item.content;
  482. }
  483. // 最终验证textContent
  484. if (!textContent || typeof textContent !== 'string' || textContent.trim() === '') {
  485. console.error('❌ handleTextClick: 无效的文本内容', textContent);
  486. uni.showToast({
  487. title: '文本内容无效',
  488. icon: 'none'
  489. });
  490. return;
  491. }
  492. if (!this.$refs.customTabbar) {
  493. console.error('❌ customTabbar引用不存在');
  494. uni.showToast({
  495. title: '音频控制组件未准备好',
  496. icon: 'none'
  497. });
  498. return;
  499. }
  500. // console.log(' audioControls存在:', !!this.$refs.customTabbar.$refs.audioControls);
  501. if (!this.$refs.customTabbar.$refs.audioControls) {
  502. console.error('❌ audioControls引用不存在');
  503. uni.showToast({
  504. title: '音频控制组件未准备好',
  505. icon: 'none'
  506. });
  507. return;
  508. }
  509. // 检查当前页面是否为文本页面或卡片页面
  510. // 卡片页面(type为'1')现在也支持整句音频播放
  511. // 特别针对划线重点页面的调试
  512. if (this.currentPageType === '1') {
  513. }
  514. if (!this.isTextPage && this.currentPageType !== '1') {
  515. console.warn('⚠️ 当前页面不是文本页面或卡片页面');
  516. uni.showToast({
  517. title: '当前页面不支持音频播放',
  518. icon: 'none'
  519. });
  520. return;
  521. }
  522. // 获取音频控制组件实例
  523. const audioControls = this.$refs.customTabbar.$refs.audioControls;
  524. // 检查音频是否正在加载中
  525. if (audioControls.isAudioLoading) {
  526. uni.showToast({
  527. title: '音频正在加载中,请稍后再试',
  528. icon: 'loading',
  529. duration: 1500
  530. });
  531. // 等待音频加载完成后自动播放
  532. const checkAndPlay = () => {
  533. if (!audioControls.isAudioLoading && audioControls.currentPageAudios.length > 0) {
  534. const success = audioControls.playSpecificAudio(textContent);
  535. if (!success) {
  536. console.error('❌ 音频加载完成后播放失败');
  537. }
  538. } else if (!audioControls.isAudioLoading) {
  539. console.error('❌ 音频加载完成但没有音频数据');
  540. uni.showToast({
  541. title: '当前页面没有音频内容',
  542. icon: 'none'
  543. });
  544. } else {
  545. // 继续等待
  546. setTimeout(checkAndPlay, 500);
  547. }
  548. };
  549. // 延迟检查,给音频加载一些时间
  550. setTimeout(checkAndPlay, 500);
  551. return;
  552. }
  553. // 检查是否有音频数据
  554. if (!audioControls.currentPageAudios || audioControls.currentPageAudios.length === 0) {
  555. console.warn('⚠️ 当前页面没有音频数据,尝试重新加载');
  556. uni.showToast({
  557. title: '正在重新加载音频...',
  558. icon: 'loading'
  559. });
  560. // 尝试重新加载音频
  561. audioControls.getCurrentPageAudio();
  562. // 等待重新加载完成后播放
  563. const retryPlay = () => {
  564. if (!audioControls.isAudioLoading && audioControls.currentPageAudios.length > 0) {
  565. const success = audioControls.playSpecificAudio(textContent);
  566. if (!success) {
  567. console.error('❌ 音频重新加载后播放失败');
  568. }
  569. } else if (!audioControls.isAudioLoading) {
  570. console.error('❌ 音频重新加载失败');
  571. uni.showToast({
  572. title: '音频加载失败,请检查网络连接',
  573. icon: 'none'
  574. });
  575. } else {
  576. // 继续等待
  577. setTimeout(retryPlay, 500);
  578. }
  579. };
  580. setTimeout(retryPlay, 1500);
  581. return;
  582. }
  583. const success = audioControls.playSpecificAudio(textContent);
  584. if (success) {
  585. // console.log('✅ 成功播放指定音频段落');
  586. } else {
  587. console.error('❌ 播放指定音频段落失败');
  588. }
  589. },
  590. onHighlightChange(highlightData) {
  591. // 兼容旧格式(直接传递索引)和新格式(传递对象)
  592. if (typeof highlightData === 'number') {
  593. // 旧格式:直接是索引
  594. this.currentHighlightIndex = highlightData;
  595. } else if (typeof highlightData === 'object' && highlightData !== null) {
  596. // 新格式:包含详细信息的对象
  597. this.currentHighlightIndex = highlightData.highlightIndex;
  598. // 可以在这里处理分段音频的额外信息
  599. if (highlightData.isSegmented) {
  600. // console.log('分段音频高亮:', {
  601. // highlightIndex: highlightData.highlightIndex,
  602. // segmentIndex: highlightData.segmentIndex,
  603. // startIndex: highlightData.startIndex,
  604. // endIndex: highlightData.endIndex,
  605. // currentText: highlightData.currentText
  606. // });
  607. }
  608. } else {
  609. // 清除高亮
  610. this.currentHighlightIndex = -1;
  611. }
  612. },
  613. // 处理滚动到高亮文本
  614. onScrollToText(scrollData) {
  615. console.log('📍 收到滚动请求:', scrollData);
  616. // 检查是否应该阻止自动滚动(用户正在手动操作)
  617. if (this.shouldPreventAutoScroll()) {
  618. console.log('🚫 用户正在手动滚动,跳过自动滚动到文本');
  619. return;
  620. }
  621. // 防抖处理:如果正在滚动中,清除之前的定时器
  622. if (this.scrollDebounceTimer) {
  623. clearTimeout(this.scrollDebounceTimer);
  624. }
  625. this.scrollDebounceTimer = setTimeout(() => {
  626. // 再次检查是否应该阻止自动滚动
  627. if (this.shouldPreventAutoScroll()) {
  628. console.log('🚫 防抖延迟后检测到用户手动滚动,跳过自动滚动到文本');
  629. return;
  630. }
  631. this.performScrollToText(scrollData);
  632. }, 50); // 减少防抖延迟,提高响应性
  633. },
  634. // 执行滚动到高亮文本的具体逻辑
  635. performScrollToText(scrollData) {
  636. // 最终检查:如果用户正在手动操作,直接返回
  637. if (this.shouldPreventAutoScroll()) {
  638. console.log('🚫 执行滚动前检测到用户手动操作,取消自动滚动');
  639. return;
  640. }
  641. // 确保在任何情况下都能重置滚动状态
  642. const resetScrollingState = () => {
  643. this.isScrolling = false;
  644. console.log('🔄 滚动状态已重置');
  645. };
  646. // 设置安全超时,确保状态不会永久卡住
  647. const safetyTimeout = setTimeout(() => {
  648. if (this.isScrolling) {
  649. console.warn('⚠️ 滚动状态安全超时,强制重置');
  650. resetScrollingState();
  651. }
  652. }, 2000); // 2秒安全超时
  653. if (!scrollData || typeof scrollData.highlightIndex !== 'number' || scrollData.highlightIndex < 0) {
  654. console.warn('滚动数据无效:', scrollData);
  655. clearTimeout(safetyTimeout);
  656. return;
  657. }
  658. // 确保在当前页面
  659. if (scrollData.currentPage && scrollData.currentPage !== this.currentPage) {
  660. console.warn('页面不匹配,跳过滚动:', {
  661. scrollDataPage: scrollData.currentPage,
  662. currentPage: this.currentPage
  663. });
  664. clearTimeout(safetyTimeout);
  665. return;
  666. }
  667. // 如果正在滚动中,跳过本次滚动
  668. if (this.isScrolling) {
  669. console.warn('正在滚动中,跳过本次滚动');
  670. clearTimeout(safetyTimeout);
  671. return;
  672. }
  673. // 构建元素选择器
  674. let selector = '';
  675. if (scrollData.isSegmented && typeof scrollData.segmentIndex === 'number') {
  676. // 分段音频:使用分段索引
  677. selector = `#text-segment-${scrollData.segmentIndex}`;
  678. } else {
  679. // 普通音频:需要找到对应的文本元素
  680. // originalTextIndex是指向原始页面数据中的索引,需要映射到实际的DOM元素
  681. const targetItemIndex = this.findTextItemIndex(scrollData.highlightIndex);
  682. if (targetItemIndex !== -1) {
  683. selector = `#text-${targetItemIndex}`;
  684. } else {
  685. console.warn('无法找到对应的文本元素索引:', scrollData.highlightIndex);
  686. selector = `#text-${scrollData.highlightIndex}`; // 备用方案
  687. }
  688. }
  689. console.log('开始滚动到文本:', { selector, scrollData });
  690. // 标记正在滚动
  691. this.isScrolling = true;
  692. // 等待DOM更新后再查找元素
  693. this.$nextTick(async () => {
  694. try {
  695. // 获取所有元素的真实位置信息
  696. const elementPositions = await this.getAllElementPositions();
  697. console.log('📏 获取到的元素位置信息:', elementPositions);
  698. // 计算精确的滚动位置
  699. const preciseScrollTop = await this.calculatePreciseScrollPosition(scrollData, elementPositions);
  700. if (preciseScrollTop !== null) {
  701. // 检查是否需要滚动(避免不必要的滚动)
  702. const currentScroll = this.scrollTops[this.currentPage - 1] || 0;
  703. const scrollDifference = Math.abs(preciseScrollTop - currentScroll);
  704. // 提高滚动阈值,减少不必要的微小滚动
  705. if (scrollDifference > 30) { // 从20提高到30
  706. this.$set(this.scrollTops, this.currentPage - 1, preciseScrollTop);
  707. console.log('✅ 使用精确位置滚动:', {
  708. selector,
  709. preciseScrollTop,
  710. currentPage: this.currentPage,
  711. scrollDifference
  712. });
  713. // 滚动完成后重置状态
  714. setTimeout(() => {
  715. resetScrollingState();
  716. }, 200);
  717. } else {
  718. resetScrollingState();
  719. console.log('📍 目标已在最佳可视位置,无需滚动');
  720. }
  721. } else {
  722. // 精确计算失败,使用原有的查询方法作为备用
  723. console.log('🔄 精确计算失败,使用备用查询方法');
  724. this.fallbackScrollToText(selector, resetScrollingState, safetyTimeout);
  725. }
  726. } catch (error) {
  727. console.error('❌ 精确滚动计算失败:', error);
  728. // 使用原有的查询方法作为备用
  729. this.fallbackScrollToText(selector, resetScrollingState, safetyTimeout);
  730. }
  731. });
  732. },
  733. // 备用滚动方法(原有的查询方式)
  734. fallbackScrollToText(selector, resetScrollingState, safetyTimeout) {
  735. // 使用uni.createSelectorQuery获取元素位置
  736. const query = uni.createSelectorQuery().in(this);
  737. // 获取scroll-view容器的位置信息
  738. query.select('.scroll-container').boundingClientRect();
  739. // 获取目标元素的位置信息
  740. query.select(selector).boundingClientRect();
  741. query.exec((res) => {
  742. // 清除安全超时
  743. clearTimeout(safetyTimeout);
  744. const scrollViewRect = res[0];
  745. const targetRect = res[1];
  746. console.log('查询结果:', {
  747. scrollViewRect: scrollViewRect ? '找到' : '未找到',
  748. targetRect: targetRect ? '找到' : '未找到',
  749. selector
  750. });
  751. if (scrollViewRect && targetRect) {
  752. // 计算目标元素相对于scroll-view的位置
  753. const currentScrollTop = this.scrollTops[this.currentPage - 1] || 0;
  754. const targetOffsetTop = targetRect.top - scrollViewRect.top + currentScrollTop;
  755. // 计算滚动位置,让目标元素在屏幕上方1/4处(更好的阅读体验)
  756. const screenHeight = uni.getSystemInfoSync().windowHeight;
  757. const targetScrollTop = targetOffsetTop - screenHeight / 4;
  758. // 更新scroll-view的滚动位置
  759. const finalScrollTop = Math.max(0, targetScrollTop);
  760. // 检查是否需要滚动(避免不必要的滚动)
  761. const currentScroll = this.scrollTops[this.currentPage - 1] || 0;
  762. const scrollDifference = Math.abs(finalScrollTop - currentScroll);
  763. // 提高滚动阈值,减少不必要的微小滚动
  764. if (scrollDifference > 30) { // 从20提高到30,与精确滚动保持一致
  765. this.$set(this.scrollTops, this.currentPage - 1, finalScrollTop);
  766. // 滚动完成后重置状态
  767. setTimeout(() => {
  768. resetScrollingState();
  769. }, 200); // 减少等待时间
  770. console.log('✅ 滚动到高亮文本:', {
  771. selector,
  772. targetOffsetTop,
  773. finalScrollTop,
  774. currentPage: this.currentPage,
  775. scrollDifference
  776. });
  777. } else {
  778. // 不需要滚动,立即重置状态
  779. resetScrollingState();
  780. console.log('📍 目标已在最佳可视位置,无需滚动');
  781. }
  782. } else {
  783. console.error('❌ 未找到目标元素或scroll-view:', {
  784. selector,
  785. scrollViewFound: !!scrollViewRect,
  786. targetFound: !!targetRect,
  787. currentPage: this.currentPage,
  788. highlightIndex: scrollData.highlightIndex
  789. });
  790. // 尝试备用方案:直接滚动到页面顶部附近
  791. if (!targetRect) {
  792. console.log('🔄 尝试备用滚动方案');
  793. // 改进备用方案:基于highlightIndex计算更准确的位置
  794. const estimatedPosition = this.calculateEstimatedScrollPosition(scrollData.highlightIndex);
  795. this.$set(this.scrollTops, this.currentPage - 1, estimatedPosition);
  796. setTimeout(() => {
  797. resetScrollingState();
  798. }, 200);
  799. } else {
  800. // 立即重置状态
  801. resetScrollingState();
  802. }
  803. }
  804. });
  805. },
  806. // 查找文本元素在页面中的实际索引
  807. findTextItemIndex(originalTextIndex) {
  808. const currentPageData = this.bookPages[this.currentPage - 1];
  809. if (!currentPageData || !Array.isArray(currentPageData)) {
  810. return -1;
  811. }
  812. let textCount = 0;
  813. for (let i = 0; i < currentPageData.length; i++) {
  814. const item = currentPageData[i];
  815. if (item && item.type === 'text' && item.content) {
  816. if (textCount === originalTextIndex) {
  817. return i; // 返回在页面数组中的实际索引
  818. }
  819. textCount++;
  820. }
  821. }
  822. return -1; // 未找到
  823. },
  824. // 获取所有页面元素的位置信息
  825. async getAllElementPositions() {
  826. return new Promise((resolve) => {
  827. const currentPageData = this.bookPages[this.currentPage - 1];
  828. if (!currentPageData || !Array.isArray(currentPageData)) {
  829. resolve([]);
  830. return;
  831. }
  832. const query = uni.createSelectorQuery().in(this);
  833. const elementPositions = [];
  834. // 获取scroll-container的位置作为基准
  835. query.select('.scroll-container').boundingClientRect();
  836. // 为每个元素添加查询
  837. currentPageData.forEach((item, index) => {
  838. if (item && (item.type === 'text' || item.type === 'image' || item.type === 'video')) {
  839. if (item.type === 'text') {
  840. query.select(`#text-${index}`).boundingClientRect();
  841. } else if (item.type === 'image') {
  842. query.select(`.image-container`).boundingClientRect();
  843. } else if (item.type === 'video') {
  844. query.select(`.video-content`).boundingClientRect();
  845. }
  846. }
  847. });
  848. query.exec((res) => {
  849. const containerRect = res[0];
  850. if (!containerRect) {
  851. resolve([]);
  852. return;
  853. }
  854. // 处理查询结果
  855. let resultIndex = 1; // 跳过第一个容器结果
  856. currentPageData.forEach((item, index) => {
  857. if (item && (item.type === 'text' || item.type === 'image' || item.type === 'video')) {
  858. const elementRect = res[resultIndex];
  859. if (elementRect) {
  860. elementPositions.push({
  861. index: index,
  862. type: item.type,
  863. top: elementRect.top - containerRect.top,
  864. height: elementRect.height,
  865. bottom: elementRect.top - containerRect.top + elementRect.height
  866. });
  867. }
  868. resultIndex++;
  869. }
  870. });
  871. resolve(elementPositions);
  872. });
  873. });
  874. },
  875. // 检查元素是否在可视范围内
  876. isElementInViewport(elementPosition, currentScrollTop) {
  877. const screenHeight = uni.getSystemInfoSync().windowHeight;
  878. const viewportTop = currentScrollTop;
  879. const viewportBottom = currentScrollTop + screenHeight;
  880. // 元素的顶部和底部位置
  881. const elementTop = elementPosition.top;
  882. const elementBottom = elementPosition.bottom;
  883. // 检查元素是否完全或部分在可视范围内
  884. const isVisible = elementBottom > viewportTop && elementTop < viewportBottom;
  885. // 计算元素在可视范围内的比例
  886. const visibleTop = Math.max(elementTop, viewportTop);
  887. const visibleBottom = Math.min(elementBottom, viewportBottom);
  888. const visibleHeight = Math.max(0, visibleBottom - visibleTop);
  889. const visibilityRatio = visibleHeight / elementPosition.height;
  890. return {
  891. isVisible,
  892. visibilityRatio,
  893. elementTop,
  894. elementBottom,
  895. viewportTop,
  896. viewportBottom
  897. };
  898. },
  899. // 计算最佳滚动位置
  900. calculateOptimalScrollPosition(targetElement, currentScrollTop) {
  901. const screenHeight = uni.getSystemInfoSync().windowHeight;
  902. // 检查当前元素的可见性
  903. const visibility = this.isElementInViewport(targetElement, currentScrollTop);
  904. // 如果元素已经完全可见且在合适位置,不需要滚动
  905. if (visibility.isVisible && visibility.visibilityRatio > 0.8) {
  906. // 检查元素是否在屏幕的合适位置(上方1/3到2/3之间)
  907. const elementCenter = (targetElement.top + targetElement.bottom) / 2;
  908. const relativePosition = (elementCenter - currentScrollTop) / screenHeight;
  909. if (relativePosition >= 0.2 && relativePosition <= 0.7) {
  910. console.log('📍 元素已在最佳可视位置,无需滚动');
  911. return null; // 不需要滚动
  912. }
  913. }
  914. // 计算目标滚动位置:让元素显示在屏幕上方1/3处(更舒适的阅读位置)
  915. const optimalOffsetRatio = 0.3; // 30%的位置,比1/4更舒适
  916. const offsetFromTop = screenHeight * optimalOffsetRatio;
  917. // 考虑元素高度,确保不会被截断
  918. const elementHeight = targetElement.height;
  919. const adjustedOffset = Math.min(offsetFromTop, screenHeight * 0.1); // 最小10%偏移
  920. const targetScrollTop = Math.max(0, targetElement.top - adjustedOffset);
  921. console.log('🎯 计算最佳滚动位置:', {
  922. currentVisibility: visibility,
  923. elementHeight,
  924. optimalOffsetRatio,
  925. adjustedOffset,
  926. targetScrollTop
  927. });
  928. return targetScrollTop;
  929. },
  930. // 计算精确的滚动位置
  931. async calculatePreciseScrollPosition(scrollData, elementPositions) {
  932. if (!elementPositions || elementPositions.length === 0) {
  933. return null;
  934. }
  935. let targetElementIndex = -1;
  936. if (scrollData.segmentIndex !== undefined) {
  937. // 分段音频情况
  938. targetElementIndex = scrollData.segmentIndex;
  939. } else if (scrollData.highlightIndex !== undefined) {
  940. // 普通音频情况,需要找到对应的文本元素
  941. targetElementIndex = this.findTextItemIndex(scrollData.highlightIndex);
  942. }
  943. if (targetElementIndex === -1) {
  944. return null;
  945. }
  946. // 查找目标元素的位置信息
  947. const targetElement = elementPositions.find(pos => pos.index === targetElementIndex && pos.type === 'text');
  948. if (!targetElement) {
  949. console.warn('未找到目标元素位置信息:', targetElementIndex);
  950. return null;
  951. }
  952. // 获取当前滚动位置
  953. const currentScrollTop = this.scrollTops[this.currentPage - 1] || 0;
  954. // 使用优化的滚动位置计算
  955. const targetScrollTop = this.calculateOptimalScrollPosition(targetElement, currentScrollTop);
  956. console.log('🎯 精确滚动位置计算:', {
  957. targetElementIndex,
  958. targetElement,
  959. currentScrollTop,
  960. targetScrollTop
  961. });
  962. return targetScrollTop;
  963. },
  964. // 计算估算的滚动位置(备用方案)
  965. calculateEstimatedScrollPosition(highlightIndex) {
  966. const currentPageData = this.bookPages[this.currentPage - 1];
  967. if (!currentPageData || !Array.isArray(currentPageData)) {
  968. return highlightIndex * 80; // 基础估算
  969. }
  970. // 基于页面内容计算更准确的位置
  971. let estimatedHeight = 0;
  972. let textCount = 0;
  973. for (let i = 0; i < currentPageData.length && textCount <= highlightIndex; i++) {
  974. const item = currentPageData[i];
  975. if (item && item.type === 'text' && item.content) {
  976. if (textCount === highlightIndex) {
  977. break;
  978. }
  979. // 根据内容长度估算高度
  980. const contentLength = item.content.length;
  981. estimatedHeight += Math.max(60, contentLength * 1.2); // 基础高度 + 内容长度因子
  982. textCount++;
  983. } else if (item && item.type === 'image') {
  984. estimatedHeight += 200; // 图片估算高度
  985. } else if (item && item.type === 'video') {
  986. estimatedHeight += 300; // 视频估算高度
  987. }
  988. }
  989. return Math.max(0, estimatedHeight - 100); // 留一些上边距
  990. },
  991. // 获取音色列表 拿第一个做默认的音色id
  992. async getVoiceList() {
  993. // 优先从Vuex获取音色列表
  994. if (this.voiceList && this.voiceList.length > 0) {
  995. console.log('从Vuex获取音色列表:', this.voiceList);
  996. this.voiceId = this.defaultVoiceId || Number(this.voiceList[0].voiceType);
  997. console.log('使用Vuex中的默认音色ID:', this.voiceId);
  998. // 同步默认音色设置到audioManager
  999. audioManager.setGlobalVoiceId(this.voiceId);
  1000. return;
  1001. }
  1002. // 如果Vuex中没有数据,则从API获取(兜底方案)
  1003. console.log('Vuex中无音色数据,从API获取...');
  1004. try {
  1005. const voiceRes = await this.$api.music.list()
  1006. if (voiceRes.code === 200) {
  1007. console.log('音色列表API返回:', voiceRes.result);
  1008. // 更新Vuex中的音色列表
  1009. this.$store.commit('setVoiceList', voiceRes.result);
  1010. this.voiceId = Number(voiceRes.result[0].voiceType)
  1011. console.log('获取默认音色ID:', this.voiceId, '类型:', typeof this.voiceId);
  1012. // 同步默认音色设置到audioManager
  1013. audioManager.setGlobalVoiceId(this.voiceId);
  1014. } else {
  1015. console.error('获取音色列表失败:', voiceRes);
  1016. }
  1017. } catch (error) {
  1018. console.error('获取音色列表异常:', error);
  1019. }
  1020. },
  1021. toggleNavbar() {
  1022. this.showNavbar = !this.showNavbar
  1023. },
  1024. goBack() {
  1025. uni.navigateBack()
  1026. },
  1027. toggleCoursePopup() {
  1028. if (this.$refs.coursePopup) {
  1029. this.$refs.coursePopup.open()
  1030. }
  1031. // console.log('123123123');
  1032. },
  1033. toggleSort() {
  1034. this.isReversed = !this.isReversed
  1035. },
  1036. selectCourse(courseId) {
  1037. this.currentCourse = courseId
  1038. this.courseId = courseId // 同时更新 courseId
  1039. // 这里可以添加切换课程的逻辑
  1040. // console.log('选择课程:', courseId)
  1041. this.getCourseList(courseId)
  1042. },
  1043. showWordMeaning() {
  1044. if (this.$refs.meaningPopup) {
  1045. this.$refs.meaningPopup.open()
  1046. }
  1047. },
  1048. closeMeaningPopup() {
  1049. this.currentWordMeaning = null;
  1050. // 重置音频播放状态,确保后续句子点击能正常播放
  1051. if (this.isWordAudioPlaying) {
  1052. this.isWordAudioPlaying = false;
  1053. // 如果有正在播放的音频,停止它
  1054. if (this.currentWordAudio) {
  1055. try {
  1056. this.currentWordAudio.pause();
  1057. this.currentWordAudio.destroy();
  1058. } catch (error) {
  1059. }
  1060. this.currentWordAudio = null;
  1061. }
  1062. }
  1063. },
  1064. // 将英文句子分割成单词数组
  1065. splitEnglishSentence(sentence) {
  1066. // 使用正则表达式分割句子,保留标点符号
  1067. const tokens = sentence.match(/\b\w+\b|[^\w\s]/g) || [];
  1068. return tokens.map((token, index) => ({
  1069. text: token,
  1070. index: index,
  1071. isWord: /\b\w+\b/.test(token), // 判断是否为单词
  1072. hasDefinition: false // 是否有释义,稍后会设置
  1073. }));
  1074. },
  1075. // 查找单词释义
  1076. findWordDefinition(word) {
  1077. const currentPageWords = this.pageWords[this.currentPage - 1] || [];
  1078. // 不区分大小写匹配
  1079. return currentPageWords.find(wordData =>
  1080. wordData.word.toLowerCase() === word.toLowerCase()
  1081. );
  1082. },
  1083. // 处理中文文本,标记重点词汇
  1084. processChineseText(text) {
  1085. const currentPageWords = this.pageWords[this.currentPage - 1] || [];
  1086. if (!text || currentPageWords.length === 0) {
  1087. return [{ text: text, isKeyword: false, keywordData: null }];
  1088. }
  1089. // 创建一个数组来存储处理后的文本片段
  1090. const segments = [];
  1091. let currentIndex = 0;
  1092. // 按照重点词汇的长度排序,优先匹配较长的词汇
  1093. const sortedWords = [...currentPageWords].sort((a, b) => b.word.length - a.word.length);
  1094. while (currentIndex < text.length) {
  1095. let matched = false;
  1096. // 尝试匹配重点词汇
  1097. for (const wordData of sortedWords) {
  1098. const keyword = wordData.word;
  1099. if (text.substr(currentIndex, keyword.length) === keyword) {
  1100. // 找到匹配的重点词汇
  1101. segments.push({
  1102. text: keyword,
  1103. isKeyword: true,
  1104. keywordData: wordData
  1105. });
  1106. currentIndex += keyword.length;
  1107. matched = true;
  1108. break;
  1109. }
  1110. }
  1111. if (!matched) {
  1112. // 没有匹配到重点词汇,添加单个字符
  1113. segments.push({
  1114. text: text[currentIndex],
  1115. isKeyword: false,
  1116. keywordData: null
  1117. });
  1118. currentIndex++;
  1119. }
  1120. }
  1121. // 合并相邻的非重点词汇片段
  1122. const mergedSegments = [];
  1123. let currentSegment = null;
  1124. for (const segment of segments) {
  1125. if (segment.isKeyword) {
  1126. // 如果当前有未完成的非重点词汇片段,先添加它
  1127. if (currentSegment) {
  1128. mergedSegments.push(currentSegment);
  1129. currentSegment = null;
  1130. }
  1131. // 添加重点词汇
  1132. mergedSegments.push(segment);
  1133. } else {
  1134. // 非重点词汇,合并到当前片段
  1135. if (currentSegment) {
  1136. currentSegment.text += segment.text;
  1137. } else {
  1138. currentSegment = { ...segment };
  1139. }
  1140. }
  1141. }
  1142. // 添加最后的非重点词汇片段
  1143. if (currentSegment) {
  1144. mergedSegments.push(currentSegment);
  1145. }
  1146. return mergedSegments;
  1147. },
  1148. // 初始化audioManager事件监听
  1149. initAudioManagerListeners() {
  1150. // 监听音频播放状态变化
  1151. audioManager.on('play', (data) => {
  1152. if (data?.audioType === 'word') {
  1153. this.isWordAudioPlaying = true;
  1154. }
  1155. });
  1156. audioManager.on('pause', (data) => {
  1157. if (data?.audioType === 'word') {
  1158. this.isWordAudioPlaying = false;
  1159. }
  1160. });
  1161. audioManager.on('ended', (data) => {
  1162. if (data?.audioType === 'word') {
  1163. this.isWordAudioPlaying = false;
  1164. }
  1165. });
  1166. audioManager.on('error', (data) => {
  1167. if (data?.audioType === 'word') {
  1168. this.isWordAudioPlaying = false;
  1169. uni.showToast({
  1170. title: '語音播放失敗',
  1171. icon: 'none'
  1172. });
  1173. }
  1174. });
  1175. },
  1176. async playWordAudio(word) {
  1177. try {
  1178. console.log('🎵 开始播放单词音频:', word);
  1179. // 🎯 使用audioManager的全局音色设置
  1180. const globalVoiceId = audioManager.getGlobalVoiceId();
  1181. const voiceIdToUse = globalVoiceId || this.voiceId;
  1182. if (!voiceIdToUse || voiceIdToUse === '' || voiceIdToUse === null || voiceIdToUse === undefined) {
  1183. console.warn('⚠️ 音色ID未设置,无法播放音频');
  1184. uni.showToast({
  1185. title: '音色未加载,请稍后重试',
  1186. icon: 'none',
  1187. duration: 2000
  1188. });
  1189. return;
  1190. }
  1191. console.log('🎵 使用音色ID:', voiceIdToUse, '播放文本:', word);
  1192. // 調用語音轉換API
  1193. const audioRes = await this.$api.music.textToVoice({
  1194. text: word,
  1195. voiceType: voiceIdToUse
  1196. });
  1197. console.log('🎵 API响应:', audioRes);
  1198. // 檢查響應並播放音頻
  1199. if (audioRes && audioRes.result && audioRes.result.url) {
  1200. console.log('✅ 获取到音频URL:', audioRes.result.url);
  1201. // 使用audioManager播放音频,应用全局语速设置
  1202. await audioManager.playAudio(audioRes.result.url, 'word', {
  1203. playbackRate: audioManager.getGlobalPlaybackRate()
  1204. });
  1205. } else {
  1206. console.error('❌ API响应无效:', audioRes);
  1207. uni.showToast({
  1208. title: '語音播放失敗',
  1209. icon: 'none'
  1210. });
  1211. }
  1212. } catch (error) {
  1213. console.error('❌ 播放单词语音异常:', error);
  1214. uni.showToast({
  1215. title: '語音播放失敗',
  1216. icon: 'none'
  1217. });
  1218. }
  1219. },
  1220. // 重複播放單詞語音(用於釋義彈窗中的揚聲器圖標)
  1221. repeatWordAudio() {
  1222. if (this.currentWordMeaning && this.currentWordMeaning.word) {
  1223. // 将单词和解释合并后播放音频
  1224. const combinedText = `${this.currentWordMeaning.word}${this.currentWordMeaning.meaning || ''}`;
  1225. this.playWordAudio(combinedText);
  1226. } else {
  1227. console.warn('沒有當前單詞可以播放');
  1228. }
  1229. },
  1230. // 处理单词点击事件
  1231. handleWordClick(word) {
  1232. const definition = this.findWordDefinition(word);
  1233. if (definition) {
  1234. this.currentWordMeaning = {
  1235. word: definition.word,
  1236. phonetic: definition.soundmark || '',
  1237. partOfSpeech: '', // 可以根据需要添加词性
  1238. meaning: definition.paraphrase || '',
  1239. knowledgeGain: definition.knowledge || '',
  1240. image: definition.image || ''
  1241. };
  1242. // 将单词和解释合并后播放音频
  1243. const combinedText = `${word}${definition.paraphrase || ''}`;
  1244. this.playWordAudio(combinedText);
  1245. this.showWordMeaning();
  1246. } else {
  1247. // 如果没有释义,只播放单词
  1248. if (word) {
  1249. this.playWordAudio(word);
  1250. } else {
  1251. }
  1252. }
  1253. },
  1254. // 处理中文重点词汇点击事件
  1255. handleChineseKeywordClick(keywordData) {
  1256. if (keywordData) {
  1257. this.currentWordMeaning = {
  1258. word: keywordData.word,
  1259. phonetic: keywordData.soundmark || '',
  1260. partOfSpeech: '', // 可以根据需要添加词性
  1261. meaning: keywordData.paraphrase || '',
  1262. knowledgeGain: keywordData.knowledge || '',
  1263. image: keywordData.image || ''
  1264. };
  1265. // 将词汇和解释合并后播放音频
  1266. const combinedText = `${keywordData.word}${keywordData.paraphrase || ''}`;
  1267. this.playWordAudio(combinedText);
  1268. this.showWordMeaning();
  1269. } else {
  1270. }
  1271. },
  1272. // 计算音频总时长
  1273. async calculateTotalDuration() {
  1274. let totalDuration = 0;
  1275. for (let i = 0; i < this.currentPageAudios.length; i++) {
  1276. const audio = this.currentPageAudios[i];
  1277. // 優先使用API返回的時長信息
  1278. if (audio.duration && audio.duration > 0) {
  1279. totalDuration += audio.duration;
  1280. continue;
  1281. }
  1282. // 如果沒有API時長信息,嘗試獲取音頻時長
  1283. try {
  1284. const duration = await this.getAudioDuration(audio.url);
  1285. audio.duration = duration;
  1286. totalDuration += duration;
  1287. } catch (error) {
  1288. console.error('获取音频时长失败:', error);
  1289. // 如果无法获取时长,根據文字長度估算(更精確的估算)
  1290. const textLength = audio.text.length;
  1291. // 假設每分鐘可以讀150-200個字符,這裡用180作為平均值
  1292. const estimatedDuration = Math.max(2, textLength / 3); // 每3個字符約1秒
  1293. audio.duration = estimatedDuration;
  1294. totalDuration += estimatedDuration;
  1295. console.log(`估算音頻時長 ${i + 1}:`, estimatedDuration, '秒 (文字長度:', textLength, ')');
  1296. }
  1297. }
  1298. this.totalTime = totalDuration;
  1299. },
  1300. // 获取音频时长
  1301. getAudioDuration(audioUrl) {
  1302. return new Promise((resolve, reject) => {
  1303. const audio = uni.createInnerAudioContext();
  1304. audio.src = audioUrl;
  1305. let resolved = false;
  1306. // 监听音频加载完成事件
  1307. audio.onCanplay(() => {
  1308. if (!resolved && audio.duration && audio.duration > 0) {
  1309. resolved = true;
  1310. resolve(audio.duration);
  1311. audio.destroy();
  1312. }
  1313. });
  1314. // 监听音频元数据加载完成事件
  1315. audio.onLoadedmetadata = () => {
  1316. if (!resolved && audio.duration && audio.duration > 0) {
  1317. resolved = true;
  1318. resolve(audio.duration);
  1319. audio.destroy();
  1320. }
  1321. };
  1322. // 监听音频时长更新事件
  1323. audio.onDurationChange = () => {
  1324. if (!resolved && audio.duration && audio.duration > 0) {
  1325. resolved = true;
  1326. resolve(audio.duration);
  1327. audio.destroy();
  1328. }
  1329. };
  1330. // 如果以上方法都無法獲取時長,嘗試播放一小段來獲取時長
  1331. audio.onPlay(() => {
  1332. if (!resolved) {
  1333. setTimeout(() => {
  1334. if (!resolved && audio.duration && audio.duration > 0) {
  1335. resolved = true;
  1336. resolve(audio.duration);
  1337. audio.destroy();
  1338. }
  1339. }, 100); // 播放100ms後檢查時長
  1340. }
  1341. });
  1342. audio.onError((error) => {
  1343. console.error('音频加载失败:', error);
  1344. if (!resolved) {
  1345. resolved = true;
  1346. reject(error);
  1347. audio.destroy();
  1348. }
  1349. });
  1350. // 設置較長的超時時間,並在超時前嘗試播放
  1351. setTimeout(() => {
  1352. if (!resolved) {
  1353. audio.play();
  1354. }
  1355. }, 1000);
  1356. // 最終超時處理
  1357. setTimeout(() => {
  1358. if (!resolved) {
  1359. console.warn('獲取音頻時長超時,使用默認值');
  1360. resolved = true;
  1361. reject(new Error('获取音频时长超时'));
  1362. audio.destroy();
  1363. }
  1364. }, 5000);
  1365. });
  1366. },
  1367. // 音频控制方法 - 这些方法现在由AudioControls组件处理
  1368. // 保留一些简单的接口方法用于与AudioControls组件通信
  1369. // 判断当前文本是否应该高亮 - 这个方法需要保留,因为它用于模板渲染
  1370. isTextHighlighted(page, index) {
  1371. // 只有当前页面且是文本类型才可能高亮
  1372. if (page !== this.bookPages[this.currentPage - 1]) return false;
  1373. // 计算当前页面中text类型元素的索引
  1374. let textIndex = 0;
  1375. for (let i = 0; i <= index; i++) {
  1376. if (page[i].type === 'text') {
  1377. if (i === index) {
  1378. const shouldHighlight = textIndex === this.currentHighlightIndex;
  1379. if (shouldHighlight) {
  1380. }
  1381. return shouldHighlight;
  1382. }
  1383. textIndex++;
  1384. }
  1385. }
  1386. return false;
  1387. },
  1388. // 判断划线重点卡片中的文本是否应该高亮
  1389. isCardTextHighlighted(page, index) {
  1390. // 只有当前页面且是文本类型才可能高亮
  1391. if (page !== this.bookPages[this.currentPage - 1]) return false;
  1392. // 计算当前页面中text类型元素的索引
  1393. let textIndex = 0;
  1394. for (let i = 0; i <= index; i++) {
  1395. if (page[i].type === 'text') {
  1396. if (i === index) {
  1397. return textIndex === this.currentHighlightIndex;
  1398. }
  1399. textIndex++;
  1400. }
  1401. }
  1402. return false;
  1403. },
  1404. async previousPage() {
  1405. if (this.currentPage > 1) {
  1406. this.currentPage--;
  1407. // 获取对应页面的数据(如果还没有获取过)
  1408. if (this.courseIdList[this.currentPage - 1] && this.bookPages[this.currentPage - 1].length === 0) {
  1409. await this.getBookPages(this.courseIdList[this.currentPage - 1]);
  1410. }
  1411. }
  1412. },
  1413. async nextPage() {
  1414. if (this.currentPage < this.bookPages.length) {
  1415. this.currentPage++;
  1416. // 获取对应页面的数据(如果还没有获取过)
  1417. if (this.courseIdList[this.currentPage - 1] && this.bookPages[this.currentPage - 1].length === 0) {
  1418. await this.getBookPages(this.courseIdList[this.currentPage - 1]);
  1419. }
  1420. }
  1421. },
  1422. toggleSound() {
  1423. // 检查是否正在加载音频,如果是则阻止音色切换
  1424. if (this.isAudioLoading) {
  1425. uni.showToast({
  1426. title: '音频加载中,请稍后再试',
  1427. icon: 'none',
  1428. duration: 2000
  1429. });
  1430. return;
  1431. }
  1432. // 检查AudioControls组件是否正在加载音频
  1433. if (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls && this.$refs.customTabbar.$refs.audioControls.isAudioLoading) {
  1434. uni.showToast({
  1435. title: '音频加载中,请稍后再试',
  1436. icon: 'none',
  1437. duration: 2000
  1438. });
  1439. return;
  1440. }
  1441. console.log('音色切换')
  1442. uni.navigateTo({
  1443. url: '/subPages/home/music?voiceId=' + this.voiceId
  1444. })
  1445. },
  1446. unlockBook() {
  1447. console.log('解锁全书')
  1448. // 这里可以跳转到会员页面或者调用解锁接口
  1449. uni.navigateTo({
  1450. url: '/subPages/member/recharge'
  1451. })
  1452. },
  1453. // 跳转到下一课
  1454. async goToNextCourse() {
  1455. if (!this.hasNextCourse) {
  1456. uni.showToast({
  1457. title: '已经是最后一课了',
  1458. icon: 'none',
  1459. duration: 2000
  1460. });
  1461. return;
  1462. }
  1463. try {
  1464. // 找到当前课程在课程列表中的索引
  1465. const currentCourseIndex = this.courseList.findIndex(course => course.id == this.courseId);
  1466. if (currentCourseIndex >= 0 && currentCourseIndex < this.courseList.length - 1) {
  1467. // 获取下一课的ID
  1468. const nextCourse = this.courseList[currentCourseIndex + 1];
  1469. console.log('跳转到下一课:', nextCourse);
  1470. // 切换到下一课
  1471. await this.selectCourse(nextCourse.id);
  1472. uni.showToast({
  1473. title: `已切换到第${currentCourseIndex + 2}`,
  1474. icon: 'success',
  1475. duration: 2000
  1476. });
  1477. }
  1478. } catch (error) {
  1479. console.error('跳转下一课失败:', error);
  1480. uni.showToast({
  1481. title: '跳转失败,请重试',
  1482. icon: 'none',
  1483. duration: 2000
  1484. });
  1485. }
  1486. },
  1487. // 回到开始(当前课程的第一页)
  1488. async backToStart() {
  1489. try {
  1490. // 回到当前课程的第一页
  1491. this.currentPage = 1;
  1492. console.log('回到开始,跳转到第一页');
  1493. // 获取第一页的数据(如果还没有获取过)
  1494. if (this.courseIdList[0] && this.bookPages[0].length === 0) {
  1495. await this.getBookPages(this.courseIdList[0]);
  1496. }
  1497. uni.showToast({
  1498. title: '已回到第一页',
  1499. icon: 'success',
  1500. duration: 2000
  1501. });
  1502. } catch (error) {
  1503. console.error('回到开始失败:', error);
  1504. uni.showToast({
  1505. title: '操作失败,请重试',
  1506. icon: 'none',
  1507. duration: 2000
  1508. });
  1509. }
  1510. },
  1511. async goToPage(page) {
  1512. this.currentPage = page
  1513. console.log('跳转到页面:', page)
  1514. // 获取对应页面的数据(如果还没有获取过)
  1515. if (this.courseIdList[this.currentPage - 1] && this.bookPages[this.currentPage - 1].length === 0) {
  1516. await this.getBookPages(this.courseIdList[this.currentPage - 1]);
  1517. }
  1518. },
  1519. async onSwiperChange(e) {
  1520. this.currentPage = e.detail.current + 1
  1521. // 获取对应页面的数据(如果还没有获取过)
  1522. if (this.courseIdList[this.currentPage - 1] && this.bookPages[this.currentPage - 1].length === 0) {
  1523. await this.getBookPages(this.courseIdList[this.currentPage - 1]);
  1524. }
  1525. },
  1526. async getCourseList(id) {
  1527. const res = await this.$api.book.coursePage({
  1528. id: id
  1529. })
  1530. if (res.code === 200) {
  1531. // 课程切换时,先清理音频控制组件的所有数据
  1532. if (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls) {
  1533. this.$refs.customTabbar.$refs.audioControls.resetForCourseChange();
  1534. }
  1535. // 清空当前页面相关数据
  1536. this.currentPage = 1; // 重置到第一页
  1537. this.currentCourse = 1; // 重置当前课程索引
  1538. this.currentWordMeaning = null; // 清空单词释义
  1539. this.currentWordAudio = null; // 清空单词音频
  1540. this.currentHighlightIndex = -1; // 清空高亮索引
  1541. // 清理单词音频缓存
  1542. this.clearWordAudioCache();
  1543. // 重新初始化课程数据
  1544. this.courseIdList = res.result.map(item => item.id)
  1545. // 初始化二维数组 换一种方式
  1546. this.bookPages = this.courseIdList.map(() => [])
  1547. // 初始化标题数组
  1548. this.pageTitles = this.courseIdList.map(() => '')
  1549. // 初始化页面类型数组
  1550. this.pageTypes = this.courseIdList.map(() => '')
  1551. // 初始化页面单词数组
  1552. this.pageWords = this.courseIdList.map(() => [])
  1553. // 初始化滚动位置数组
  1554. this.scrollTops = this.courseIdList.map(() => 0)
  1555. // 初始化第一页
  1556. if (this.courseIdList.length > 0) {
  1557. await this.getBookPages(this.courseIdList[0])
  1558. // 课程切换后,确保音频控件能正确加载新课程的音频
  1559. // 使用$nextTick确保DOM和数据都已更新
  1560. this.$nextTick(async () => {
  1561. if (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls) {
  1562. try {
  1563. // 直接调用getCurrentPageAudio方法,更可靠
  1564. await this.$refs.customTabbar.$refs.audioControls.getCurrentPageAudio();
  1565. } catch (error) {
  1566. console.error('课程切换后音频加载失败:', error);
  1567. }
  1568. }
  1569. });
  1570. // 预加载后续几页的内容(异步执行,不阻塞当前页面显示)
  1571. this.preloadNextPages()
  1572. }
  1573. }
  1574. },
  1575. async getBookPages(id) {
  1576. const res = await this.$api.book.coursesPageDetail({
  1577. id: id
  1578. })
  1579. if (res.code === 200) {
  1580. // 使用$set确保响应式更新
  1581. const rawPageData = JSON.parse(res.result.content)
  1582. console.log('获取到的原始页面数据:', rawPageData)
  1583. // 过滤掉无效的数据项
  1584. const filteredPageData = rawPageData.filter(item => {
  1585. return item && typeof item === 'object' && (item.type || item.content)
  1586. })
  1587. console.log('过滤后的页面数据:', filteredPageData)
  1588. // 确保当前页面存在
  1589. if (this.currentPage - 1 < this.bookPages.length) {
  1590. this.$set(this.bookPages, this.currentPage - 1, filteredPageData)
  1591. // 保存页面标题
  1592. this.$set(this.pageTitles, this.currentPage - 1, res.result.title || '')
  1593. // 保存页面类型
  1594. this.$set(this.pageTypes, this.currentPage - 1, res.result.type || '')
  1595. // 保存页面单词释义数据
  1596. this.$set(this.pageWords, this.currentPage - 1, res.result.words || [])
  1597. // 保存页面付费状态
  1598. this.$set(this.pagePay, this.currentPage - 1, res.result.pay || 'N')
  1599. }
  1600. }
  1601. },
  1602. // 获取课程列表
  1603. async getCoursePageList(bookId) {
  1604. const res = await this.$api.book.course({
  1605. id: bookId
  1606. })
  1607. if (res.code === 200) {
  1608. this.courseList = res.result.records
  1609. // 打上序列号
  1610. this.courseList = this.courseList.map((item, index) => ({
  1611. ...item,
  1612. index,
  1613. }))
  1614. }
  1615. },
  1616. // 清理音频缓存
  1617. clearAudioCache() {
  1618. this.audioCache = {};
  1619. },
  1620. // 清理單詞語音緩存
  1621. clearWordAudioCache() {
  1622. this.wordAudioCache = {};
  1623. },
  1624. // 限制缓存大小,保留最近访问的页面
  1625. limitCacheSize(maxSize = 10) {
  1626. const cacheKeys = Object.keys(this.audioCache);
  1627. if (cacheKeys.length > maxSize) {
  1628. // 删除最旧的缓存项
  1629. const keysToDelete = cacheKeys.slice(0, cacheKeys.length - maxSize);
  1630. keysToDelete.forEach(key => {
  1631. delete this.audioCache[key];
  1632. });
  1633. }
  1634. },
  1635. // 预加载后续页面内容
  1636. async preloadNextPages() {
  1637. try {
  1638. // 优化策略:只预加载接下来的2-3页内容,避免过多请求
  1639. const preloadCount = Math.min(3, this.courseIdList.length - 1); // 预加载3页或剩余页数
  1640. // 串行预加载,避免并发请求过多
  1641. for (let i = 1; i <= preloadCount; i++) {
  1642. if (i < this.courseIdList.length && this.bookPages[i].length === 0) {
  1643. try {
  1644. await this.preloadSinglePage(this.courseIdList[i], i);
  1645. // 每页之间间隔400ms,提高预加载效率
  1646. if (i < preloadCount) {
  1647. await new Promise(resolve => setTimeout(resolve, 0));
  1648. }
  1649. } catch (error) {
  1650. console.error(`预加载第${i + 1}页失败:`, error);
  1651. // 继续预加载下一页
  1652. }
  1653. }
  1654. }
  1655. // 延迟800ms后再通知AudioControls组件开始预加载音频,提高响应速度
  1656. setTimeout(() => {
  1657. if (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls) {
  1658. this.$refs.customTabbar.$refs.audioControls.startPreloadAudio();
  1659. }
  1660. }, 800);
  1661. } catch (error) {
  1662. console.error('预加载页面内容失败:', error);
  1663. }
  1664. },
  1665. // 预加载单个页面
  1666. async preloadSinglePage(courseId, pageIndex) {
  1667. try {
  1668. const res = await this.$api.book.coursesPageDetail({
  1669. id: courseId
  1670. });
  1671. if (res.code === 200) {
  1672. const rawPageData = JSON.parse(res.result.content);
  1673. const filteredPageData = rawPageData.filter(item => {
  1674. return item && typeof item === 'object' && (item.type || item.content);
  1675. });
  1676. // 使用$set确保响应式更新
  1677. this.$set(this.bookPages, pageIndex, filteredPageData);
  1678. this.$set(this.pageTitles, pageIndex, res.result.title || '');
  1679. this.$set(this.pageTypes, pageIndex, res.result.type || '');
  1680. this.$set(this.pageWords, pageIndex, res.result.words || []);
  1681. this.$set(this.pagePay, pageIndex, res.result.pay || 'N');
  1682. }
  1683. } catch (error) {
  1684. console.error(`预加载第${pageIndex + 1}页失败:`, error);
  1685. throw error;
  1686. }
  1687. },
  1688. // 自動加載第一頁音頻並播放
  1689. async autoLoadAndPlayFirstPage() {
  1690. try {
  1691. // 確保當前是第一頁且需要加載音頻
  1692. if (this.currentPage === 1 && this.shouldLoadAudio) {
  1693. // 加載音頻
  1694. await this.getCurrentPageAudio();
  1695. // 檢查是否成功加載音頻
  1696. if (this.currentPageAudios && this.currentPageAudios.length > 0) {
  1697. // getCurrentPageAudio方法已經處理了第一個音頻的播放,這裡不需要再次調用playAudio
  1698. } else {
  1699. }
  1700. } else {
  1701. }
  1702. } catch (error) {
  1703. console.error('自動加載和播放音頻失敗:', error);
  1704. }
  1705. },
  1706. },
  1707. async onLoad(args) {
  1708. this.$scrollTo('imageRef')
  1709. // 初始化audioManager事件监听
  1710. this.initAudioManagerListeners();
  1711. // 监听音色切换事件,传递给AudioControls组件处理
  1712. uni.$on('selectVoice', async (voiceId) => {
  1713. if (this.voiceId === voiceId) {
  1714. return;
  1715. }
  1716. // 检查是否正在加载音频,如果是则阻止音色切换
  1717. if (this.isAudioLoading || (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls && this.$refs.customTabbar.$refs.audioControls.isAudioLoading)) {
  1718. uni.showToast({
  1719. title: '音频加载中,请稍后再试',
  1720. icon: 'none',
  1721. duration: 2000
  1722. });
  1723. return;
  1724. }
  1725. // 更新本地音色ID
  1726. this.voiceId = voiceId;
  1727. // 同步音色设置到audioManager
  1728. audioManager.setGlobalVoiceId(voiceId);
  1729. // 清理單詞語音資源
  1730. this.clearWordAudioCache();
  1731. // 停止当前播放的音频(现在由audioManager统一管理)
  1732. audioManager.stopCurrentAudio();
  1733. // 通知AudioControls组件处理音色切换
  1734. if (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls) {
  1735. try {
  1736. // 传入选项:preloadAllPages: true 表示要预加载所有页面的音频
  1737. await this.$refs.customTabbar.$refs.audioControls.handleVoiceChange(voiceId, {
  1738. preloadAllPages: true
  1739. });
  1740. } catch (error) {
  1741. console.error('音色切换处理失败:', error);
  1742. }
  1743. }
  1744. })
  1745. this.courseId = args.courseId
  1746. this.currentCourse = args.courseId // 同时设置 currentCourse
  1747. this.memberId = args.memberId
  1748. // 先获取点进来的课程的页面列表
  1749. await Promise.all([this.getVoiceList(), this.getMemberInfo(), this.getCourseList(this.courseId), this.getCoursePageList(args.bookId)])
  1750. // 页面加载完成后,通知AudioControls组件自动加载第一页音频
  1751. this.$nextTick(() => {
  1752. if (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls) {
  1753. this.$refs.customTabbar.$refs.audioControls.autoLoadAndPlayFirstPage();
  1754. }
  1755. });
  1756. },
  1757. // 页面卸载时清理资源
  1758. onUnload() {
  1759. uni.$off('selectVoice')
  1760. // 0. 清理滚动防抖定时器
  1761. if (this.scrollDebounceTimer) {
  1762. clearTimeout(this.scrollDebounceTimer);
  1763. this.scrollDebounceTimer = null;
  1764. }
  1765. // 1. 清理单词语音资源
  1766. if (this.currentWordAudio) {
  1767. try {
  1768. this.currentWordAudio.destroy();
  1769. } catch (error) {
  1770. console.error('销毁单词音频实例失败:', error);
  1771. }
  1772. this.currentWordAudio = null;
  1773. }
  1774. // 2. 清理单词语音缓存
  1775. this.clearWordAudioCache();
  1776. // 3. 停止单词音频播放状态
  1777. this.isWordAudioPlaying = false;
  1778. // 4. 通知AudioControls组件清理资源
  1779. if (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls) {
  1780. this.$refs.customTabbar.$refs.audioControls.destroyAudio();
  1781. }
  1782. // 5. 清理全局音频实例(防止遗漏)
  1783. try {
  1784. // 获取所有可能的音频上下文并销毁
  1785. if (typeof wx !== 'undefined' && wx.getBackgroundAudioManager) {
  1786. const bgAudio = wx.getBackgroundAudioManager();
  1787. if (bgAudio) {
  1788. bgAudio.stop();
  1789. }
  1790. }
  1791. } catch (error) {
  1792. console.error('清理背景音频失败:', error);
  1793. }
  1794. },
  1795. // 页面隐藏时暂停音频
  1796. onHide() {
  1797. // 1. 暂停单词音频
  1798. if (this.currentWordAudio && this.isWordAudioPlaying) {
  1799. try {
  1800. this.currentWordAudio.pause();
  1801. this.isWordAudioPlaying = false;
  1802. } catch (error) {
  1803. console.error('暂停单词音频失败:', error);
  1804. }
  1805. }
  1806. // 2. 通知AudioControls组件暂停音频
  1807. if (this.$refs.customTabbar && this.$refs.customTabbar.$refs.audioControls) {
  1808. this.$refs.customTabbar.$refs.audioControls.pauseOnHide();
  1809. }
  1810. }
  1811. }
  1812. </script>
  1813. <style lang="scss" scoped>
  1814. .book-container {
  1815. width: 100%;
  1816. min-height: 100vh;
  1817. background-color: #F8F8F8;
  1818. position: relative;
  1819. overflow: hidden;
  1820. }
  1821. .custom-navbar {
  1822. position: fixed;
  1823. top: 0;
  1824. left: 0;
  1825. right: 0;
  1826. background-color: #F8F8F8;
  1827. z-index: 1000;
  1828. transition: transform 0.3s ease;
  1829. &.navbar-hidden {
  1830. transform: translateY(-100%);
  1831. }
  1832. }
  1833. .navbar-content {
  1834. display: flex;
  1835. align-items: center;
  1836. justify-content: space-between;
  1837. padding: 20rpx 32rpx;
  1838. // padding-top: calc(20rpx + var(--status-bar-height, 0));
  1839. height: 60rpx;
  1840. }
  1841. .navbar-left,
  1842. .navbar-right {
  1843. width: 80rpx;
  1844. display: flex;
  1845. align-items: center;
  1846. }
  1847. .navbar-right {
  1848. justify-content: flex-end;
  1849. flex: 1;
  1850. }
  1851. .navbar-title {
  1852. transform: translateX(-50rpx);
  1853. flex: 1;
  1854. text-align: center;
  1855. font-family: PingFang SC;
  1856. font-weight: 500;
  1857. font-size: 32rpx;
  1858. color: #262626;
  1859. line-height: 48rpx;
  1860. }
  1861. .content-swiper {
  1862. flex: 1;
  1863. // min-height: calc(100vh - 100rpx);
  1864. // margin-top: 100rpx;
  1865. height: 100vh;
  1866. }
  1867. .swiper-item {
  1868. min-height: 100vh;
  1869. // background-color: red;
  1870. }
  1871. .content-area {
  1872. flex: 1;
  1873. padding: 30rpx 40rpx 100rpx;
  1874. /* #ifndef H5 */
  1875. padding: 100rpx 40rpx;
  1876. /* #endif */
  1877. // padding-top: ;
  1878. // background: linear-gradient(180deg, #DEFFFF 0%, #FBFEFF 22.65%, #F0FBFF 100%);
  1879. min-height: 100%;
  1880. box-sizing: border-box;
  1881. overflow-y: auto;
  1882. .title {
  1883. font-family: PingFang SC;
  1884. font-weight: 500;
  1885. font-size: 34rpx;
  1886. text-align: center;
  1887. color: #181818;
  1888. line-height: 48rpx;
  1889. margin-bottom: 32rpx;
  1890. }
  1891. .image-container {
  1892. width: 100%;
  1893. display: flex;
  1894. justify-content: center;
  1895. align-items: center;
  1896. margin: 30rpx 0;
  1897. /* 平板设备适配 */
  1898. @media screen and (min-width: 768px) {
  1899. margin: 40rpx 0;
  1900. }
  1901. }
  1902. .content-image {
  1903. width: 100%;
  1904. height: auto;
  1905. //max-width: 600rpx;
  1906. /* 限制最大宽度,避免在大屏设备上过大 */
  1907. display: block;
  1908. border-radius: 12rpx;
  1909. /* 添加圆角,提升视觉效果 */
  1910. box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
  1911. /* 添加阴影,增强层次感 */
  1912. /* 平板设备适配 */
  1913. // @media screen and (min-width: 768px) {
  1914. // max-width: 500rpx;
  1915. // }
  1916. }
  1917. .video-content {
  1918. width: 100%;
  1919. height: auto;
  1920. margin: 30rpx auto;
  1921. position: relative;
  1922. .video-player {
  1923. // height: 100%;
  1924. width: 100%;
  1925. height: 60vw;
  1926. // margin: 0 auto;
  1927. // height: auto;
  1928. }
  1929. .video-loading {
  1930. position: absolute;
  1931. top: 50%;
  1932. left: 50%;
  1933. transform: translate(-50%, -50%);
  1934. color: #666;
  1935. font-size: 28rpx;
  1936. }
  1937. }
  1938. }
  1939. .card-content {
  1940. background: linear-gradient(180deg, #DEFFFF 0%, #FBFEFF 22.65%, #F0FBFF 100%);
  1941. display: flex;
  1942. flex-direction: column;
  1943. gap: 32rpx;
  1944. min-height: 1172rpx;
  1945. margin-top: 20rpx;
  1946. border-radius: 32rpx;
  1947. // height: 100%;
  1948. padding: 20rpx;
  1949. padding-bottom: 100rpx;
  1950. // margin: 0
  1951. border: 1px solid #FFFFFF;
  1952. box-sizing: border-box;
  1953. .card-line {
  1954. display: flex;
  1955. align-items: center;
  1956. // margin-bottom: 20rpx;
  1957. padding: 20rpx;
  1958. padding-bottom: 0;
  1959. }
  1960. .card-line-image {
  1961. width: 48rpx;
  1962. height: 48rpx;
  1963. margin-right: 16rpx;
  1964. }
  1965. .card-line-text {
  1966. font-family: PingFang SC;
  1967. font-weight: 600;
  1968. font-size: 30rpx;
  1969. line-height: 48rpx;
  1970. color: #3B3D3D;
  1971. }
  1972. .card-image {
  1973. // width: 590rpx;
  1974. width: 100%;
  1975. height: 268rpx;
  1976. border-radius: 24rpx;
  1977. margin: 30rpx auto;
  1978. // margin-bottom: 20rpx;
  1979. }
  1980. // .english-text {
  1981. // display: block;
  1982. // font-family: PingFang SC;
  1983. // font-weight: 600;
  1984. // font-size: 32rpx;
  1985. // line-height: 48rpx;
  1986. // color: #3B3D3D;
  1987. // // margin-bottom: 16rpx;
  1988. // }
  1989. // .english-text-container {
  1990. // display: flex;
  1991. // flex-wrap: wrap;
  1992. // align-items: baseline;
  1993. // }
  1994. // .english-token {
  1995. // font-family: PingFang SC;
  1996. // font-weight: 600;
  1997. // font-size: 32rpx;
  1998. // line-height: 48rpx;
  1999. // color: #3B3D3D;
  2000. // margin-right: 10rpx;
  2001. // }
  2002. .clickable-word {
  2003. background: $primary-color;
  2004. text-decoration: underline;
  2005. cursor: pointer;
  2006. transition: all 0.2s ease;
  2007. padding: 0 20rpx;
  2008. }
  2009. .clickable-word:hover {
  2010. background-color: rgba(0, 122, 255, 0.1);
  2011. border-radius: 4rpx;
  2012. }
  2013. .chinese-segment {
  2014. font-family: PingFang SC;
  2015. font-weight: 400;
  2016. font-size: 28rpx;
  2017. line-height: 48rpx;
  2018. color: #3B3D3D;
  2019. }
  2020. .clickable-keyword {
  2021. background: $primary-color;
  2022. text-decoration: underline;
  2023. cursor: pointer;
  2024. color: #fff !important;
  2025. transition: all 0.2s ease;
  2026. border-radius: 4rpx;
  2027. padding: 4rpx;
  2028. }
  2029. .clickable-keyword:hover {
  2030. background-color: rgba(0, 122, 255, 0.1);
  2031. }
  2032. .chinese-text {
  2033. display: block;
  2034. font-family: PingFang SC;
  2035. font-weight: 400;
  2036. font-size: 28rpx;
  2037. line-height: 48rpx;
  2038. color: #4F4F4F;
  2039. }
  2040. }
  2041. /* 会员限制页面样式 */
  2042. .member-content {
  2043. display: flex;
  2044. flex-direction: column;
  2045. align-items: center;
  2046. justify-content: center;
  2047. height: 90%;
  2048. background-color: #F8F8F8;
  2049. padding: 40rpx;
  2050. margin: -40rpx;
  2051. box-sizing: border-box;
  2052. }
  2053. .member-title {
  2054. font-family: PingFang SC;
  2055. font-weight: 500;
  2056. font-size: 40rpx;
  2057. line-height: 1;
  2058. color: #6f6f6f;
  2059. text-align: center;
  2060. margin-bottom: 48rpx;
  2061. }
  2062. .member-button {
  2063. width: 670rpx;
  2064. height: 72rpx;
  2065. background: #06DADC;
  2066. border-radius: 200rpx;
  2067. display: flex;
  2068. align-items: center;
  2069. justify-content: center;
  2070. }
  2071. .member-button-text {
  2072. font-family: PingFang SC;
  2073. font-weight: 400;
  2074. font-size: 30rpx;
  2075. color: #FFFFFF;
  2076. }
  2077. // .video-content {
  2078. // width: 100%;
  2079. // height: auto;
  2080. // // margin: 200rpx -40rpx 0;
  2081. // // height: 500rpx;
  2082. // background-color: #FFFFFF;
  2083. // // padding: 40rpx;
  2084. // border-radius: 24rpx;
  2085. // display: flex;
  2086. // align-items: center;
  2087. // justify-content: center;
  2088. // .video-player{
  2089. // width: 100%;
  2090. // margin: 0 auto;
  2091. // height: auto;
  2092. // }
  2093. // }
  2094. .text-content {
  2095. // background-color: #F6F6F6;
  2096. box-sizing: border-box;
  2097. &>view {
  2098. padding: 20rpx;
  2099. }
  2100. }
  2101. .content-text {
  2102. font-family: PingFang SC;
  2103. // font-weight: 400;
  2104. font-size: 28rpx;
  2105. color: #3B3D3D;
  2106. line-height: 48rpx;
  2107. letter-spacing: 0;
  2108. text-align: justify;
  2109. word-break: break-all;
  2110. transition: all 0.3s ease;
  2111. }
  2112. .clickable-text {
  2113. cursor: pointer;
  2114. &:active {
  2115. background-color: rgba(6, 218, 220, 0.1);
  2116. border-radius: 4rpx;
  2117. }
  2118. }
  2119. .lead-text {
  2120. background: #06dadc12;
  2121. // background: #fffbe6;#06dadc
  2122. /* 柔和的提示背景 */
  2123. // border: 1px solid #ffe58f;
  2124. border-radius: 8px;
  2125. // padding: 10rpx 20rpx;
  2126. /* 添加平滑过渡动画 */
  2127. transition: all 0.3s ease;
  2128. }
  2129. .introduction-text {
  2130. background: #fffbe6;
  2131. border: 1px solid #ffe58f;
  2132. border-radius: 8px;
  2133. padding: 10rpx 20rpx;
  2134. }
  2135. .text-highlight {
  2136. background-color: rgba(255, 248, 220, 0.8);
  2137. /* 温暖的米黄色,对眼睛友好 */
  2138. border-left: 4rpx solid #ffd700;
  2139. /* 左侧金色边框作为朗读指示 */
  2140. padding: 4rpx 8rpx;
  2141. border-radius: 6rpx;
  2142. box-shadow: 0 2rpx 6rpx rgba(255, 215, 0, 0.15);
  2143. /* 柔和的阴影 */
  2144. /* 添加平滑过渡动画 */
  2145. transition: all 0.3s ease;
  2146. }
  2147. </style>