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

2392 lines
69 KiB

  1. <template>
  2. <view class="book-container">
  3. <!-- 自定义顶部导航栏 -->
  4. <uv-status-bar></uv-status-bar>
  5. <view class="custom-navbar" :class="{ 'navbar-hidden': !showNavbar }">
  6. <uv-status-bar></uv-status-bar>
  7. <view class="navbar-content">
  8. <view class="navbar-left" @click="goBack">
  9. <uv-icon name="arrow-left" size="20" color="#262626"></uv-icon>
  10. </view>
  11. <view class="navbar-title">{{ currentPageTitle }}</view>
  12. </view>
  13. </view>
  14. <!-- Swiper内容区域 -->
  15. <swiper
  16. class="content-swiper"
  17. :current="currentPage - 1"
  18. :disable-touch="isAudioLoading"
  19. @change="onSwiperChange"
  20. >
  21. <swiper-item
  22. v-for="(page, index) in bookPages"
  23. :key="index"
  24. class="swiper-item"
  25. >
  26. <view class="content-area" @click="toggleNavbar">
  27. <!-- 会员限制页面 -->
  28. <view v-if="!isMember && pagePay[index] === 'Y'" class="member-content">
  29. <text class="member-title">{{ pageTitles[index] }}</text>
  30. <view class="member-button" @click="unlockBook">
  31. <text class="member-button-text">升級會員解鎖</text>
  32. </view>
  33. </view>
  34. <!-- 图片卡片页面 -->
  35. <view class="card-content" v-else-if="currentPageType === '1'">
  36. <view class="card-line">
  37. <image :src="configParamContent('highlight_icon')" class="card-line-image" mode="aspectFill" />
  38. <text class="card-line-text">划线重点</text>
  39. </view>
  40. <view v-for="(item, index) in page" :key="index">
  41. <image class="card-image" v-if="item.type === 'image'" :src="item.imageUrl" mode="aspectFill"></image>
  42. <view class="english-text-container" v-else-if="item.type === 'text' && item.language === 'en'">
  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. @tap="token.isWord && findWordDefinition(token.text) ? handleWordClick(token.text) : null"
  48. user-select
  49. >{{ token.text }}</text>
  50. </view>
  51. <text class="chinese-text" v-else-if="item.type === 'text' && item.language === 'zh'" user-select>{{ item.content }}</text>
  52. </view>
  53. </view>
  54. <view v-else>
  55. <view v-for="(item, index) in page" :key="index">
  56. <!-- 文本页面 -->
  57. <view v-if="item.type === 'text'" class="text-content" >
  58. <view :class="{ 'text-highlight': isTextHighlighted(page, index) }">
  59. <text
  60. class="content-text"
  61. user-select
  62. >
  63. {{ item.content }}
  64. </text>
  65. </view>
  66. </view>
  67. <!-- 文本页面 -->
  68. <view v-else-if="item.type === 'image'" class="image-container">
  69. <image class="content-image" :src="item.imageUrl" mode="aspectFill"></image>
  70. </view>
  71. <!-- 视频页面 -->
  72. <view v-else-if="item.type === 'video'" class="video-content">
  73. <video :src="item.video" class="video-player" controls :poster="item.poster"></video>
  74. </view>
  75. </view>
  76. </view>
  77. </view>
  78. </swiper-item>
  79. </swiper>
  80. <!-- 自定义底部控制栏 -->
  81. <view class="custom-tabbar" :class="{ 'tabbar-hidden': !showNavbar }">
  82. <!-- 音频控制栏 -->
  83. <!-- 获取音频按钮 -->
  84. <view v-if="!hasAudioData && !isAudioLoading && isTextPage" class="audio-get-button-container">
  85. <view class="get-audio-btn" @click="handleGetAudio">
  86. <uv-icon name="play-circle" size="24" color="#06DADC"></uv-icon>
  87. <text class="get-audio-text">获取第{{currentPage}}页音频</text>
  88. </view>
  89. </view>
  90. <!-- 音频加载状态 -->
  91. <view v-else-if="isAudioLoading && isTextPage" class="audio-loading-container">
  92. <uv-loading-icon mode="spinner" size="30" color="#06DADC"></uv-loading-icon>
  93. <text class="loading-text">正在加载第{{currentPage}}页音频...</text>
  94. </view>
  95. <!-- 正常音频控制栏 -->
  96. <view v-else-if="hasAudioData" class="audio-controls" :class="{ 'audio-hidden': !isTextPage }">
  97. <view class="audio-time">
  98. <text class="time-text">{{ formatTime(currentTime) }}</text>
  99. <view class="progress-container">
  100. <uv-slider
  101. v-model="sliderValue"
  102. :min="0"
  103. :max="totalTime"
  104. :step="0.1"
  105. activeColor="#06DADC"
  106. backgroundColor="#e0e0e0"
  107. :blockSize="16"
  108. blockColor="#ffffff"
  109. :disabled="!hasAudioData || totalTime <= 0"
  110. @input="onSliderInput"
  111. @changing="onSliderChanging"
  112. @change="onSliderChange"
  113. :customStyle="{ flex: 1, margin: '0 10px' }"
  114. />
  115. </view>
  116. <text class="time-text">{{ formatTime(totalTime) }}</text>
  117. </view>
  118. <view class="audio-controls-row">
  119. <view class="control-btn" @click="toggleLoop">
  120. <uv-icon name="reload" size="20" :color="isLoop ? '#06DADC' : '#999'"></uv-icon>
  121. <text class="control-text">循环</text>
  122. </view>
  123. <view class="control-btn" @click="previousPage">
  124. <text class="control-text">上一页</text>
  125. </view>
  126. <view class="play-btn" @click="togglePlay">
  127. <uv-icon :name="isPlaying ? 'pause-circle-fill' : 'play-circle-fill'" size="40" color="#666"></uv-icon>
  128. </view>
  129. <view class="control-btn" @click="nextPage">
  130. <text class="control-text">下一页</text>
  131. </view>
  132. <view class="control-btn" @click="toggleSpeed" :class="{ 'disabled': !playbackRateSupported }">
  133. <text class="control-text" :style="{ opacity: playbackRateSupported ? 1 : 0.5 }">
  134. {{ playbackRateSupported ? playSpeed + 'x' : '不支持' }}
  135. </text>
  136. </view>
  137. </view>
  138. </view>
  139. <view style="background-color: #fff;position: relative;z-index: 100" >
  140. <view class="tabbar-content">
  141. <view class="tabbar-left">
  142. <view class="tab-button" @click="toggleCoursePopup">
  143. <image src="/static/课程图标.png" class="tab-icon" />
  144. <text class="tab-text">课程</text>
  145. </view>
  146. <view class="tab-button" @click="toggleSound">
  147. <image src="/static/音色切换图标.png" class="tab-icon" />
  148. <text class="tab-text">音色切换</text>
  149. </view>
  150. </view>
  151. <view class="tabbar-right">
  152. <view class="page-controls">
  153. <view class="page-numbers">
  154. <view
  155. v-for="(page, index) in bookPages"
  156. :key="index"
  157. class="page-number"
  158. :class="{ 'active': (index + 1) === currentPage }"
  159. @click="goToPage(index + 1)"
  160. >
  161. {{ index + 1 }}
  162. </view>
  163. </view>
  164. </view>
  165. </view>
  166. </view>
  167. <uv-safe-bottom></uv-safe-bottom>
  168. </view>
  169. </view>
  170. <!-- 课程选择弹出窗 -->
  171. <uv-popup
  172. mode="bottom"
  173. ref="coursePopup"
  174. round="32rpx"
  175. bg-color="#f8f8f8"
  176. >
  177. <view class="course-popup">
  178. <view class="popup-header">
  179. <view>
  180. <uv-icon name="arrow-down" color="black" size="20"></uv-icon>
  181. </view>
  182. <view class="popup-title">课程</view>
  183. <view class="popup-title" @click="toggleSort">
  184. 倒序
  185. </view>
  186. </view>
  187. <view class="course-list">
  188. <view
  189. v-for="(course, index) in displayCourseList"
  190. :key="course.id"
  191. class="course-item"
  192. :class="{ 'active': course.id === currentCourse }"
  193. @click="selectCourse(course.id)"
  194. >
  195. <view class="course-number " :class="{ 'highlight': course.id === currentCourse }">{{ String(course.index).padStart(2, '0') }}</view>
  196. <view class="course-content">
  197. <view class="course-english" :class="{ 'highlight': course.id === currentCourse }">{{ course.english }}</view>
  198. <view class="course-chinese" :class="{ 'highlight': course.id === currentCourse }">{{ course.chinese }}</view>
  199. </view>
  200. </view>
  201. </view>
  202. </view>
  203. </uv-popup>
  204. <!-- 释义弹出窗 -->
  205. <uv-popup
  206. mode="bottom"
  207. ref="meaningPopup"
  208. round="32rpx"
  209. bg-color="#FFFFFF"
  210. :overlay="true"
  211. >
  212. <view class="meaning-popup" v-if="currentWordMeaning">
  213. <view class="meaning-header">
  214. <view class="close-btn" @click="closeMeaningPopup">
  215. <text class="close-text">关闭</text>
  216. </view>
  217. <view class="meaning-title">释义</view>
  218. <view style="width: 80rpx;"></view>
  219. </view>
  220. <view class="meaning-content">
  221. <image class="meaning-image" src="/static/默认图片.png" mode="aspectFill"></image>
  222. <view class="word-info">
  223. <view class="word-main">
  224. <text class="word-text">{{ currentWordMeaning.word }}</text>
  225. </view>
  226. <view class="phonetic-container">
  227. <uv-icon
  228. name="volume-fill"
  229. size="16"
  230. color="#007AFF"
  231. class="speaker-icon"
  232. @click="repeatWordAudio"
  233. ></uv-icon>
  234. <text class="phonetic-text">{{ currentWordMeaning.phonetic }}</text>
  235. </view>
  236. <view class="word-meaning">
  237. <text class="part-of-speech">{{ currentWordMeaning.partOfSpeech }}</text>
  238. <text class="meaning-text">{{ currentWordMeaning.meaning }}</text>
  239. </view>
  240. </view>
  241. <view class="knowledge-gain">
  242. <view class="knowledge-header">
  243. <image src="/static/知识收获图标.png" class="knowledge-icon" mode="aspectFill" />
  244. <text class="knowledge-title">知识收获</text>
  245. </view>
  246. <text class="knowledge-content">{{ currentWordMeaning.knowledgeGain }}</text>
  247. </view>
  248. </view>
  249. </view>
  250. </uv-popup>
  251. </view>
  252. </template>
  253. <script>
  254. export default {
  255. data() {
  256. return {
  257. isMember: false,
  258. memberId: '',
  259. voiceId: null,
  260. courseId: '',
  261. showNavbar: true,
  262. currentPage: 1,
  263. currentCourse: 1, // 当前课程索引
  264. currentWordMeaning: null, // 当前显示的单词释义
  265. isReversed: false, // 是否倒序显示
  266. // 音频控制相关数据
  267. isPlaying: false,
  268. currentTime: 0,
  269. totalTime: 0,
  270. sliderValue: 0, // 滑動條的值
  271. isDragging: false, // 是否正在拖動滑動條
  272. isLoop: false,
  273. playSpeed: 1.0,
  274. speedOptions: [0.5, 0.8, 1.0, 1.25, 1.5, 2.0], // 根據uni-app文檔的官方支持值
  275. playbackRateSupported: true, // 播放速度控制是否支持
  276. // 音频数组管理
  277. currentPageAudios: [], // 当前页面的音频数组
  278. currentAudioIndex: 0, // 当前播放的音频索引
  279. audioContext: null, // 音频上下文
  280. currentAudio: null, // 当前音频实例
  281. // 音频缓存管理
  282. audioCache: {}, // 页面音频缓存 {pageIndex: {audios: [], totalDuration: 0}}
  283. wordAudioCache: {}, // 單詞語音緩存
  284. currentWordAudio: null, // 當前播放的單詞音頻實例
  285. // 音频加载状态
  286. isAudioLoading: false, // 音频是否正在加载
  287. hasAudioData: false, // 当前页面是否已有音频数据
  288. // 文本高亮相关
  289. currentHighlightIndex: -1, // 当前高亮的文本索引
  290. courseIdList: [],
  291. bookTitle: '',
  292. courseList: [
  293. ],
  294. // 二维数组 代表每个页面
  295. bookPages: [
  296. ],
  297. // 存储每个页面的标题
  298. pageTitles: [],
  299. // 存储每个页面的type信息
  300. pageTypes: [],
  301. // 存储每个页面的单词释义数据
  302. pageWords: [],
  303. // 存储每个页面的付费状态
  304. pagePay: [],
  305. }
  306. },
  307. computed: {
  308. displayCourseList() {
  309. return this.isReversed ? [...this.courseList].reverse() : this.courseList;
  310. },
  311. // 判断当前页面是否为文字类型
  312. isTextPage() {
  313. // 如果是卡片页面(type为'1'),不显示音频控制栏
  314. if (this.currentPageType === '1') {
  315. return false;
  316. }
  317. const currentPageData = this.bookPages[this.currentPage - 1];
  318. // currentPageData是一个数组 其中的一个元素的type是text就会返回true
  319. return currentPageData && currentPageData.some(item => item.type === 'text');
  320. },
  321. // 计算音频播放进度百分比
  322. progressPercent() {
  323. return this.totalTime > 0 ? (this.currentTime / this.totalTime) * 100 : 0;
  324. },
  325. // 动态页面标题
  326. currentPageTitle() {
  327. return this.pageTitles[this.currentPage - 1] || this.bookTitle;
  328. },
  329. // 当前页面类型
  330. currentPageType() {
  331. return this.pageTypes[this.currentPage - 1] || '';
  332. },
  333. // 当前页面的单词释义数据
  334. currentPageWords() {
  335. return this.pageWords[this.currentPage - 1] || [];
  336. }
  337. },
  338. methods: {
  339. // 獲取用戶會員信息 判斷是否和傳參傳過來的會員id相同
  340. async getMemberInfo(){
  341. const memberRes = await this.$api.member.getUserMemberInfo()
  342. if (memberRes.code === 200) {
  343. this.isMember = memberRes.result.map(item => item.memberId).includes(this.memberId)
  344. console.log('isMember:', this.isMember);
  345. }
  346. },
  347. // 获取当前页面的音频内容
  348. async getCurrentPageAudio() {
  349. // 检查缓存中是否已有当前页面的音频数据
  350. const cacheKey = `${this.courseId}_${this.currentPage}`;
  351. if (this.audioCache[cacheKey]) {
  352. console.log('从缓存加载音频数据:', cacheKey);
  353. // 从缓存加载音频数据
  354. this.currentPageAudios = this.audioCache[cacheKey].audios;
  355. this.totalTime = this.audioCache[cacheKey].totalDuration;
  356. this.currentAudioIndex = 0;
  357. this.isPlaying = false;
  358. this.currentTime = 0;
  359. return;
  360. }
  361. // 开始加载状态
  362. this.isAudioLoading = true;
  363. // 清空当前页面音频数组
  364. this.currentPageAudios = [];
  365. this.currentAudioIndex = 0;
  366. this.isPlaying = false;
  367. this.currentTime = 0;
  368. this.totalTime = 0;
  369. // 对着当前页面的每一个[]元素进行切割 如果是文本text类型则进行音频请求
  370. const currentPageData = this.bookPages[this.currentPage - 1];
  371. if (currentPageData) {
  372. // 收集所有text类型的元素
  373. const textItems = currentPageData.filter(item => item.type === 'text');
  374. if (textItems.length > 0) {
  375. // 并行发送所有音频请求
  376. const audioPromises = textItems.map(async (item, index) => {
  377. try {
  378. // 进行音频请求 - 修正字段名:使用content而不是text
  379. const radioRes = await this.$api.music.textToVoice({
  380. text: item.content,
  381. voiceType: this.voiceId,
  382. });
  383. console.log(`音频请求响应 ${index + 1}:`, radioRes);
  384. if(radioRes.code === 200){
  385. // 新格式:API直接返回音頻URL
  386. const audioUrl = radioRes.result.audio;
  387. // 檢查API是否返回時長信息
  388. const duration = radioRes.result.duration || 0;
  389. // 同时保存到原始数据中以保持兼容性
  390. item.audioUrl = audioUrl;
  391. console.log(`音频URL设置成功 ${index + 1}:`, audioUrl, '时长:', duration);
  392. return {
  393. url: audioUrl,
  394. text: item.content,
  395. duration: duration, // 優先使用API返回的時長
  396. index: index // 保持原始顺序
  397. };
  398. } else {
  399. console.error(`音频请求失败 ${index + 1}:`, radioRes);
  400. return null;
  401. }
  402. } catch (error) {
  403. console.error(`音频请求异常 ${index + 1}:`, error);
  404. return null;
  405. }
  406. });
  407. // 等待所有音频请求完成
  408. const audioResults = await Promise.all(audioPromises);
  409. // 按原始顺序添加到音频数组中,过滤掉失败的请求
  410. this.currentPageAudios = audioResults
  411. .filter(result => result !== null)
  412. .sort((a, b) => a.index - b.index)
  413. .map(result => ({
  414. url: result.url,
  415. text: result.text,
  416. duration: result.duration
  417. }));
  418. console.log('所有音频请求完成,共获取', this.currentPageAudios.length, '个音频');
  419. }
  420. // 如果有音频,计算总时长
  421. if (this.currentPageAudios.length > 0) {
  422. await this.calculateTotalDuration();
  423. // 将音频数据保存到缓存中
  424. const cacheKey = `${this.courseId}_${this.currentPage}`;
  425. this.audioCache[cacheKey] = {
  426. audios: [...this.currentPageAudios], // 深拷贝音频数组
  427. totalDuration: this.totalTime
  428. };
  429. console.log('音频数据已缓存:', cacheKey, this.audioCache[cacheKey]);
  430. // 限制缓存大小
  431. this.limitCacheSize(10);
  432. }
  433. }
  434. // 结束加载状态
  435. this.isAudioLoading = false;
  436. // 设置音频数据状态
  437. this.hasAudioData = this.currentPageAudios.length > 0;
  438. },
  439. // 重置音频状态
  440. resetAudioState() {
  441. // 检查当前页面是否已有缓存的音频数据
  442. const pageKey = `${this.courseId}_${this.currentPage}`;
  443. const cachedAudio = this.audioCache[pageKey];
  444. if (cachedAudio && cachedAudio.audios && cachedAudio.audios.length > 0) {
  445. // 如果有缓存的音频数据,恢复音频状态
  446. this.currentPageAudios = cachedAudio.audios;
  447. this.totalTime = cachedAudio.totalDuration || 0;
  448. this.hasAudioData = true;
  449. } else {
  450. // 如果没有缓存的音频数据,重置为初始状态
  451. this.currentPageAudios = [];
  452. this.totalTime = 0;
  453. this.hasAudioData = false;
  454. }
  455. // 重置播放状态,翻頁時統一設置播放速度為1.0
  456. this.currentAudioIndex = 0;
  457. this.isPlaying = false;
  458. this.currentTime = 0;
  459. this.isAudioLoading = false;
  460. this.currentHighlightIndex = -1;
  461. // 翻頁時強制設置播放速度為1.0(適應微信小程序特性)
  462. this.playSpeed = 1.0;
  463. },
  464. // 手动获取音频
  465. async handleGetAudio() {
  466. // 检查是否有音色ID
  467. if (!this.voiceId) {
  468. uni.showToast({
  469. title: '音色未加载,请稍后重试',
  470. icon: 'none'
  471. });
  472. return;
  473. }
  474. // 检查当前页面是否有文本内容
  475. if (!this.isTextPage) {
  476. uni.showToast({
  477. title: '当前页面没有文本内容',
  478. icon: 'none'
  479. });
  480. return;
  481. }
  482. // 检查是否正在加载
  483. if (this.isAudioLoading) {
  484. return;
  485. }
  486. // 调用获取音频方法
  487. await this.getCurrentPageAudio();
  488. },
  489. // 获取音色列表 拿第一个做默认的音色id
  490. async getVoiceList() {
  491. const voiceRes = await this.$api.music.list()
  492. if(voiceRes.code === 200){
  493. this.voiceId = voiceRes.result[0].voiceType
  494. console.log('获取默认音色ID:', this.voiceId);
  495. }
  496. },
  497. toggleNavbar() {
  498. this.showNavbar = !this.showNavbar
  499. },
  500. goBack() {
  501. uni.navigateBack()
  502. },
  503. toggleCoursePopup() {
  504. if (this.$refs.coursePopup) {
  505. this.$refs.coursePopup.open()
  506. }
  507. // console.log('123123123');
  508. },
  509. toggleSort() {
  510. this.isReversed = !this.isReversed
  511. },
  512. selectCourse(courseId) {
  513. this.currentCourse = courseId
  514. if (this.$refs.coursePopup) {
  515. this.$refs.coursePopup.close()
  516. }
  517. // 这里可以添加切换课程的逻辑
  518. // console.log('选择课程:', courseId)
  519. this.getCourseList(courseId)
  520. },
  521. showWordMeaning() {
  522. if (this.$refs.meaningPopup) {
  523. this.$refs.meaningPopup.open()
  524. }
  525. },
  526. closeMeaningPopup() {
  527. if (this.$refs.meaningPopup) {
  528. this.$refs.meaningPopup.close()
  529. }
  530. this.currentWordMeaning = null
  531. },
  532. // 将英文句子分割成单词数组
  533. splitEnglishSentence(sentence) {
  534. // 使用正则表达式分割句子,保留标点符号
  535. const tokens = sentence.match(/\b\w+\b|[^\w\s]/g) || [];
  536. return tokens.map((token, index) => ({
  537. text: token,
  538. index: index,
  539. isWord: /\b\w+\b/.test(token), // 判断是否为单词
  540. hasDefinition: false // 是否有释义,稍后会设置
  541. }));
  542. },
  543. // 查找单词释义
  544. findWordDefinition(word) {
  545. const currentPageWords = this.pageWords[this.currentPage - 1] || [];
  546. // 不区分大小写匹配
  547. return currentPageWords.find(wordData =>
  548. wordData.word.toLowerCase() === word.toLowerCase()
  549. );
  550. },
  551. async playWordAudio(word) {
  552. try {
  553. console.log('開始播放單詞語音:', word);
  554. // 停止當前播放的單詞語音
  555. if (this.currentWordAudio) {
  556. this.currentWordAudio.destroy();
  557. this.currentWordAudio = null;
  558. }
  559. // 調用語音轉換API
  560. const audioRes = await this.$api.music.textToVoice({
  561. text: word,
  562. voiceType: this.voiceId
  563. });
  564. console.log('單詞語音API響應:', audioRes);
  565. // 檢查響應並播放音頻
  566. if (audioRes && audioRes.result && audioRes.result.audio) {
  567. // 新格式:直接使用返回的音頻URL
  568. const audioUrl = audioRes.result.audio;
  569. // 創建並播放音頻
  570. const audio = uni.createInnerAudioContext();
  571. audio.src = audioUrl;
  572. audio.onPlay(() => {
  573. console.log('開始播放單詞語音:', word);
  574. });
  575. audio.onEnded(() => {
  576. console.log('單詞語音播放完成:', word);
  577. audio.destroy();
  578. if (this.currentWordAudio === audio) {
  579. this.currentWordAudio = null;
  580. }
  581. });
  582. audio.onError((error) => {
  583. console.error('單詞語音播放失敗:', error);
  584. audio.destroy();
  585. if (this.currentWordAudio === audio) {
  586. this.currentWordAudio = null;
  587. }
  588. uni.showToast({
  589. title: '語音播放失敗',
  590. icon: 'none'
  591. });
  592. });
  593. // 保存當前音頻實例並播放
  594. this.currentWordAudio = audio;
  595. audio.play();
  596. } else {
  597. console.error('單詞語音請求失敗:', audioRes);
  598. uni.showToast({
  599. title: '語音播放失敗',
  600. icon: 'none'
  601. });
  602. }
  603. } catch (error) {
  604. console.error('播放單詞語音異常:', error);
  605. uni.showToast({
  606. title: '語音播放失敗',
  607. icon: 'none'
  608. });
  609. }
  610. },
  611. // 重複播放單詞語音(用於釋義彈窗中的揚聲器圖標)
  612. repeatWordAudio() {
  613. if (this.currentWordMeaning && this.currentWordMeaning.word) {
  614. console.log('重複播放單詞語音:', this.currentWordMeaning.word);
  615. this.playWordAudio(this.currentWordMeaning.word);
  616. } else {
  617. console.warn('沒有當前單詞可以播放');
  618. }
  619. },
  620. // 处理单词点击事件
  621. handleWordClick(word) {
  622. const definition = this.findWordDefinition(word);
  623. console.log('查找单词:', word, '释义:', definition);
  624. // 獲取單詞的讀音
  625. if (word) {
  626. this.playWordAudio(word);
  627. }
  628. if (definition) {
  629. this.currentWordMeaning = {
  630. word: definition.word,
  631. phonetic: definition.soundmark || '',
  632. partOfSpeech: '', // 可以根据需要添加词性
  633. meaning: definition.paraphrase || '',
  634. knowledgeGain: definition.knowledge || ''
  635. };
  636. this.showWordMeaning();
  637. } else {
  638. console.log('未找到单词释义:', word);
  639. }
  640. },
  641. // 计算音频总时长
  642. async calculateTotalDuration() {
  643. let totalDuration = 0;
  644. for (let i = 0; i < this.currentPageAudios.length; i++) {
  645. const audio = this.currentPageAudios[i];
  646. // 優先使用API返回的時長信息
  647. if (audio.duration && audio.duration > 0) {
  648. console.log(`使用API返回的時長 ${i + 1}:`, audio.duration, '秒');
  649. totalDuration += audio.duration;
  650. continue;
  651. }
  652. // 如果沒有API時長信息,嘗試獲取音頻時長
  653. try {
  654. const duration = await this.getAudioDuration(audio.url);
  655. audio.duration = duration;
  656. totalDuration += duration;
  657. console.log(`獲取到音頻時長 ${i + 1}:`, duration, '秒');
  658. } catch (error) {
  659. console.error('获取音频时长失败:', error);
  660. // 如果无法获取时长,根據文字長度估算(更精確的估算)
  661. const textLength = audio.text.length;
  662. // 假設每分鐘可以讀150-200個字符,這裡用180作為平均值
  663. const estimatedDuration = Math.max(2, textLength / 3); // 每3個字符約1秒
  664. audio.duration = estimatedDuration;
  665. totalDuration += estimatedDuration;
  666. console.log(`估算音頻時長 ${i + 1}:`, estimatedDuration, '秒 (文字長度:', textLength, ')');
  667. }
  668. }
  669. this.totalTime = totalDuration;
  670. console.log('音频总时长:', totalDuration, '秒');
  671. },
  672. // 获取音频时长
  673. getAudioDuration(audioUrl) {
  674. return new Promise((resolve, reject) => {
  675. const audio = uni.createInnerAudioContext();
  676. audio.src = audioUrl;
  677. let resolved = false;
  678. // 监听音频加载完成事件
  679. audio.onCanplay(() => {
  680. console.log('音频可以播放,duration:', audio.duration);
  681. if (!resolved && audio.duration && audio.duration > 0) {
  682. resolved = true;
  683. resolve(audio.duration);
  684. audio.destroy();
  685. }
  686. });
  687. // 监听音频元数据加载完成事件
  688. audio.onLoadedmetadata = () => {
  689. console.log('音频元数据加载完成,duration:', audio.duration);
  690. if (!resolved && audio.duration && audio.duration > 0) {
  691. resolved = true;
  692. resolve(audio.duration);
  693. audio.destroy();
  694. }
  695. };
  696. // 监听音频时长更新事件
  697. audio.onDurationChange = () => {
  698. console.log('音频时长更新,duration:', audio.duration);
  699. if (!resolved && audio.duration && audio.duration > 0) {
  700. resolved = true;
  701. resolve(audio.duration);
  702. audio.destroy();
  703. }
  704. };
  705. // 如果以上方法都無法獲取時長,嘗試播放一小段來獲取時長
  706. audio.onPlay(() => {
  707. console.log('音频开始播放,duration:', audio.duration);
  708. if (!resolved) {
  709. setTimeout(() => {
  710. if (!resolved && audio.duration && audio.duration > 0) {
  711. resolved = true;
  712. resolve(audio.duration);
  713. audio.destroy();
  714. }
  715. }, 100); // 播放100ms後檢查時長
  716. }
  717. });
  718. audio.onError((error) => {
  719. console.error('音频加载失败:', error);
  720. if (!resolved) {
  721. resolved = true;
  722. reject(error);
  723. audio.destroy();
  724. }
  725. });
  726. // 設置較長的超時時間,並在超時前嘗試播放
  727. setTimeout(() => {
  728. if (!resolved) {
  729. console.log('嘗試播放音頻以獲取時長');
  730. audio.play();
  731. }
  732. }, 1000);
  733. // 最終超時處理
  734. setTimeout(() => {
  735. if (!resolved) {
  736. console.warn('獲取音頻時長超時,使用默認值');
  737. resolved = true;
  738. reject(new Error('获取音频时长超时'));
  739. audio.destroy();
  740. }
  741. }, 5000);
  742. });
  743. },
  744. // 音频控制方法
  745. togglePlay() {
  746. if (this.currentPageAudios.length === 0) {
  747. uni.showToast({
  748. title: '当前页面没有音频内容',
  749. icon: 'none'
  750. });
  751. return;
  752. }
  753. if (this.isPlaying) {
  754. this.pauseAudio();
  755. } else {
  756. this.playAudio();
  757. }
  758. },
  759. // 播放音频
  760. playAudio() {
  761. if (this.currentPageAudios.length === 0) return;
  762. // 如果没有当前音频实例或者需要切换音频
  763. if (!this.currentAudio || this.currentAudio.src !== this.currentPageAudios[this.currentAudioIndex].url) {
  764. this.createAudioInstance();
  765. }
  766. this.currentAudio.play();
  767. this.isPlaying = true;
  768. // 更新高亮状态
  769. this.updateHighlightIndex();
  770. },
  771. // 暂停音频
  772. pauseAudio() {
  773. if (this.currentAudio) {
  774. this.currentAudio.pause();
  775. }
  776. this.isPlaying = false;
  777. // 暂停时清除高亮
  778. this.currentHighlightIndex = -1;
  779. },
  780. // 创建音频实例
  781. createAudioInstance() {
  782. // 销毁之前的音频实例
  783. if (this.currentAudio) {
  784. this.currentAudio.destroy();
  785. }
  786. // 優先使用微信原生API以支持playbackRate
  787. let audio;
  788. if (typeof wx !== 'undefined' && wx.createInnerAudioContext) {
  789. console.log('使用微信原生音頻API');
  790. audio = wx.createInnerAudioContext();
  791. } else {
  792. console.log('使用uni-app音頻API');
  793. audio = uni.createInnerAudioContext();
  794. }
  795. audio.src = this.currentPageAudios[this.currentAudioIndex].url;
  796. // 在音頻可以播放時檢測playbackRate支持
  797. audio.onCanplay(() => {
  798. console.log('🎵 音頻可以播放,開始檢測playbackRate支持');
  799. this.checkPlaybackRateSupport(audio);
  800. // 檢測完成後,設置用戶期望的播放速度
  801. setTimeout(() => {
  802. if (this.playbackRateSupported) {
  803. console.log('設置播放速度:', this.playSpeed);
  804. audio.playbackRate = this.playSpeed;
  805. // 驗證設置結果
  806. setTimeout(() => {
  807. console.log('最終播放速度:', audio.playbackRate);
  808. if (Math.abs(audio.playbackRate - this.playSpeed) > 0.01) {
  809. console.log('⚠️ 播放速度設置可能未生效');
  810. } else {
  811. console.log('✅ 播放速度設置成功');
  812. }
  813. }, 50);
  814. } else {
  815. console.log('❌ 當前環境不支持播放速度控制');
  816. }
  817. }, 50);
  818. });
  819. // 音频事件监听
  820. audio.onPlay(() => {
  821. console.log('音频开始播放');
  822. this.isPlaying = true;
  823. });
  824. audio.onPause(() => {
  825. console.log('音频暂停');
  826. this.isPlaying = false;
  827. });
  828. audio.onTimeUpdate(() => {
  829. this.updateCurrentTime();
  830. });
  831. audio.onEnded(() => {
  832. console.log('当前音频播放结束');
  833. this.onAudioEnded();
  834. });
  835. audio.onError((error) => {
  836. console.error('音频播放错误:', error);
  837. this.isPlaying = false;
  838. uni.showToast({
  839. title: '音频播放失败',
  840. icon: 'none'
  841. });
  842. });
  843. this.currentAudio = audio;
  844. },
  845. // 更新当前播放时间
  846. updateCurrentTime() {
  847. if (!this.currentAudio) return;
  848. let totalTime = 0;
  849. // 计算之前音频的总时长
  850. for (let i = 0; i < this.currentAudioIndex; i++) {
  851. totalTime += this.currentPageAudios[i].duration;
  852. }
  853. // 加上当前音频的播放时间
  854. totalTime += this.currentAudio.currentTime;
  855. this.currentTime = totalTime;
  856. // 如果不是正在拖動滑動條,則同步更新滑動條的值
  857. if (!this.isDragging) {
  858. this.sliderValue = this.currentTime;
  859. }
  860. // 更新当前高亮的文本索引
  861. this.updateHighlightIndex();
  862. },
  863. // 更新高亮文本索引
  864. updateHighlightIndex() {
  865. if (!this.isPlaying || this.currentPageAudios.length === 0) {
  866. this.currentHighlightIndex = -1;
  867. return;
  868. }
  869. // 根据当前播放的音频索引设置高亮
  870. this.currentHighlightIndex = this.currentAudioIndex;
  871. console.log('更新高亮索引:', this.currentHighlightIndex, '当前音频索引:', this.currentAudioIndex);
  872. },
  873. // 判断当前文本是否应该高亮
  874. isTextHighlighted(page, index) {
  875. // 只有当前页面且是文本类型才可能高亮
  876. if (page !== this.bookPages[this.currentPage - 1]) return false;
  877. // 计算当前页面中text类型元素的索引
  878. let textIndex = 0;
  879. for (let i = 0; i <= index; i++) {
  880. if (page[i].type === 'text') {
  881. if (i === index) {
  882. const shouldHighlight = textIndex === this.currentHighlightIndex;
  883. if (shouldHighlight) {
  884. console.log('高亮文本:', textIndex, '当前高亮索引:', this.currentHighlightIndex);
  885. }
  886. return shouldHighlight;
  887. }
  888. textIndex++;
  889. }
  890. }
  891. return false;
  892. },
  893. // 音频播放结束处理
  894. onAudioEnded() {
  895. if (this.currentAudioIndex < this.currentPageAudios.length - 1) {
  896. // 播放下一个音频
  897. this.currentAudioIndex++;
  898. this.playAudio();
  899. } else {
  900. // 所有音频播放完毕
  901. if (this.isLoop) {
  902. // 循环播放
  903. this.currentAudioIndex = 0;
  904. this.playAudio();
  905. } else {
  906. // 停止播放
  907. this.isPlaying = false;
  908. this.currentTime = this.totalTime;
  909. this.currentHighlightIndex = -1;
  910. }
  911. }
  912. },
  913. toggleLoop() {
  914. this.isLoop = !this.isLoop;
  915. },
  916. toggleSpeed() {
  917. // 檢查是否支持播放速度控制
  918. if (!this.playbackRateSupported) {
  919. uni.showToast({
  920. title: '當前設備不支持播放速度控制',
  921. icon: 'none',
  922. duration: 2000
  923. });
  924. return;
  925. }
  926. const currentIndex = this.speedOptions.indexOf(this.playSpeed);
  927. const nextIndex = (currentIndex + 1) % this.speedOptions.length;
  928. const oldSpeed = this.playSpeed;
  929. this.playSpeed = this.speedOptions[nextIndex];
  930. console.log(`播放速度切換: ${oldSpeed}x -> ${this.playSpeed}x`);
  931. // 如果当前有音频在播放,更新播放速度
  932. if (this.currentAudio) {
  933. const wasPlaying = this.isPlaying;
  934. const currentTime = this.currentAudio.currentTime;
  935. // 設置新的播放速度
  936. this.currentAudio.playbackRate = this.playSpeed;
  937. // 如果正在播放,需要重啟播放才能使播放速度生效
  938. if (wasPlaying) {
  939. this.currentAudio.pause();
  940. setTimeout(() => {
  941. this.currentAudio.seek(currentTime);
  942. this.currentAudio.play();
  943. }, 50);
  944. }
  945. console.log('音頻實例播放速度已更新為:', this.currentAudio.playbackRate);
  946. // 顯示速度變更提示
  947. uni.showToast({
  948. title: `播放速度: ${this.playSpeed}x`,
  949. icon: 'none',
  950. duration: 1000
  951. });
  952. }
  953. },
  954. // 滑動條值實時更新 (@input 事件)
  955. onSliderInput(value) {
  956. // 在拖動過程中實時更新顯示的時間,但不影響實際播放
  957. if (this.isDragging) {
  958. // 可以在這裡實時更新顯示時間,讓用戶看到拖動到的時間點
  959. // 但不改變實際的 currentTime,避免影響播放邏輯
  960. console.log('實時更新滑動條值:', value);
  961. }
  962. },
  963. // 滑動條拖動過程中的處理 (@changing 事件)
  964. onSliderChanging(value) {
  965. // 第一次觸發 changing 事件時,暫停播放並標記為拖動狀態
  966. if (!this.isDragging) {
  967. if (this.isPlaying) {
  968. this.pauseAudio();
  969. console.log('開始拖動滑動條,暫停播放');
  970. }
  971. this.isDragging = true;
  972. }
  973. // 更新滑動條的值,但不改變實際播放位置
  974. this.sliderValue = value;
  975. console.log('拖動中,滑動條值:', value);
  976. },
  977. // 滑動條拖動結束的處理 (@change 事件)
  978. onSliderChange(value) {
  979. console.log('滑動條變化,跳轉到位置:', value, '是否為拖動:', this.isDragging);
  980. // 如果不是拖動狀態(即單點),需要先暫停播放
  981. if (!this.isDragging && this.isPlaying) {
  982. this.pauseAudio();
  983. console.log('單點滑動條,暫停播放');
  984. }
  985. // 重置拖動狀態
  986. this.isDragging = false;
  987. this.sliderValue = value;
  988. // 跳轉到指定位置,但不自動恢復播放
  989. this.seekToTime(value, false);
  990. console.log('滑動條操作完成,保持暫停狀態,需要手動點擊播放');
  991. },
  992. // 跳轉到指定時間
  993. seekToTime(targetTime, shouldResume = false) {
  994. if (!this.currentPageAudios || this.currentPageAudios.length === 0) {
  995. console.log('沒有音頻數據,無法跳轉');
  996. return;
  997. }
  998. // 確保目標時間在有效範圍內
  999. targetTime = Math.max(0, Math.min(targetTime, this.totalTime));
  1000. console.log('跳轉到時間:', targetTime, '秒', '總時長:', this.totalTime, '是否恢復播放:', shouldResume);
  1001. let accumulatedTime = 0;
  1002. let targetAudioIndex = -1;
  1003. let targetAudioTime = 0;
  1004. // 找到目標時間對應的音頻片段
  1005. for (let i = 0; i < this.currentPageAudios.length; i++) {
  1006. const audioDuration = this.currentPageAudios[i].duration || 0;
  1007. console.log(`音頻片段 ${i}: 時長=${audioDuration}, 累計時間=${accumulatedTime}, 範圍=[${accumulatedTime}, ${accumulatedTime + audioDuration}]`);
  1008. if (targetTime >= accumulatedTime && targetTime <= accumulatedTime + audioDuration) {
  1009. targetAudioIndex = i;
  1010. targetAudioTime = targetTime - accumulatedTime;
  1011. break;
  1012. }
  1013. accumulatedTime += audioDuration;
  1014. }
  1015. // 如果沒有找到合適的音頻片段,使用最後一個
  1016. if (targetAudioIndex === -1 && this.currentPageAudios.length > 0) {
  1017. targetAudioIndex = this.currentPageAudios.length - 1;
  1018. targetAudioTime = this.currentPageAudios[targetAudioIndex].duration || 0;
  1019. console.log('使用最後一個音頻片段作為目標');
  1020. }
  1021. console.log('目標音頻索引:', targetAudioIndex, '目標音頻時間:', targetAudioTime);
  1022. if (targetAudioIndex === -1) {
  1023. console.error('無法找到目標音頻片段');
  1024. return;
  1025. }
  1026. // 如果需要切換到不同的音頻片段
  1027. if (targetAudioIndex !== this.currentAudioIndex) {
  1028. console.log(`切換音頻片段: ${this.currentAudioIndex} -> ${targetAudioIndex}`);
  1029. this.currentAudioIndex = targetAudioIndex;
  1030. this.createAudioInstance();
  1031. // 等待音頻實例創建完成後再跳轉
  1032. this.waitForAudioReady(() => {
  1033. if (this.currentAudio) {
  1034. this.currentAudio.seek(targetAudioTime);
  1035. this.currentTime = targetTime;
  1036. console.log('切換音頻並跳轉到:', targetAudioTime, '秒');
  1037. // 如果拖動前正在播放,則恢復播放
  1038. if (shouldResume) {
  1039. this.currentAudio.play();
  1040. this.isPlaying = true;
  1041. console.log('恢復播放狀態');
  1042. }
  1043. }
  1044. });
  1045. } else {
  1046. // 在當前音頻片段內跳轉
  1047. if (this.currentAudio) {
  1048. this.currentAudio.seek(targetAudioTime);
  1049. this.currentTime = targetTime;
  1050. console.log('在當前音頻內跳轉到:', targetAudioTime, '秒');
  1051. // 如果拖動前正在播放,則恢復播放
  1052. if (shouldResume) {
  1053. this.currentAudio.play();
  1054. this.isPlaying = true;
  1055. console.log('恢復播放狀態');
  1056. }
  1057. }
  1058. }
  1059. },
  1060. // 等待音頻實例準備就緒
  1061. waitForAudioReady(callback, maxAttempts = 10, currentAttempt = 0) {
  1062. if (currentAttempt >= maxAttempts) {
  1063. console.error('音頻實例準備超時');
  1064. return;
  1065. }
  1066. if (this.currentAudio && this.currentAudio.src) {
  1067. // 音頻實例已準備好
  1068. setTimeout(callback, 50); // 稍微延遲確保完全準備好
  1069. } else {
  1070. // 繼續等待
  1071. setTimeout(() => {
  1072. this.waitForAudioReady(callback, maxAttempts, currentAttempt + 1);
  1073. }, 100);
  1074. }
  1075. },
  1076. // 初始檢測播放速度支持(不依賴音頻實例)
  1077. checkInitialPlaybackRateSupport() {
  1078. try {
  1079. const systemInfo = uni.getSystemInfoSync();
  1080. console.log('初始檢測 - 系統信息:', systemInfo);
  1081. // 檢查基礎庫版本 - playbackRate需要2.11.0及以上
  1082. const SDKVersion = systemInfo.SDKVersion || '0.0.0';
  1083. const versionArray = SDKVersion.split('.').map(v => parseInt(v));
  1084. const isVersionSupported = versionArray[0] > 2 ||
  1085. (versionArray[0] === 2 && versionArray[1] > 11) ||
  1086. (versionArray[0] === 2 && versionArray[1] === 11 && versionArray[2] >= 0);
  1087. if (!isVersionSupported) {
  1088. this.playbackRateSupported = false;
  1089. console.log(`初始檢測 - 基礎庫版本過低 (${SDKVersion}),需要2.11.0及以上才支持播放速度控制`);
  1090. return;
  1091. }
  1092. // Android 6以下版本不支持
  1093. if (systemInfo.platform === 'android') {
  1094. const androidVersion = systemInfo.system.match(/Android (\d+)/);
  1095. if (androidVersion && parseInt(androidVersion[1]) < 6) {
  1096. this.playbackRateSupported = false;
  1097. console.log('初始檢測 - Android版本過低,需要Android 6及以上才支持播放速度控制');
  1098. return;
  1099. }
  1100. }
  1101. // 檢查微信原生API是否可用
  1102. if (typeof wx === 'undefined' || !wx.createInnerAudioContext) {
  1103. console.log('初始檢測 - 微信原生API不可用,可能影響playbackRate支持');
  1104. } else {
  1105. console.log('初始檢測 - 微信原生API可用');
  1106. }
  1107. // 如果通過基本檢測,暫時設為支持,等音頻實例創建後再詳細檢測
  1108. this.playbackRateSupported = true;
  1109. console.log('初始檢測 - 基本條件滿足,等待音頻實例檢測');
  1110. } catch (error) {
  1111. console.error('初始檢測播放速度支持時出錯:', error);
  1112. this.playbackRateSupported = false;
  1113. }
  1114. },
  1115. // 檢查播放速度控制支持
  1116. checkPlaybackRateSupport(audio) {
  1117. try {
  1118. // 檢查基礎庫版本和平台支持
  1119. const systemInfo = uni.getSystemInfoSync();
  1120. console.log('系統信息:', systemInfo);
  1121. console.log('平台:', systemInfo.platform);
  1122. console.log('基礎庫版本:', systemInfo.SDKVersion);
  1123. console.log('系統版本:', systemInfo.system);
  1124. // 根據uni-app文檔,微信小程序需要基礎庫2.11.0+才支持playbackRate
  1125. const SDKVersion = systemInfo.SDKVersion || '0.0.0';
  1126. const versionArray = SDKVersion.split('.').map(v => parseInt(v));
  1127. const isVersionSupported = versionArray[0] > 2 ||
  1128. (versionArray[0] === 2 && versionArray[1] > 11) ||
  1129. (versionArray[0] === 2 && versionArray[1] === 11 && versionArray[2] >= 0);
  1130. console.log('基礎庫版本檢查:', {
  1131. version: SDKVersion,
  1132. parsed: versionArray,
  1133. supported: isVersionSupported
  1134. });
  1135. if (!isVersionSupported) {
  1136. this.playbackRateSupported = false;
  1137. console.log(`❌ 基礎庫版本不支持 (${SDKVersion}),微信小程序需要2.11.0+才支持playbackRate`);
  1138. return;
  1139. }
  1140. // Android平台需要6.0+版本支持
  1141. if (systemInfo.platform === 'android') {
  1142. const androidVersion = systemInfo.system.match(/Android (\d+)/);
  1143. console.log('Android版本檢查:', androidVersion);
  1144. if (androidVersion && parseInt(androidVersion[1]) < 6) {
  1145. this.playbackRateSupported = false;
  1146. console.log(`❌ Android版本不支持 (${androidVersion[1]}),需要Android 6+才支持playbackRate`);
  1147. return;
  1148. }
  1149. }
  1150. // 檢查音頻實例是否支持playbackRate
  1151. console.log('🔍 音頻實例檢查:');
  1152. console.log('- playbackRate初始值:', audio.playbackRate);
  1153. console.log('- playbackRate類型:', typeof audio.playbackRate);
  1154. // 嘗試設置並檢測是否真正支持
  1155. const testRate = 1.25;
  1156. const originalRate = audio.playbackRate || 1.0;
  1157. try {
  1158. audio.playbackRate = testRate;
  1159. console.log('- 設置測試速度:', testRate);
  1160. console.log('- 設置後的值:', audio.playbackRate);
  1161. // 檢查設置是否生效
  1162. if (Math.abs(audio.playbackRate - testRate) < 0.01) {
  1163. this.playbackRateSupported = true;
  1164. console.log('✅ playbackRate功能正常');
  1165. } else {
  1166. this.playbackRateSupported = false;
  1167. console.log('❌ playbackRate設置無效,可能不被支持');
  1168. }
  1169. } catch (error) {
  1170. this.playbackRateSupported = false;
  1171. console.log('❌ playbackRate設置出錯:', error);
  1172. }
  1173. } catch (error) {
  1174. console.error('檢查播放速度支持時出錯:', error);
  1175. this.playbackRateSupported = false;
  1176. }
  1177. },
  1178. // 上一个音频
  1179. previousAudio() {
  1180. // 如果正在加载音频,禁止切换
  1181. if (this.isAudioLoading) {
  1182. return;
  1183. }
  1184. if (this.currentPageAudios.length === 0) return;
  1185. if (this.currentAudioIndex > 0) {
  1186. this.currentAudioIndex--;
  1187. if (this.isPlaying) {
  1188. this.playAudio();
  1189. }
  1190. }
  1191. },
  1192. // 下一个音频
  1193. nextAudio() {
  1194. // 如果正在加载音频,禁止切换
  1195. if (this.isAudioLoading) {
  1196. return;
  1197. }
  1198. if (this.currentPageAudios.length === 0) return;
  1199. if (this.currentAudioIndex < this.currentPageAudios.length - 1) {
  1200. this.currentAudioIndex++;
  1201. if (this.isPlaying) {
  1202. this.playAudio();
  1203. }
  1204. }
  1205. },
  1206. async previousPage() {
  1207. // 如果正在加载音频,禁止翻页
  1208. if (this.isAudioLoading) {
  1209. return;
  1210. }
  1211. if (this.currentPage > 1) {
  1212. // 停止当前音频播放並銷毀實例
  1213. this.pauseAudio();
  1214. if (this.currentAudio) {
  1215. this.currentAudio.destroy();
  1216. this.currentAudio = null;
  1217. }
  1218. this.currentPage--;
  1219. // 获取对应页面的数据(如果还没有获取过)
  1220. if (this.courseIdList[this.currentPage - 1] && this.bookPages[this.currentPage - 1].length === 0) {
  1221. await this.getBookPages(this.courseIdList[this.currentPage - 1]);
  1222. }
  1223. // 清空当前音频状态
  1224. this.resetAudioState();
  1225. }
  1226. },
  1227. async nextPage() {
  1228. // 如果正在加载音频,禁止翻页
  1229. if (this.isAudioLoading) {
  1230. return;
  1231. }
  1232. if (this.currentPage < this.bookPages.length) {
  1233. // 停止当前音频播放並銷毀實例
  1234. this.pauseAudio();
  1235. if (this.currentAudio) {
  1236. this.currentAudio.destroy();
  1237. this.currentAudio = null;
  1238. }
  1239. this.currentPage++;
  1240. // 获取对应页面的数据(如果还没有获取过)
  1241. if (this.courseIdList[this.currentPage - 1] && this.bookPages[this.currentPage - 1].length === 0) {
  1242. await this.getBookPages(this.courseIdList[this.currentPage - 1]);
  1243. }
  1244. // 清空当前音频状态
  1245. this.resetAudioState();
  1246. }
  1247. },
  1248. formatTime(seconds) {
  1249. const mins = Math.floor(seconds / 60);
  1250. const secs = Math.floor(seconds % 60);
  1251. return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
  1252. },
  1253. toggleSound() {
  1254. console.log('音色切换')
  1255. uni.navigateTo({
  1256. url: '/subPages/home/music?voiceId=' + this.voiceId
  1257. })
  1258. },
  1259. unlockBook() {
  1260. console.log('解锁全书')
  1261. // 这里可以跳转到会员页面或者调用解锁接口
  1262. uni.navigateTo({
  1263. url: '/subPages/member/recharge'
  1264. })
  1265. },
  1266. async goToPage(page) {
  1267. // 如果正在加载音频,禁止翻页
  1268. if (this.isAudioLoading) {
  1269. return;
  1270. }
  1271. // 停止当前音频播放並銷毀實例
  1272. this.pauseAudio();
  1273. if (this.currentAudio) {
  1274. this.currentAudio.destroy();
  1275. this.currentAudio = null;
  1276. }
  1277. this.currentPage = page
  1278. console.log('跳转到页面:', page)
  1279. // 获取对应页面的数据(如果还没有获取过)
  1280. if (this.courseIdList[this.currentPage - 1] && this.bookPages[this.currentPage - 1].length === 0) {
  1281. await this.getBookPages(this.courseIdList[this.currentPage - 1]);
  1282. }
  1283. // 清空当前音频状态
  1284. this.resetAudioState();
  1285. },
  1286. async onSwiperChange(e) {
  1287. // 如果正在加载音频,禁止翻页
  1288. if (this.isAudioLoading) {
  1289. return;
  1290. }
  1291. // 停止当前音频播放並銷毀實例
  1292. this.pauseAudio();
  1293. if (this.currentAudio) {
  1294. this.currentAudio.destroy();
  1295. this.currentAudio = null;
  1296. }
  1297. this.currentPage = e.detail.current + 1
  1298. // 获取对应页面的数据(如果还没有获取过)
  1299. if (this.courseIdList[this.currentPage - 1] && this.bookPages[this.currentPage - 1].length === 0) {
  1300. await this.getBookPages(this.courseIdList[this.currentPage - 1]);
  1301. }
  1302. // 清空当前音频状态
  1303. this.resetAudioState();
  1304. },
  1305. async getCourseList(id) {
  1306. const res = await this.$api.book.coursePage({
  1307. id: id
  1308. })
  1309. if (res.code === 200) {
  1310. this.courseIdList = res.result.map(item => item.id)
  1311. // 初始化二维数组 换一种方式
  1312. this.bookPages = this.courseIdList.map(() => [])
  1313. // 初始化标题数组
  1314. this.pageTitles = this.courseIdList.map(() => '')
  1315. // 初始化页面类型数组
  1316. this.pageTypes = this.courseIdList.map(() => '')
  1317. // 初始化页面单词数组
  1318. this.pageWords = this.courseIdList.map(() => [])
  1319. // 重置音频状态
  1320. this.resetAudioState()
  1321. // 初始化第一页
  1322. if (this.courseIdList.length > 0) {
  1323. await this.getBookPages(this.courseIdList[0])
  1324. }
  1325. }
  1326. },
  1327. async getBookPages(id) {
  1328. const res = await this.$api.book.coursesPageDetail({
  1329. id: id
  1330. })
  1331. if (res.code === 200) {
  1332. // 使用$set确保响应式更新
  1333. console.log('获取到的页面数据:', JSON.parse(res.result.content))
  1334. // 确保当前页面存在
  1335. if (this.currentPage - 1 < this.bookPages.length) {
  1336. this.$set(this.bookPages, this.currentPage - 1, JSON.parse(res.result.content))
  1337. // 保存页面标题
  1338. this.$set(this.pageTitles, this.currentPage - 1, res.result.title || '')
  1339. // 保存页面类型
  1340. this.$set(this.pageTypes, this.currentPage - 1, res.result.type || '')
  1341. // 保存页面单词释义数据
  1342. this.$set(this.pageWords, this.currentPage - 1, res.result.words || [])
  1343. // 保存页面付费状态
  1344. this.$set(this.pagePay, this.currentPage - 1, res.result.pay || 'N')
  1345. }
  1346. }
  1347. },
  1348. // 获取课程列表
  1349. async getCoursePageList (bookId) {
  1350. const res = await this.$api.book.course({
  1351. id: bookId
  1352. })
  1353. if (res.code === 200) {
  1354. this.courseList = res.result.records
  1355. // 打上序列号
  1356. this.courseList = this.courseList.map((item, index) => ({
  1357. ...item,
  1358. index,
  1359. }))
  1360. }
  1361. },
  1362. // 清理音频缓存
  1363. clearAudioCache() {
  1364. this.audioCache = {};
  1365. console.log('音频缓存已清理');
  1366. },
  1367. // 清理單詞語音緩存
  1368. clearWordAudioCache() {
  1369. this.wordAudioCache = {};
  1370. console.log('單詞語音緩存已清理');
  1371. },
  1372. // 限制缓存大小,保留最近访问的页面
  1373. limitCacheSize(maxSize = 10) {
  1374. const cacheKeys = Object.keys(this.audioCache);
  1375. if (cacheKeys.length > maxSize) {
  1376. // 删除最旧的缓存项
  1377. const keysToDelete = cacheKeys.slice(0, cacheKeys.length - maxSize);
  1378. keysToDelete.forEach(key => {
  1379. delete this.audioCache[key];
  1380. });
  1381. console.log('缓存大小已限制,删除了', keysToDelete.length, '个缓存项');
  1382. }
  1383. },
  1384. // 自動加載第一頁音頻並播放
  1385. async autoLoadAndPlayFirstPage() {
  1386. try {
  1387. console.log('開始自動加載第一頁音頻');
  1388. // 確保當前是第一頁且是文字頁面
  1389. if (this.currentPage === 1 && this.isTextPage) {
  1390. console.log('當前是第一頁文字頁面,開始加載音頻');
  1391. // 加載音頻
  1392. await this.getCurrentPageAudio();
  1393. // 檢查是否成功加載音頻
  1394. if (this.currentPageAudios && this.currentPageAudios.length > 0) {
  1395. console.log('音頻加載成功,開始自動播放');
  1396. // 稍微延遲一下確保音頻完全準備好
  1397. setTimeout(() => {
  1398. this.playAudio();
  1399. }, 500);
  1400. } else {
  1401. console.log('第一頁沒有音頻數據');
  1402. }
  1403. } else {
  1404. console.log('當前頁面不是第一頁文字頁面,跳過自動播放');
  1405. }
  1406. } catch (error) {
  1407. console.error('自動加載和播放音頻失敗:', error);
  1408. }
  1409. },
  1410. },
  1411. async onLoad(args) {
  1412. uni.$on('selectVoice', (voiceId) => {
  1413. console.log('音色切換:', this.voiceId, '->', voiceId);
  1414. this.voiceId = voiceId;
  1415. // 音色切換時清除所有音頻緩存,因為不同音色的音頻文件不同
  1416. this.clearAudioCache();
  1417. this.clearWordAudioCache();
  1418. // 停止當前播放的音頻
  1419. if (this.currentAudio) {
  1420. this.currentAudio.destroy();
  1421. this.currentAudio = null;
  1422. }
  1423. if (this.currentWordAudio) {
  1424. this.currentWordAudio.destroy();
  1425. this.currentWordAudio = null;
  1426. }
  1427. // 重置音頻狀態
  1428. this.currentPageAudios = [];
  1429. this.totalTime = 0;
  1430. this.hasAudioData = false;
  1431. this.isPlaying = false;
  1432. this.currentTime = 0;
  1433. this.currentAudioIndex = 0;
  1434. this.currentHighlightIndex = -1;
  1435. this.sliderValue = 0;
  1436. this.isDragging = false;
  1437. // 重新請求當前頁面的音頻數據
  1438. if (this.isTextPage) {
  1439. console.log('音色切換後重新獲取音頻數據');
  1440. this.getAudio();
  1441. }
  1442. })
  1443. this.courseId = args.courseId
  1444. this.memberId = args.memberId
  1445. // 重置音频状态
  1446. this.resetAudioState()
  1447. // 初始檢測播放速度支持
  1448. this.checkInitialPlaybackRateSupport()
  1449. // 先获取点进来的课程的页面列表
  1450. await Promise.all([this.getCourseList(this.courseId), this.getCoursePageList(args.bookId), this.getMemberInfo(), this.getVoiceList()])
  1451. // 頁面加載完成後,自動加載第一頁音頻並播放
  1452. await this.autoLoadAndPlayFirstPage();
  1453. },
  1454. // 页面卸载时清理音频资源
  1455. onUnload() {
  1456. uni.$off('selectVoice')
  1457. if (this.currentAudio) {
  1458. this.currentAudio.destroy();
  1459. this.currentAudio = null;
  1460. }
  1461. // 清理單詞語音資源
  1462. if (this.currentWordAudio) {
  1463. this.currentWordAudio.destroy();
  1464. this.currentWordAudio = null;
  1465. }
  1466. this.isPlaying = false;
  1467. // 清理音频缓存
  1468. this.clearAudioCache();
  1469. // 清理單詞語音緩存
  1470. this.clearWordAudioCache();
  1471. },
  1472. // 页面隐藏时暂停音频
  1473. onHide() {
  1474. this.pauseAudio();
  1475. }
  1476. }
  1477. </script>
  1478. <style lang="scss" scoped>
  1479. .book-container {
  1480. width: 100%;
  1481. height: 100vh;
  1482. background-color: #F8F8F8;
  1483. position: relative;
  1484. overflow: hidden;
  1485. }
  1486. .custom-navbar {
  1487. position: fixed;
  1488. top: 0;
  1489. left: 0;
  1490. right: 0;
  1491. background-color: #F8F8F8;
  1492. z-index: 1000;
  1493. transition: transform 0.3s ease;
  1494. &.navbar-hidden {
  1495. transform: translateY(-100%);
  1496. }
  1497. }
  1498. .navbar-content {
  1499. display: flex;
  1500. align-items: center;
  1501. justify-content: space-between;
  1502. padding: 20rpx 32rpx;
  1503. // padding-top: calc(20rpx + var(--status-bar-height, 0));
  1504. height: 60rpx;
  1505. }
  1506. .navbar-left,
  1507. .navbar-right {
  1508. width: 80rpx;
  1509. display: flex;
  1510. align-items: center;
  1511. }
  1512. .navbar-right {
  1513. justify-content: flex-end;
  1514. flex: 1;
  1515. }
  1516. .navbar-title {
  1517. transform: translateX(-50rpx);
  1518. flex: 1;
  1519. text-align: center;
  1520. font-family: PingFang SC;
  1521. font-weight: 500;
  1522. font-size: 32rpx;
  1523. color: #262626;
  1524. line-height: 48rpx;
  1525. }
  1526. .content-swiper {
  1527. flex: 1;
  1528. height: calc(100vh - 100rpx);
  1529. // margin-top: 100rpx;
  1530. margin-bottom: 100rpx;
  1531. }
  1532. .swiper-item {
  1533. height: 100%;
  1534. }
  1535. .content-area {
  1536. flex: 1;
  1537. padding: 0 40rpx;
  1538. padding-top: 100rpx;
  1539. // background: linear-gradient(180deg, #DEFFFF 0%, #FBFEFF 22.65%, #F0FBFF 100%);
  1540. height: 100%;
  1541. box-sizing: border-box;
  1542. overflow-y: auto;
  1543. }
  1544. .card-content {
  1545. background: linear-gradient(180deg, #DEFFFF 0%, #FBFEFF 22.65%, #F0FBFF 100%);
  1546. display: flex;
  1547. flex-direction: column;
  1548. gap: 32rpx;
  1549. height: 1172rpx;
  1550. margin-top: 20rpx;
  1551. border-radius: 32rpx;
  1552. // height: 100%;
  1553. padding: 40rpx;
  1554. // margin: 0
  1555. border: 1px solid #FFFFFF;
  1556. box-sizing: border-box;
  1557. .card-line {
  1558. display: flex;
  1559. align-items: center;
  1560. // margin-bottom: 20rpx;
  1561. }
  1562. .card-line-image {
  1563. width: 48rpx;
  1564. height: 48rpx;
  1565. margin-right: 16rpx;
  1566. }
  1567. .card-line-text {
  1568. font-family: PingFang SC;
  1569. font-weight: 600;
  1570. font-size: 30rpx;
  1571. line-height: 48rpx;
  1572. color: #3B3D3D;
  1573. }
  1574. .card-image {
  1575. width: 590rpx;
  1576. height: 268rpx;
  1577. border-radius: 24rpx;
  1578. // margin-bottom: 20rpx;
  1579. }
  1580. .english-text {
  1581. display: block;
  1582. font-family: PingFang SC;
  1583. font-weight: 600;
  1584. font-size: 32rpx;
  1585. line-height: 48rpx;
  1586. color: #3B3D3D;
  1587. // margin-bottom: 16rpx;
  1588. }
  1589. .english-text-container {
  1590. display: flex;
  1591. flex-wrap: wrap;
  1592. align-items: baseline;
  1593. }
  1594. .english-token {
  1595. font-family: PingFang SC;
  1596. font-weight: 600;
  1597. font-size: 32rpx;
  1598. line-height: 48rpx;
  1599. color: #3B3D3D;
  1600. margin-right: 10rpx;
  1601. }
  1602. .clickable-word {
  1603. background: $primary-color;
  1604. text-decoration: underline;
  1605. cursor: pointer;
  1606. transition: all 0.2s ease;
  1607. }
  1608. .clickable-word:hover {
  1609. background-color: rgba(0, 122, 255, 0.1);
  1610. border-radius: 4rpx;
  1611. }
  1612. .chinese-text {
  1613. display: block;
  1614. font-family: PingFang SC;
  1615. font-weight: 400;
  1616. font-size: 28rpx;
  1617. line-height: 48rpx;
  1618. color: #4F4F4F;
  1619. }
  1620. }
  1621. /* 会员限制页面样式 */
  1622. .member-content {
  1623. display: flex;
  1624. flex-direction: column;
  1625. align-items: center;
  1626. justify-content: center;
  1627. height: 90%;
  1628. background-color: #F8F8F8;
  1629. padding: 40rpx;
  1630. margin: -40rpx;
  1631. box-sizing: border-box;
  1632. }
  1633. .member-title {
  1634. font-family: PingFang SC;
  1635. font-weight: 500;
  1636. font-size: 40rpx;
  1637. line-height: 1;
  1638. color: #6f6f6f;
  1639. text-align: center;
  1640. margin-bottom: 48rpx;
  1641. }
  1642. .member-button {
  1643. width: 670rpx;
  1644. height: 72rpx;
  1645. background: #06DADC;
  1646. border-radius: 200rpx;
  1647. display: flex;
  1648. align-items: center;
  1649. justify-content: center;
  1650. }
  1651. .member-button-text {
  1652. font-family: PingFang SC;
  1653. font-weight: 400;
  1654. font-size: 30rpx;
  1655. color: #FFFFFF;
  1656. }
  1657. .video-content {
  1658. width: 100vw;
  1659. margin: 200rpx -40rpx 0;
  1660. height: 500rpx;
  1661. background-color: #FFFFFF;
  1662. // padding: 40rpx;
  1663. border-radius: 24rpx;
  1664. display: flex;
  1665. align-items: center;
  1666. justify-content: center;
  1667. .video-player{
  1668. width: 670rpx;
  1669. margin: 0 auto;
  1670. height: 376rpx;
  1671. }
  1672. }
  1673. .text-content {
  1674. width: 100vw;
  1675. background-color: #F6F6F6;
  1676. height: 100%;
  1677. padding: 40rpx;
  1678. margin: -40rpx;
  1679. box-sizing: border-box;
  1680. }
  1681. .content-text {
  1682. font-family: PingFang SC;
  1683. // font-weight: 400;
  1684. font-size: 28rpx;
  1685. color: #3B3D3D;
  1686. line-height: 48rpx;
  1687. letter-spacing: 0;
  1688. text-align: justify;
  1689. word-break: break-all;
  1690. transition: all 0.3s ease;
  1691. }
  1692. .text-highlight {
  1693. background-color: $primary-color;
  1694. // color: #fff;
  1695. // padding: 8rpx 16rpx;
  1696. // border-radius: 8rpx;
  1697. // box-shadow: 0 2rpx 8rpx rgba(6, 218, 220, 0.3);
  1698. }
  1699. .custom-tabbar {
  1700. position: fixed;
  1701. bottom: 0;
  1702. left: 0;
  1703. right: 0;
  1704. // background-color: #fff;
  1705. border-top: 1rpx solid #EEEEEE;
  1706. z-index: 1000;
  1707. transition: transform 0.3s ease;
  1708. &.tabbar-hidden {
  1709. transform: translateY(100%);
  1710. }
  1711. }
  1712. .tabbar-content {
  1713. display: flex;
  1714. align-items: center;
  1715. justify-content: space-between;
  1716. padding: 24rpx 62rpx;
  1717. // z-index: 100;
  1718. // position: relative;
  1719. // background-color: #fff;
  1720. // padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
  1721. height: 88rpx;
  1722. }
  1723. .tabbar-left {
  1724. display: flex;
  1725. align-items: center;
  1726. gap: 35rpx;
  1727. }
  1728. .tab-button {
  1729. display: flex;
  1730. align-items: center;
  1731. flex-direction: column;
  1732. gap: 8rpx;
  1733. }
  1734. .tab-icon {
  1735. width: 52rpx;
  1736. height: 52rpx;
  1737. }
  1738. .tab-text {
  1739. font-family: PingFang SC;
  1740. // font-weight: 400;
  1741. font-size: 22rpx;
  1742. color: #999;
  1743. line-height: 24rpx;
  1744. }
  1745. .tabbar-right {
  1746. flex: 1;
  1747. display: flex;
  1748. justify-content: flex-end;
  1749. }
  1750. .page-controls {
  1751. display: flex;
  1752. align-items: center;
  1753. }
  1754. .page-numbers {
  1755. display: flex;
  1756. align-items: center;
  1757. gap: 8rpx;
  1758. overflow-x: auto;
  1759. max-width: 400rpx;
  1760. &::-webkit-scrollbar {
  1761. display: none;
  1762. }
  1763. }
  1764. .page-number {
  1765. min-width: 84rpx;
  1766. height: 58rpx;
  1767. display: flex;
  1768. align-items: center;
  1769. justify-content: center;
  1770. border-radius: 100rpx;
  1771. font-family: PingFang SC;
  1772. // font-weight: 400;
  1773. font-size: 30rpx;
  1774. color: #3B3D3D;
  1775. background-color: transparent;
  1776. border: 1px solid #3B3D3D;
  1777. transition: all 0.3s ease;
  1778. &.active {
  1779. border: 1px solid $primary-color;
  1780. color: $primary-color;
  1781. }
  1782. }
  1783. /* 课程弹出窗样式 */
  1784. .course-popup {
  1785. padding: 0 32rpx;
  1786. max-height: 80vh;
  1787. }
  1788. .popup-header {
  1789. display: flex;
  1790. justify-content: space-between;
  1791. align-items: center;
  1792. padding: 20rpx 0;
  1793. border-bottom: 2rpx solid #EEEEEE
  1794. // margin-bottom: 40rpx;
  1795. }
  1796. .popup-title {
  1797. font-family: PingFang SC;
  1798. font-weight: 500;
  1799. font-size: 34rpx;
  1800. color: #181818;
  1801. }
  1802. .course-list {
  1803. max-height: 60vh;
  1804. overflow-y: auto;
  1805. }
  1806. .course-item {
  1807. display: flex;
  1808. align-items: center;
  1809. gap: 24rpx;
  1810. padding-top: 24rpx;
  1811. padding-right: 8rpx;
  1812. padding-bottom: 24rpx;
  1813. padding-left: 8rpx;
  1814. border-bottom: 1px solid #EEEEEE;
  1815. cursor: pointer;
  1816. &:last-child {
  1817. border-bottom: none;
  1818. }
  1819. }
  1820. .course-number {
  1821. width: 80rpx;
  1822. font-family: PingFang SC;
  1823. // font-weight: 400;
  1824. font-size: 36rpx;
  1825. color: #999;
  1826. &.highlight {
  1827. color: $primary-color;
  1828. }
  1829. // margin-right: 24rpx;
  1830. }
  1831. .course-content {
  1832. flex: 1;
  1833. }
  1834. .course-english {
  1835. font-family: PingFang SC;
  1836. font-weight: 600;
  1837. font-size: 36rpx;
  1838. line-height: 44rpx;
  1839. color: #252545;
  1840. margin-bottom: 8rpx;
  1841. &.highlight {
  1842. color: $primary-color;
  1843. }
  1844. }
  1845. .course-chinese {
  1846. font-size: 28rpx;
  1847. line-height: 48rpx;
  1848. color: #3B3D3D;
  1849. &.highlight {
  1850. color: $primary-color;
  1851. }
  1852. }
  1853. /* 释义弹窗样式 */
  1854. .meaning-popup {
  1855. // width: 670rpx;
  1856. background-color: #FFFFFF;
  1857. // border-radius: 32rpx;
  1858. overflow: hidden;
  1859. }
  1860. .meaning-header {
  1861. display: flex;
  1862. justify-content: space-between;
  1863. align-items: center;
  1864. padding: 32rpx;
  1865. border-bottom: 2rpx solid #EEEEEE;
  1866. }
  1867. .close-btn {
  1868. width: 80rpx;
  1869. }
  1870. .close-text {
  1871. font-family: PingFang SC;
  1872. font-size: 32rpx;
  1873. color: #8b8b8b;
  1874. }
  1875. .meaning-title {
  1876. font-family: PingFang SC;
  1877. font-weight: 500;
  1878. font-size: 34rpx;
  1879. color: #181818;
  1880. }
  1881. .meaning-content {
  1882. padding: 32rpx;
  1883. }
  1884. .meaning-image {
  1885. width: 670rpx;
  1886. height: 268rpx;
  1887. border-radius: 24rpx;
  1888. margin-bottom: 32rpx;
  1889. }
  1890. .word-info {
  1891. margin-bottom: 32rpx;
  1892. }
  1893. .word-main {
  1894. display: flex;
  1895. align-items: center;
  1896. gap: 16rpx;
  1897. margin-bottom: 8rpx;
  1898. }
  1899. .word-text {
  1900. font-family: PingFang SC;
  1901. font-weight: 500;
  1902. font-size: 40rpx;
  1903. color: #181818;
  1904. }
  1905. .phonetic-container{
  1906. display: flex;
  1907. gap: 24rpx;
  1908. align-items: center;
  1909. .speaker-icon {
  1910. cursor: pointer;
  1911. transition: opacity 0.2s ease;
  1912. padding: 8rpx;
  1913. border-radius: 8rpx;
  1914. &:hover {
  1915. opacity: 0.7;
  1916. background-color: rgba(0, 122, 255, 0.1);
  1917. }
  1918. }
  1919. .phonetic-text {
  1920. font-family: PingFang SC;
  1921. font-size: 28rpx;
  1922. color: #262626;
  1923. margin-bottom: 16rpx;
  1924. }
  1925. }
  1926. .word-meaning {
  1927. display: flex;
  1928. gap: 16rpx;
  1929. }
  1930. .part-of-speech {
  1931. font-family: PingFang SC;
  1932. font-size: 28rpx;
  1933. color: #262626;
  1934. }
  1935. .meaning-text {
  1936. font-family: PingFang SC;
  1937. font-size: 28rpx;
  1938. color: #181818;
  1939. flex: 1;
  1940. }
  1941. .knowledge-gain {
  1942. background: linear-gradient(180deg, #DEFFFF 0%, #FBFEFF 22.65%, #F0FBFF 100%);
  1943. border-radius: 24rpx;
  1944. padding: 32rpx 40rpx;
  1945. }
  1946. .knowledge-header {
  1947. display: flex;
  1948. align-items: center;
  1949. gap: 16rpx;
  1950. margin-bottom: 20rpx;
  1951. }
  1952. .knowledge-icon {
  1953. width: 48rpx;
  1954. height: 48rpx;
  1955. }
  1956. .knowledge-title {
  1957. font-family: PingFang SC;
  1958. font-weight: 600;
  1959. font-size: 30rpx;
  1960. color: #3B3D3D;
  1961. }
  1962. .knowledge-content {
  1963. font-family: PingFang SC;
  1964. font-size: 28rpx;
  1965. color: #4f4f4f;
  1966. line-height: 48rpx;
  1967. }
  1968. /* 音频控制栏样式 */
  1969. .audio-controls {
  1970. background: #fff;
  1971. padding: 20rpx 40rpx;
  1972. border-bottom: 1rpx solid #eee;
  1973. transition: transform 0.3s ease;
  1974. position: relative;
  1975. z-index: 10;
  1976. &.audio-hidden {
  1977. transform: translateY(100%);
  1978. }
  1979. }
  1980. .audio-time {
  1981. display: flex;
  1982. align-items: center;
  1983. margin-bottom: 20rpx;
  1984. }
  1985. .time-text {
  1986. font-size: 28rpx;
  1987. color: #999;
  1988. min-width: 80rpx;
  1989. }
  1990. .progress-container {
  1991. flex: 1;
  1992. margin: 0 20rpx;
  1993. }
  1994. .progress-bar {
  1995. position: relative;
  1996. height: 6rpx;
  1997. background: #f0f0f0;
  1998. border-radius: 3rpx;
  1999. }
  2000. .progress-fill {
  2001. position: absolute;
  2002. left: 0;
  2003. top: 0;
  2004. height: 100%;
  2005. background: #06DADC;
  2006. border-radius: 3rpx;
  2007. transition: width 0.1s ease;
  2008. }
  2009. .progress-thumb {
  2010. position: absolute;
  2011. top: 50%;
  2012. width: 16rpx;
  2013. height: 16rpx;
  2014. background: #06DADC;
  2015. border-radius: 50%;
  2016. transform: translate(-50%, -50%);
  2017. transition: left 0.1s ease;
  2018. }
  2019. .audio-controls-row {
  2020. display: flex;
  2021. align-items: center;
  2022. justify-content: space-between;
  2023. }
  2024. .control-btn {
  2025. display: flex;
  2026. // flex-direction: column;
  2027. align-items: center;
  2028. padding: 10rpx;
  2029. gap: 8rpx;
  2030. }
  2031. .control-btn.disabled {
  2032. pointer-events: none;
  2033. opacity: 0.6;
  2034. }
  2035. .control-text {
  2036. font-size: 28rpx;
  2037. color: #4A4A4A;
  2038. // margin-top: 8rpx;
  2039. }
  2040. .play-btn {
  2041. display: flex;
  2042. align-items: center;
  2043. justify-content: center;
  2044. padding: 10rpx;
  2045. }
  2046. /* 音频加载状态样式 */
  2047. .audio-loading-container {
  2048. background: #fff;
  2049. padding: 40rpx;
  2050. border-bottom: 1rpx solid #eee;
  2051. display: flex;
  2052. flex-direction: column;
  2053. align-items: center;
  2054. justify-content: center;
  2055. gap: 20rpx;
  2056. position: relative;
  2057. z-index: 10;
  2058. }
  2059. .loading-text {
  2060. font-size: 28rpx;
  2061. color: #999;
  2062. }
  2063. /* 获取音频按钮样式 */
  2064. .audio-get-button-container {
  2065. background: rgba(255, 255, 255, 0.95);
  2066. backdrop-filter: blur(10px);
  2067. padding: 30rpx;
  2068. border-radius: 20rpx;
  2069. border: 2rpx solid #E5E5E5;
  2070. transition: all 0.3s ease;
  2071. position: relative;
  2072. z-index: 10;
  2073. }
  2074. .get-audio-btn {
  2075. display: flex;
  2076. align-items: center;
  2077. justify-content: center;
  2078. gap: 16rpx;
  2079. padding: 20rpx 40rpx;
  2080. background: linear-gradient(135deg, #06DADC 0%, #04B8BA 100%);
  2081. border-radius: 50rpx;
  2082. box-shadow: 0 8rpx 20rpx rgba(6, 218, 220, 0.3);
  2083. transition: all 0.3s ease;
  2084. }
  2085. .get-audio-btn:active {
  2086. transform: scale(0.95);
  2087. box-shadow: 0 4rpx 10rpx rgba(6, 218, 220, 0.2);
  2088. }
  2089. .get-audio-text {
  2090. font-size: 32rpx;
  2091. color: #FFFFFF;
  2092. font-weight: 500;
  2093. }
  2094. </style>