四零语境前端代码仓库
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.

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