小说小程序前端代码仓库(小程序)
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.

1174 lines
29 KiB

2 weeks ago
2 weeks ago
2 weeks ago
2 weeks ago
2 weeks ago
2 weeks ago
2 weeks ago
2 weeks ago
2 weeks ago
2 weeks ago
2 weeks ago
2 weeks ago
2 weeks ago
2 weeks ago
2 weeks ago
2 weeks ago
2 weeks ago
2 weeks ago
  1. <template>
  2. <!-- 小说文本页面 -->
  3. <view class="reader-container" :class="{'dark-mode': isDarkMode}">
  4. <view class="top-controls" :class="{'top-controls-hidden': isFullScreen}">
  5. <view class="controls-inner">
  6. <view class="left" @click="$utils.navigateBack">
  7. <uv-icon name="arrow-left" :color="isDarkMode ? '#ccc' : '#333'" size="46rpx"></uv-icon>
  8. </view>
  9. <view class="center">
  10. <text class="title">{{ novelData.name }}</text>
  11. <text class="chapter">{{ currentChapter }}</text>
  12. </view>
  13. <!-- <view class="right">
  14. <uv-icon name="more-dot-fill" color="#333" size="46rpx"></uv-icon>
  15. </view> -->
  16. </view>
  17. <!-- <view class="progress-bar">
  18. <view class="progress-inner" :style="{width: readProgress + '%'}"></view>
  19. </view> -->
  20. </view>
  21. <view class="chapter-content" :class="{'full-content': isFullScreen}"
  22. @tap="handleContentClick">
  23. <view class="chapter-content-item">
  24. <view class="chapter-title">{{ currentChapter }}</view>
  25. <view class="paragraph-content">
  26. <view class="paragraph" v-for="(paragraph, index) in paragraphs" :key="index">
  27. {{ paragraph }}
  28. </view>
  29. </view>
  30. <!-- 加载更多提示区域 -->
  31. <view class="load-more-area" v-if="autoLoadNext && !isPay">
  32. <view class="load-more-content" v-if="!isAutoLoading && hasNextChapter && !isAtBottom">
  33. <view class="load-more-line"></view>
  34. <text class="load-more-text">滚动到底部停留2秒自动加载下一章</text>
  35. <view class="load-more-line"></view>
  36. </view>
  37. <view class="waiting-content" v-else-if="!isAutoLoading && hasNextChapter && isAtBottom">
  38. <uv-icon name="time" color="#ff6b6b" size="30rpx"></uv-icon>
  39. <text class="waiting-text">正在底部停留 {{ bottomStayTime.toFixed(1) }}s / 2.0s</text>
  40. <text class="tip-text">向上滚动可取消</text>
  41. </view>
  42. <view class="loading-content" v-else-if="isAutoLoading">
  43. <uv-icon name="clock" color="#4a90e2" size="30rpx"></uv-icon>
  44. <text class="loading-text">{{ countdown }}秒后自动跳转下一章</text>
  45. <text class="cancel-text" @tap="cancelAutoLoad">点击取消</text>
  46. </view>
  47. </view>
  48. </view>
  49. </view>
  50. <view class="bottom-bar" :class="{'bottom-bar-hidden': isFullScreen}">
  51. <view class="bottom-left">
  52. <view class="bar-item" v-if="!isBooshelf" @click="addToBookshelf">
  53. <view class="bar-icon"> <uv-icon name="plus"></uv-icon> </view>
  54. <text class="bar-label">加入书架</text>
  55. </view>
  56. <view class="bar-item" @click="toggleThemeMode">
  57. <view class="bar-icon">
  58. <uv-icon :name="isDarkMode ? 'eye' : 'eye-fill'"></uv-icon>
  59. </view>
  60. <text class="bar-label">{{ isDarkMode ? '白天' : '夜间' }}</text>
  61. </view>
  62. <view class="bar-item" @click="toggleAutoLoad">
  63. <view class="bar-icon">
  64. <uv-icon :name="autoLoadNext ? 'play-circle-fill' : 'play-circle'"></uv-icon>
  65. </view>
  66. <text class="bar-label">{{ autoLoadNext ? '自动' : '手动' }}</text>
  67. </view>
  68. <!-- <view class="bar-item" @click="resetReadingPosition">
  69. <view class="bar-icon">
  70. <uv-icon name="rewind-left"></uv-icon>
  71. </view>
  72. <text class="bar-label">重读</text>
  73. </view> -->
  74. </view>
  75. <view class="bottom-right">
  76. <button class="outline-btn"
  77. @click="nextChapter(-1)">
  78. <text class="btn-text">上一章</text>
  79. </button>
  80. <button class="outline-btn" @click="$refs.chapterPopup.open()">
  81. <text class="btn-text">目录</text>
  82. </button>
  83. <button class="outline-btn"
  84. @click="nextChapter(1)">
  85. <text class="btn-text">下一章</text>
  86. </button>
  87. </view>
  88. </view>
  89. <!-- 使用封装的订阅弹窗组件 -->
  90. <subscriptionPopup ref="subscriptionPopup"
  91. :chapterList="chapterList"
  92. :currentChapter="currentChapterInfo"
  93. :currentIndex="currentIndex"
  94. :bookId="id"
  95. @maskClick="toggleFullScreen"
  96. @subscribe="goToSubscription"
  97. @batchSubscribe="handleBatchSubscribe"
  98. @videoUnlock="handleVideoUnlock" />
  99. <novelVotePopup ref="novelVotePopup" />
  100. <chapterPopup ref="chapterPopup" :chapterList="chapterList" :currentIndex="currentIndex"
  101. @selectChapter="selectChapter" />
  102. </view>
  103. </template>
  104. <script>
  105. import chapterPopup from '../components/novel/chapterPopup.vue'
  106. import novelVotePopup from '../components/novel/novelVotePopup.vue'
  107. import subscriptionPopup from '../components/novel/subscriptionPopup.vue'
  108. import themeMixin from '@/mixins/themeMode.js' // 导入主题混合器
  109. export default {
  110. components: {
  111. chapterPopup,
  112. novelVotePopup,
  113. subscriptionPopup,
  114. },
  115. mixins: [themeMixin], // 使用主题混合器
  116. data() {
  117. return {
  118. isFullScreen: false,
  119. popupShown: false, // 只弹一次
  120. currentChapter: "",
  121. readProgress: 15, // 阅读进度百分比
  122. paragraphs: [],
  123. id: 0,
  124. cid: 0,
  125. novelData: {},
  126. chapterList: [],
  127. // 是否需要购买
  128. isPay : false,
  129. isBooshelf : false,
  130. // 自动加载下一章相关
  131. autoLoadNext: true, // 是否启用自动加载下一章
  132. lastScrollTop: 0, // 记录上次滚动位置
  133. isAutoLoading: false, // 是否正在自动加载
  134. autoLoadTimer: null, // 自动加载计时器
  135. countdown: 3, // 倒计时秒数
  136. scrollThrottle: null, // 滚动节流计时器
  137. triggerChecked: false, // 是否已经检查过触发条件
  138. bottomStayTime: 0, // 在底部停留时间
  139. bottomCheckTimer: null, // 底部停留检测计时器
  140. isAtBottom: false, // 是否在底部区域
  141. // 阅读位置记录相关
  142. savePositionThrottle: null, // 保存位置节流计时器
  143. restorePositionTimer: null, // 恢复位置计时器
  144. }
  145. },
  146. computed: {
  147. currentIndex() {
  148. for (var index = 0; index < this.chapterList.length; index++) {
  149. var element = this.chapterList[index];
  150. if (element.id == this.cid) return index
  151. }
  152. return -1
  153. },
  154. currentChapterInfo() {
  155. return this.chapterList.find(chapter => chapter.id == this.cid) || {}
  156. },
  157. hasNextChapter() {
  158. return this.currentIndex >= 0 && this.currentIndex < this.chapterList.length - 1
  159. }
  160. },
  161. onLoad({
  162. id,
  163. cid
  164. }) {
  165. this.id = id
  166. this.cid = cid
  167. this.getDateil()
  168. this.getBookCatalogDetail()
  169. },
  170. onShow() {
  171. this.getBookCatalogList()
  172. this.isAddBook()
  173. },
  174. onPageScroll(e) {
  175. const scrollTop = e.scrollTop;
  176. // 保存阅读位置
  177. if (scrollTop > 100) { // 滚动超过100rpx才开始记录,避免记录无意义的顶部位置
  178. this.saveReadingPosition(scrollTop);
  179. }
  180. // 页面滚动监听,实现自动加载下一章
  181. if (!this.autoLoadNext || this.isAutoLoading || this.isPay || !this.hasNextChapter || this.triggerChecked) {
  182. return;
  183. }
  184. // 节流处理,避免频繁执行
  185. if (this.scrollThrottle) {
  186. clearTimeout(this.scrollThrottle);
  187. }
  188. this.scrollThrottle = setTimeout(() => {
  189. this.checkAutoLoadTrigger(scrollTop);
  190. }, 100);
  191. },
  192. mounted() {
  193. // 初始设置为全屏模式
  194. this.isFullScreen = true;
  195. },
  196. methods: {
  197. getDateil() {
  198. this.$fetch('getBookDetail', {
  199. id: this.id
  200. }).then(res => {
  201. this.novelData = res
  202. })
  203. },
  204. async isAddBook(){
  205. this.$fetch('isAddBook', {
  206. bookId: this.id
  207. }).then(res => {
  208. this.isBooshelf = res
  209. })
  210. },
  211. async getBookCatalogDetail() {
  212. this.isPay = await this.$fetch('getMyShopNovel', {
  213. bookId : this.id,
  214. novelId : this.cid,
  215. })
  216. this.isPay = !this.isPay
  217. this.$fetch('getBookCatalogDetail', {
  218. id: this.cid
  219. }).then(res => {
  220. this.paragraphs = res.details && res.details.split('\n')
  221. this.currentChapter = res.title
  222. if(res.isPay != 'Y'){
  223. this.isPay = false
  224. }
  225. this.updateSub()
  226. // 更新阅读进度到书架
  227. this.updateReadProgress()
  228. // 重置自动加载相关状态
  229. this.lastScrollTop = 0;
  230. if (this.autoLoadTimer) {
  231. clearInterval(this.autoLoadTimer);
  232. this.autoLoadTimer = null;
  233. }
  234. if (this.scrollThrottle) {
  235. clearTimeout(this.scrollThrottle);
  236. this.scrollThrottle = null;
  237. }
  238. if (this.savePositionThrottle) {
  239. clearTimeout(this.savePositionThrottle);
  240. this.savePositionThrottle = null;
  241. }
  242. if (this.restorePositionTimer) {
  243. clearTimeout(this.restorePositionTimer);
  244. this.restorePositionTimer = null;
  245. }
  246. this.clearBottomTimer(); // 清除底部计时器
  247. this.isAutoLoading = false;
  248. this.countdown = 3;
  249. this.triggerChecked = false; // 重置触发检查标记
  250. this.isAtBottom = false; // 重置底部状态
  251. this.bottomStayTime = 0; // 重置停留时间
  252. // 滚动到顶部或恢复阅读位置
  253. this.$nextTick(() => {
  254. // 先尝试恢复阅读位置,如果没有保存的位置则滚动到顶部
  255. const key = this.getReadingPositionKey();
  256. let hasSavedPosition = false;
  257. try {
  258. const positionData = uni.getStorageSync(key);
  259. hasSavedPosition = positionData && positionData.scrollTop > 100;
  260. } catch (error) {
  261. console.warn('检查保存位置失败:', error);
  262. }
  263. if (hasSavedPosition) {
  264. // 有保存的位置,恢复到上次阅读位置
  265. this.restoreReadingPosition();
  266. } else {
  267. // 没有保存的位置,滚动到顶部
  268. uni.pageScrollTo({
  269. scrollTop: 0,
  270. duration: 0
  271. });
  272. }
  273. })
  274. })
  275. },
  276. getBookCatalogList() {
  277. this.$fetch('getBookCatalogList', {
  278. bookId: this.id,
  279. pageNo: 1,
  280. pageSize: 9999999,
  281. reverse: 0,
  282. }).then(res => {
  283. this.chapterList = res.records
  284. })
  285. },
  286. handleContentClick() {
  287. this.toggleFullScreen();
  288. },
  289. toggleFullScreen() {
  290. this.isFullScreen = !this.isFullScreen
  291. },
  292. // 检查自动加载触发条件
  293. checkAutoLoadTrigger(scrollTop) {
  294. // 使用更严格的触发条件:距离底部很近且停留一段时间
  295. uni.createSelectorQuery().select('.chapter-content').boundingClientRect((rect) => {
  296. if (rect) {
  297. const windowHeight = uni.getSystemInfoSync().windowHeight;
  298. const contentBottom = rect.bottom;
  299. const distanceToBottom = contentBottom - windowHeight;
  300. // 严格条件:距离底部100rpx以内才算到达底部
  301. const isNearBottom = distanceToBottom < 100;
  302. if (isNearBottom) {
  303. // 如果刚到达底部,开始计时
  304. if (!this.isAtBottom) {
  305. this.isAtBottom = true;
  306. this.bottomStayTime = 0;
  307. console.log('到达章节底部,开始计时...');
  308. this.startBottomTimer();
  309. }
  310. } else {
  311. // 如果离开底部区域,重置状态
  312. if (this.isAtBottom) {
  313. this.isAtBottom = false;
  314. this.bottomStayTime = 0;
  315. this.clearBottomTimer();
  316. console.log('离开章节底部,重置计时');
  317. }
  318. }
  319. }
  320. this.lastScrollTop = scrollTop;
  321. }).exec();
  322. },
  323. // 开始底部停留计时
  324. startBottomTimer() {
  325. this.clearBottomTimer(); // 清除之前的计时器
  326. this.bottomCheckTimer = setInterval(() => {
  327. // 只有在底部状态下才继续计时
  328. if (this.isAtBottom) {
  329. this.bottomStayTime += 0.1; // 每100ms增加0.1秒
  330. console.log('底部停留时间:', this.bottomStayTime.toFixed(1) + 's');
  331. // 在底部停留2秒后触发自动加载
  332. if (this.bottomStayTime >= 2) {
  333. console.log('底部停留足够时间,触发自动加载');
  334. this.clearBottomTimer();
  335. this.autoLoadNextChapter();
  336. }
  337. } else {
  338. // 如果不在底部,停止计时
  339. this.clearBottomTimer();
  340. }
  341. }, 100);
  342. },
  343. // 清除底部计时器
  344. clearBottomTimer() {
  345. if (this.bottomCheckTimer) {
  346. clearInterval(this.bottomCheckTimer);
  347. this.bottomCheckTimer = null;
  348. }
  349. },
  350. // 生成阅读位置存储key
  351. getReadingPositionKey() {
  352. return `novel_reading_position_${this.id}_${this.cid}`;
  353. },
  354. // 保存阅读位置(带节流)
  355. saveReadingPosition(scrollTop) {
  356. // 节流处理,避免频繁保存
  357. if (this.savePositionThrottle) {
  358. clearTimeout(this.savePositionThrottle);
  359. }
  360. this.savePositionThrottle = setTimeout(() => {
  361. const key = this.getReadingPositionKey();
  362. const positionData = {
  363. scrollTop: scrollTop,
  364. timestamp: Date.now(),
  365. chapterTitle: this.currentChapter
  366. };
  367. try {
  368. uni.setStorageSync(key, positionData);
  369. console.log('保存阅读位置:', scrollTop, '章节:', this.currentChapter);
  370. } catch (error) {
  371. console.warn('保存阅读位置失败:', error);
  372. }
  373. }, 1000); // 1秒节流
  374. },
  375. // 恢复阅读位置
  376. restoreReadingPosition() {
  377. const key = this.getReadingPositionKey();
  378. try {
  379. const positionData = uni.getStorageSync(key);
  380. if (positionData && positionData.scrollTop > 0) {
  381. console.log('恢复阅读位置:', positionData.scrollTop, '章节:', positionData.chapterTitle);
  382. // 检查是否是最近保存的(5分钟内),如果是则显示提示
  383. const now = Date.now();
  384. const saveTime = positionData.timestamp || 0;
  385. const shouldShowToast = (now - saveTime) > 5 * 60 * 1000; // 5分钟
  386. // 等待DOM更新后再恢复位置
  387. this.$nextTick(() => {
  388. // 延迟恢复,确保内容已渲染
  389. this.restorePositionTimer = setTimeout(() => {
  390. uni.pageScrollTo({
  391. scrollTop: positionData.scrollTop,
  392. duration: 0
  393. });
  394. // 只有在距离上次保存超过5分钟时才显示提示
  395. if (shouldShowToast) {
  396. uni.showToast({
  397. title: '已恢复阅读位置',
  398. icon: 'none',
  399. duration: 1500
  400. });
  401. }
  402. }, 500);
  403. });
  404. }
  405. } catch (error) {
  406. console.warn('恢复阅读位置失败:', error);
  407. }
  408. },
  409. // 清除当前章节的阅读位置记录
  410. clearReadingPosition() {
  411. const key = this.getReadingPositionKey();
  412. try {
  413. uni.removeStorageSync(key);
  414. console.log('清除阅读位置记录:', this.currentChapter);
  415. } catch (error) {
  416. console.warn('清除阅读位置失败:', error);
  417. }
  418. },
  419. // 重置阅读位置(清除记录并回到顶部)
  420. resetReadingPosition() {
  421. uni.showModal({
  422. title: '重新阅读',
  423. content: '确定要清除当前章节的阅读记录并回到开头吗?',
  424. confirmText: '确定',
  425. cancelText: '取消',
  426. success: (res) => {
  427. if (res.confirm) {
  428. // 清除阅读位置记录
  429. this.clearReadingPosition();
  430. // 回到顶部
  431. uni.pageScrollTo({
  432. scrollTop: 0,
  433. duration: 0
  434. });
  435. uni.showToast({
  436. title: '已重置到章节开头',
  437. icon: 'success',
  438. duration: 1500
  439. });
  440. }
  441. }
  442. });
  443. },
  444. // 自动加载下一章
  445. autoLoadNextChapter() {
  446. if (this.isAutoLoading || !this.hasNextChapter || this.triggerChecked) return;
  447. this.triggerChecked = true; // 标记已触发,避免重复
  448. this.isAutoLoading = true;
  449. this.countdown = 3;
  450. // 清除之前的计时器
  451. if (this.autoLoadTimer) {
  452. clearInterval(this.autoLoadTimer);
  453. }
  454. console.log('开始自动加载下一章倒计时');
  455. // 开始倒计时
  456. this.startCountdown();
  457. },
  458. // 开始倒计时
  459. startCountdown() {
  460. const countdownInterval = setInterval(() => {
  461. this.countdown--;
  462. if (this.countdown <= 0) {
  463. clearInterval(countdownInterval);
  464. // 清除当前章节的阅读记录,因为用户已经读完了
  465. this.clearReadingPosition();
  466. this.nextChapter(1);
  467. this.isAutoLoading = false;
  468. this.countdown = 3;
  469. }
  470. }, 1000);
  471. // 保存interval引用以便取消
  472. this.autoLoadTimer = countdownInterval;
  473. },
  474. // 取消自动加载
  475. cancelAutoLoad() {
  476. if (this.autoLoadTimer) {
  477. clearInterval(this.autoLoadTimer);
  478. this.autoLoadTimer = null;
  479. }
  480. if (this.savePositionThrottle) {
  481. clearTimeout(this.savePositionThrottle);
  482. this.savePositionThrottle = null;
  483. }
  484. if (this.restorePositionTimer) {
  485. clearTimeout(this.restorePositionTimer);
  486. this.restorePositionTimer = null;
  487. }
  488. this.clearBottomTimer(); // 清除底部计时器
  489. this.isAutoLoading = false;
  490. this.countdown = 3;
  491. this.triggerChecked = false; // 重置触发标记,允许重新触发
  492. this.isAtBottom = false; // 重置底部状态
  493. this.bottomStayTime = 0; // 重置停留时间
  494. uni.showToast({
  495. title: '已取消自动跳转',
  496. icon: 'none'
  497. });
  498. },
  499. // 切换自动加载功能
  500. toggleAutoLoad() {
  501. this.autoLoadNext = !this.autoLoadNext;
  502. // 如果关闭自动加载且正在加载中,则取消加载
  503. if (!this.autoLoadNext && this.isAutoLoading) {
  504. this.cancelAutoLoad();
  505. }
  506. uni.showToast({
  507. title: this.autoLoadNext ? '已开启自动加载' : '已关闭自动加载',
  508. icon: 'none'
  509. });
  510. },
  511. async goToSubscription() {
  512. await this.$fetch('buyNovel', {
  513. bookId : this.id,
  514. novelId : this.cid,
  515. })
  516. this.isPay = false
  517. this.updateSub()
  518. },
  519. selectChapter({
  520. item,
  521. index
  522. }) {
  523. // 保存当前章节ID用于清除记录
  524. const previousCid = this.cid;
  525. this.cid = item.id
  526. this.isFullScreen = true
  527. this.getBookCatalogDetail()
  528. },
  529. nextChapter(next) {
  530. let index = this.currentIndex + next
  531. if(index < 0 || index >= this.chapterList.length){
  532. uni.showToast({
  533. title: '到底了',
  534. icon: 'none'
  535. })
  536. return
  537. }
  538. this.cid = this.chapterList[index].id
  539. this.isFullScreen = true
  540. this.getBookCatalogDetail()
  541. // 跳转后重置自动加载状态
  542. this.isAutoLoading = false;
  543. this.triggerChecked = false;
  544. if (this.autoLoadTimer) {
  545. clearInterval(this.autoLoadTimer);
  546. this.autoLoadTimer = null;
  547. }
  548. if (this.scrollThrottle) {
  549. clearTimeout(this.scrollThrottle);
  550. this.scrollThrottle = null;
  551. }
  552. if (this.savePositionThrottle) {
  553. clearTimeout(this.savePositionThrottle);
  554. this.savePositionThrottle = null;
  555. }
  556. if (this.restorePositionTimer) {
  557. clearTimeout(this.restorePositionTimer);
  558. this.restorePositionTimer = null;
  559. }
  560. this.clearBottomTimer(); // 清除底部计时器
  561. this.isAtBottom = false; // 重置底部状态
  562. this.bottomStayTime = 0; // 重置停留时间
  563. },
  564. addToBookshelf() {
  565. this.$fetch('addReadBook', {
  566. shopId: this.id,
  567. name: this.novelData.name,
  568. image: this.novelData.image,
  569. novelId : this.fastCatalog && this.fastCatalog.id
  570. }).then(res => {
  571. this.isBooshelf = true
  572. uni.showToast({
  573. title: '已加入书架',
  574. icon: 'success'
  575. })
  576. })
  577. },
  578. // 更新阅读进度到书架
  579. updateReadProgress() {
  580. if (!this.id || !this.cid) return;
  581. this.$fetch('saveOrUpdateReadBook', {
  582. shopId: this.id, // 书籍id
  583. novelId: this.cid, // 章节id
  584. name: this.novelData.name,
  585. image: this.novelData.image
  586. }).then(res => {
  587. console.log('阅读进度已更新');
  588. }).catch(err => {
  589. console.error('更新阅读进度失败:', err);
  590. });
  591. },
  592. updateSub(){
  593. if(this.isPay){
  594. this.$refs.subscriptionPopup.open()
  595. }else{
  596. this.$refs.subscriptionPopup.close()
  597. }
  598. },
  599. // 处理批量订阅
  600. async handleBatchSubscribe(batchCount) {
  601. try {
  602. // 获取从当前章节开始的连续章节ID
  603. const chapterIds = [];
  604. const startIndex = this.currentIndex;
  605. for (let i = 0; i < batchCount && (startIndex + i) < this.chapterList.length; i++) {
  606. const chapter = this.chapterList[startIndex + i];
  607. if (chapter.isPay === 'Y' && !chapter.pay) {
  608. chapterIds.push(chapter.id);
  609. }
  610. }
  611. if (chapterIds.length === 0) {
  612. uni.showToast({
  613. title: '没有需要购买的章节',
  614. icon: 'none'
  615. });
  616. return;
  617. }
  618. // 调用批量订阅接口
  619. await this.$fetch('buyNovel', {
  620. bookId: this.id,
  621. novelId: chapterIds.join(',')
  622. });
  623. uni.showToast({
  624. title: `成功订阅${chapterIds.length}`,
  625. icon: 'success'
  626. });
  627. // 刷新章节列表状态
  628. this.getBookCatalogList();
  629. this.isPay = false;
  630. this.updateSub();
  631. } catch (error) {
  632. console.error('批量订阅失败:', error);
  633. uni.showToast({
  634. title: '订阅失败,请重试',
  635. icon: 'none'
  636. });
  637. }
  638. },
  639. // 处理视频解锁
  640. async handleVideoUnlock() {
  641. try {
  642. // await this.$fetch('openBookCatalog', {
  643. // bookId: this.id,
  644. // catalogId: this.cid
  645. // });
  646. uni.showToast({
  647. title: '暂未开放',
  648. icon: 'none'
  649. });
  650. // this.isPay = false;
  651. // this.updateSub();
  652. } catch (error) {
  653. console.error('视频解锁失败:', error);
  654. uni.showToast({
  655. title: '解锁失败,请重试',
  656. icon: 'none'
  657. });
  658. }
  659. },
  660. },
  661. beforeDestroy() {
  662. // 组件销毁时清除所有计时器
  663. if (this.autoLoadTimer) {
  664. clearInterval(this.autoLoadTimer);
  665. this.autoLoadTimer = null;
  666. }
  667. if (this.scrollThrottle) {
  668. clearTimeout(this.scrollThrottle);
  669. this.scrollThrottle = null;
  670. }
  671. if (this.savePositionThrottle) {
  672. clearTimeout(this.savePositionThrottle);
  673. this.savePositionThrottle = null;
  674. }
  675. if (this.restorePositionTimer) {
  676. clearTimeout(this.restorePositionTimer);
  677. this.restorePositionTimer = null;
  678. }
  679. this.clearBottomTimer(); // 清除底部计时器
  680. }
  681. }
  682. </script>
  683. <style lang="scss" scoped>
  684. .reader-container {
  685. min-height: 100vh;
  686. background: #fff;
  687. display: flex;
  688. flex-direction: column;
  689. position: relative;
  690. overflow: hidden;
  691. &.dark-mode {
  692. background: #1a1a1a;
  693. .top-controls {
  694. background: rgba(34, 34, 34, 0.98);
  695. box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.2);
  696. .controls-inner {
  697. .center {
  698. .title {
  699. color: #eee;
  700. }
  701. .chapter {
  702. color: #bbb;
  703. }
  704. }
  705. }
  706. .progress-bar {
  707. background: #333;
  708. .progress-inner {
  709. background: #4a90e2;
  710. }
  711. }
  712. }
  713. .chapter-content {
  714. color: #ccc;
  715. .chapter-content-item {
  716. .chapter-title {
  717. color: #eee;
  718. }
  719. .paragraph-content {
  720. .paragraph {
  721. color: #bbb;
  722. }
  723. }
  724. }
  725. }
  726. .bottom-bar {
  727. background: #222;
  728. box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.2);
  729. .bottom-left {
  730. .bar-item {
  731. .bar-label {
  732. color: #999;
  733. }
  734. }
  735. }
  736. .bottom-right {
  737. .outline-btn {
  738. background: #222;
  739. color: #999;
  740. border: 2rpx solid #999;
  741. .btn-text {
  742. color: #999;
  743. border-bottom: 2rpx solid #999;
  744. }
  745. }
  746. }
  747. }
  748. .load-more-area {
  749. .load-more-content {
  750. .load-more-line {
  751. background: linear-gradient(to right, transparent, #444, transparent);
  752. }
  753. .load-more-text {
  754. color: #666;
  755. }
  756. }
  757. .waiting-content {
  758. background: #4a3728;
  759. border-color: #6b5b47;
  760. .waiting-text {
  761. color: #ff8a80;
  762. }
  763. .tip-text {
  764. color: #999;
  765. }
  766. }
  767. .loading-content {
  768. background: #2a2a2a;
  769. .loading-text {
  770. color: #4a90e2;
  771. }
  772. .cancel-text {
  773. color: #999;
  774. }
  775. }
  776. }
  777. }
  778. .top-controls {
  779. position: fixed;
  780. top: 0;
  781. left: 0;
  782. right: 0;
  783. background: rgba(255, 255, 255, 0.98);
  784. padding-top: calc(var(--status-bar-height) + 10rpx);
  785. z-index: 100000;
  786. transform: translateY(0);
  787. transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out, background-color 0.3s ease;
  788. box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
  789. .controls-inner {
  790. display: flex;
  791. align-items: center;
  792. justify-content: space-between;
  793. padding: 20rpx 32rpx;
  794. position: relative;
  795. .left {
  796. width: 100rpx;
  797. display: flex;
  798. justify-content: flex-start;
  799. align-items: center;
  800. }
  801. .center {
  802. flex: 1;
  803. display: flex;
  804. flex-direction: column;
  805. align-items: center;
  806. justify-content: center;
  807. .title {
  808. font-size: 32rpx;
  809. font-weight: 500;
  810. color: #333;
  811. margin-bottom: 4rpx;
  812. white-space: nowrap;
  813. overflow: hidden;
  814. text-overflow: ellipsis;
  815. max-width: 320rpx;
  816. }
  817. .chapter {
  818. font-size: 24rpx;
  819. color: #666;
  820. white-space: nowrap;
  821. overflow: hidden;
  822. text-overflow: ellipsis;
  823. max-width: 320rpx;
  824. }
  825. }
  826. .right {
  827. width: 100rpx;
  828. display: flex;
  829. justify-content: flex-end;
  830. align-items: center;
  831. }
  832. }
  833. .progress-bar {
  834. height: 4rpx;
  835. background: #f0f0f0;
  836. width: 100%;
  837. position: relative;
  838. .progress-inner {
  839. height: 100%;
  840. background: #4a90e2;
  841. transition: width 0.3s;
  842. }
  843. }
  844. &.top-controls-hidden {
  845. transform: translateY(-100%);
  846. opacity: 0;
  847. }
  848. }
  849. .chapter-content {
  850. flex: 1;
  851. padding: 0 32rpx;
  852. font-size: 28rpx;
  853. color: #222;
  854. line-height: 2.2;
  855. padding-top: 160rpx;
  856. /* 为导航栏预留空间,不随状态变化 */
  857. padding-bottom: 180rpx;
  858. width: 100%;
  859. box-sizing: border-box;
  860. overflow-x: hidden;
  861. transition: color 0.3s ease, background-color 0.3s ease;
  862. .chapter-content-item {
  863. width: 100%;
  864. .chapter-title {
  865. font-size: 36rpx;
  866. font-weight: bold;
  867. margin: 20rpx 0 40rpx 0;
  868. text-align: center;
  869. word-break: break-word;
  870. white-space: normal;
  871. transition: color 0.3s ease;
  872. }
  873. .paragraph-content {
  874. width: 100%;
  875. .paragraph {
  876. text-indent: 2em;
  877. margin-bottom: 30rpx;
  878. line-height: 1.8;
  879. font-size: 30rpx;
  880. color: #333;
  881. word-wrap: break-word;
  882. word-break: normal;
  883. white-space: normal;
  884. transition: color 0.3s ease;
  885. }
  886. }
  887. }
  888. &.full-content {
  889. /* 不再修改顶部padding,保持内容位置不变 */
  890. }
  891. }
  892. .bottom-bar {
  893. position: fixed;
  894. left: 0;
  895. right: 0;
  896. bottom: 0;
  897. background: #fff;
  898. display: flex;
  899. justify-content: center;
  900. align-items: center;
  901. height: 180rpx;
  902. box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
  903. z-index: 100000;
  904. padding: 0 40rpx 10rpx 40rpx;
  905. transform: translateY(0);
  906. transition: transform 0.3s ease-in-out, background-color 0.3s ease;
  907. &.bottom-bar-hidden {
  908. transform: translateY(100%);
  909. }
  910. .bottom-left {
  911. display: flex;
  912. align-items: flex-end;
  913. gap: 48rpx;
  914. .bar-item {
  915. display: flex;
  916. flex-direction: column;
  917. align-items: center;
  918. justify-content: flex-end;
  919. .bar-icon {
  920. width: 48rpx;
  921. height: 48rpx;
  922. margin-bottom: 4rpx;
  923. margin-right: 1rpx;
  924. display: flex;
  925. align-items: center;
  926. justify-content: center;
  927. }
  928. .bar-label {
  929. font-size: 22rpx;
  930. color: #b3b3b3;
  931. margin-top: 2rpx;
  932. transition: color 0.3s ease;
  933. }
  934. }
  935. }
  936. .bottom-right {
  937. display: flex;
  938. align-items: flex-end;
  939. gap: 22rpx;
  940. margin-left: 40rpx;
  941. text-overflow: ellipsis;
  942. .outline-btn {
  943. flex-shrink: 0;
  944. min-width: 110rpx;
  945. padding: 0 26rpx;
  946. height: 60rpx;
  947. line-height: 60rpx;
  948. background: #fff;
  949. color: #223a7a;
  950. border: 2rpx solid #223a7a;
  951. border-radius: 32rpx;
  952. font-size: 26rpx;
  953. font-weight: bold;
  954. margin: 0;
  955. display: flex;
  956. align-items: center;
  957. justify-content: center;
  958. transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
  959. .btn-text {
  960. font-weight: bold;
  961. color: #223a7a;
  962. font-size: 26rpx;
  963. border-bottom: 2rpx solid #223a7a;
  964. padding-bottom: 2rpx;
  965. transition: color 0.3s ease, border-color 0.3s ease;
  966. }
  967. }
  968. }
  969. }
  970. /* 加载更多区域样式 */
  971. .load-more-area {
  972. margin-top: 60rpx;
  973. padding: 40rpx 0;
  974. width: 100%;
  975. .load-more-content {
  976. display: flex;
  977. align-items: center;
  978. justify-content: center;
  979. .load-more-line {
  980. flex: 1;
  981. height: 2rpx;
  982. background: linear-gradient(to right, transparent, #e0e0e0, transparent);
  983. }
  984. .load-more-text {
  985. font-size: 24rpx;
  986. color: #999;
  987. margin: 0 30rpx;
  988. white-space: nowrap;
  989. }
  990. }
  991. .waiting-content {
  992. display: flex;
  993. flex-direction: column;
  994. align-items: center;
  995. justify-content: center;
  996. padding: 20rpx;
  997. background: #fff3cd;
  998. border-radius: 16rpx;
  999. margin: 0 60rpx;
  1000. border: 2rpx solid #ffeaa7;
  1001. .waiting-text {
  1002. font-size: 28rpx;
  1003. color: #ff6b6b;
  1004. margin: 10rpx 0 5rpx 0;
  1005. font-weight: 500;
  1006. }
  1007. .tip-text {
  1008. font-size: 22rpx;
  1009. color: #666;
  1010. font-style: italic;
  1011. }
  1012. }
  1013. .loading-content {
  1014. display: flex;
  1015. flex-direction: column;
  1016. align-items: center;
  1017. justify-content: center;
  1018. padding: 20rpx;
  1019. background: #f8f9fa;
  1020. border-radius: 16rpx;
  1021. margin: 0 60rpx;
  1022. .loading-text {
  1023. font-size: 28rpx;
  1024. color: #4a90e2;
  1025. margin: 10rpx 0 5rpx 0;
  1026. font-weight: 500;
  1027. }
  1028. .cancel-text {
  1029. font-size: 22rpx;
  1030. color: #666;
  1031. text-decoration: underline;
  1032. }
  1033. }
  1034. }
  1035. }
  1036. </style>