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

653 lines
14 KiB

  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. <scroll-view class="chapter-content" :class="{'full-content': isFullScreen}"
  22. @scroll="handleScroll" @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. </view>
  31. </scroll-view>
  32. <view class="bottom-bar" :class="{'bottom-bar-hidden': isFullScreen}">
  33. <view class="bottom-left">
  34. <view class="bar-item" v-if="!isBooshelf" @click="addToBookshelf">
  35. <view class="bar-icon"> <uv-icon name="plus"></uv-icon> </view>
  36. <text class="bar-label">加入书架</text>
  37. </view>
  38. <view class="bar-item" @click="toggleThemeMode">
  39. <view class="bar-icon">
  40. <uv-icon :name="isDarkMode ? 'eye' : 'eye-fill'"></uv-icon>
  41. </view>
  42. <text class="bar-label">{{ isDarkMode ? '白天' : '夜间' }}</text>
  43. </view>
  44. </view>
  45. <view class="bottom-right">
  46. <button class="outline-btn"
  47. @click="nextChapter(-1)">
  48. <text class="btn-text">上一章</text>
  49. </button>
  50. <button class="outline-btn" @click="$refs.chapterPopup.open()">
  51. <text class="btn-text">目录</text>
  52. </button>
  53. <button class="outline-btn"
  54. @click="nextChapter(1)">
  55. <text class="btn-text">下一章</text>
  56. </button>
  57. </view>
  58. </view>
  59. <!-- 使用封装的订阅弹窗组件 -->
  60. <subscriptionPopup ref="subscriptionPopup"
  61. :chapterList="chapterList"
  62. :currentChapter="currentChapterInfo"
  63. :currentIndex="currentIndex"
  64. :bookId="id"
  65. @maskClick="toggleFullScreen"
  66. @subscribe="goToSubscription"
  67. @batchSubscribe="handleBatchSubscribe"
  68. @videoUnlock="handleVideoUnlock" />
  69. <novelVotePopup ref="novelVotePopup" />
  70. <chapterPopup ref="chapterPopup" :chapterList="chapterList" :currentIndex="currentIndex"
  71. @selectChapter="selectChapter" />
  72. </view>
  73. </template>
  74. <script>
  75. import chapterPopup from '../components/novel/chapterPopup.vue'
  76. import novelVotePopup from '../components/novel/novelVotePopup.vue'
  77. import subscriptionPopup from '../components/novel/subscriptionPopup.vue'
  78. import themeMixin from '@/mixins/themeMode.js' // 导入主题混合器
  79. export default {
  80. components: {
  81. chapterPopup,
  82. novelVotePopup,
  83. subscriptionPopup,
  84. },
  85. mixins: [themeMixin], // 使用主题混合器
  86. data() {
  87. return {
  88. isFullScreen: false,
  89. popupShown: false, // 只弹一次
  90. currentChapter: "",
  91. readProgress: 15, // 阅读进度百分比
  92. paragraphs: [],
  93. id: 0,
  94. cid: 0,
  95. novelData: {},
  96. chapterList: [],
  97. // 是否需要购买
  98. isPay : false,
  99. isBooshelf : false,
  100. }
  101. },
  102. computed: {
  103. currentIndex() {
  104. for (var index = 0; index < this.chapterList.length; index++) {
  105. var element = this.chapterList[index];
  106. if (element.id == this.cid) return index
  107. }
  108. return -1
  109. },
  110. currentChapterInfo() {
  111. return this.chapterList.find(chapter => chapter.id == this.cid) || {}
  112. }
  113. },
  114. onLoad({
  115. id,
  116. cid
  117. }) {
  118. this.id = id
  119. this.cid = cid
  120. this.getDateil()
  121. this.getBookCatalogDetail()
  122. },
  123. onShow() {
  124. this.getBookCatalogList()
  125. this.isAddBook()
  126. },
  127. mounted() {
  128. // 初始设置为全屏模式
  129. this.isFullScreen = true;
  130. },
  131. methods: {
  132. getDateil() {
  133. this.$fetch('getBookDetail', {
  134. id: this.id
  135. }).then(res => {
  136. this.novelData = res
  137. })
  138. },
  139. async isAddBook(){
  140. this.$fetch('isAddBook', {
  141. bookId: this.id
  142. }).then(res => {
  143. this.isBooshelf = res
  144. })
  145. },
  146. async getBookCatalogDetail() {
  147. this.isPay = await this.$fetch('getMyShopNovel', {
  148. bookId : this.id,
  149. novelId : this.cid,
  150. })
  151. this.isPay = !this.isPay
  152. this.$fetch('getBookCatalogDetail', {
  153. id: this.cid
  154. }).then(res => {
  155. this.paragraphs = res.details && res.details.split('\n')
  156. this.currentChapter = res.title
  157. if(res.isPay != 'Y'){
  158. this.isPay = false
  159. }
  160. this.updateSub()
  161. // 更新阅读进度到书架
  162. this.updateReadProgress()
  163. // 滚动到顶部
  164. this.$nextTick(() => {
  165. uni.pageScrollTo({
  166. scrollTop: 0,
  167. duration: 0
  168. });
  169. })
  170. })
  171. },
  172. getBookCatalogList() {
  173. this.$fetch('getBookCatalogList', {
  174. bookId: this.id,
  175. pageNo: 1,
  176. pageSize: 9999999,
  177. reverse: 0,
  178. }).then(res => {
  179. this.chapterList = res.records
  180. })
  181. },
  182. handleContentClick() {
  183. this.toggleFullScreen();
  184. },
  185. handleScroll(e) {
  186. // scroll-view的滚动事件(如果需要处理scroll-view内部的滚动逻辑)
  187. // 目前主要的滚动逻辑已移至onPageScroll处理
  188. },
  189. toggleFullScreen() {
  190. this.isFullScreen = !this.isFullScreen
  191. },
  192. async goToSubscription() {
  193. await this.$fetch('buyNovel', {
  194. bookId : this.id,
  195. novelId : this.cid,
  196. })
  197. this.isPay = false
  198. this.updateSub()
  199. },
  200. selectChapter({
  201. item,
  202. index
  203. }) {
  204. this.cid = item.id
  205. this.isFullScreen = true
  206. this.getBookCatalogDetail()
  207. },
  208. nextChapter(next) {
  209. let index = this.currentIndex + next
  210. if(index < 0 || index >= this.chapterList.length){
  211. uni.showToast({
  212. title: '到底了',
  213. icon: 'none'
  214. })
  215. return
  216. }
  217. this.cid = this.chapterList[index].id
  218. this.isFullScreen = true
  219. this.getBookCatalogDetail()
  220. },
  221. addToBookshelf() {
  222. this.$fetch('addReadBook', {
  223. shopId: this.id,
  224. name: this.novelData.name,
  225. image: this.novelData.image,
  226. novelId : this.fastCatalog && this.fastCatalog.id
  227. }).then(res => {
  228. this.isBooshelf = true
  229. uni.showToast({
  230. title: '已加入书架',
  231. icon: 'success'
  232. })
  233. })
  234. },
  235. // 更新阅读进度到书架
  236. updateReadProgress() {
  237. if (!this.id || !this.cid) return;
  238. this.$fetch('saveOrUpdateReadBook', {
  239. shopId: this.id, // 书籍id
  240. novelId: this.cid, // 章节id
  241. name: this.novelData.name,
  242. image: this.novelData.image
  243. }).then(res => {
  244. console.log('阅读进度已更新');
  245. }).catch(err => {
  246. console.error('更新阅读进度失败:', err);
  247. });
  248. },
  249. updateSub(){
  250. if(this.isPay){
  251. this.$refs.subscriptionPopup.open()
  252. }else{
  253. this.$refs.subscriptionPopup.close()
  254. }
  255. },
  256. // 处理批量订阅
  257. async handleBatchSubscribe(batchCount) {
  258. try {
  259. // 获取从当前章节开始的连续章节ID
  260. const chapterIds = [];
  261. const startIndex = this.currentIndex;
  262. for (let i = 0; i < batchCount && (startIndex + i) < this.chapterList.length; i++) {
  263. const chapter = this.chapterList[startIndex + i];
  264. if (chapter.isPay === 'Y' && !chapter.pay) {
  265. chapterIds.push(chapter.id);
  266. }
  267. }
  268. if (chapterIds.length === 0) {
  269. uni.showToast({
  270. title: '没有需要购买的章节',
  271. icon: 'none'
  272. });
  273. return;
  274. }
  275. // 调用批量订阅接口
  276. await this.$fetch('buyNovel', {
  277. bookId: this.id,
  278. novelId: chapterIds.join(',')
  279. });
  280. uni.showToast({
  281. title: `成功订阅${chapterIds.length}`,
  282. icon: 'success'
  283. });
  284. // 刷新章节列表状态
  285. this.getBookCatalogList();
  286. this.isPay = false;
  287. this.updateSub();
  288. } catch (error) {
  289. console.error('批量订阅失败:', error);
  290. uni.showToast({
  291. title: '订阅失败,请重试',
  292. icon: 'none'
  293. });
  294. }
  295. },
  296. // 处理视频解锁
  297. async handleVideoUnlock() {
  298. try {
  299. await this.$fetch('openBookCatalog', {
  300. bookId: this.id,
  301. catalogId: this.cid
  302. });
  303. uni.showToast({
  304. title: '视频解锁成功',
  305. icon: 'success'
  306. });
  307. this.isPay = false;
  308. this.updateSub();
  309. } catch (error) {
  310. console.error('视频解锁失败:', error);
  311. uni.showToast({
  312. title: '解锁失败,请重试',
  313. icon: 'none'
  314. });
  315. }
  316. },
  317. },
  318. }
  319. </script>
  320. <style lang="scss" scoped>
  321. .reader-container {
  322. min-height: 100vh;
  323. background: #fff;
  324. display: flex;
  325. flex-direction: column;
  326. position: relative;
  327. overflow: hidden;
  328. &.dark-mode {
  329. background: #1a1a1a;
  330. .top-controls {
  331. background: rgba(34, 34, 34, 0.98);
  332. box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.2);
  333. .controls-inner {
  334. .center {
  335. .title {
  336. color: #eee;
  337. }
  338. .chapter {
  339. color: #bbb;
  340. }
  341. }
  342. }
  343. .progress-bar {
  344. background: #333;
  345. .progress-inner {
  346. background: #4a90e2;
  347. }
  348. }
  349. }
  350. .chapter-content {
  351. color: #ccc;
  352. .chapter-content-item {
  353. .chapter-title {
  354. color: #eee;
  355. }
  356. .paragraph-content {
  357. .paragraph {
  358. color: #bbb;
  359. }
  360. }
  361. }
  362. }
  363. .bottom-bar {
  364. background: #222;
  365. box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.2);
  366. .bottom-left {
  367. .bar-item {
  368. .bar-label {
  369. color: #999;
  370. }
  371. }
  372. }
  373. .bottom-right {
  374. .outline-btn {
  375. background: #222;
  376. color: #999;
  377. border: 2rpx solid #999;
  378. .btn-text {
  379. color: #999;
  380. border-bottom: 2rpx solid #999;
  381. }
  382. }
  383. }
  384. }
  385. }
  386. .top-controls {
  387. position: fixed;
  388. top: 0;
  389. left: 0;
  390. right: 0;
  391. background: rgba(255, 255, 255, 0.98);
  392. padding-top: calc(var(--status-bar-height) + 10rpx);
  393. z-index: 100000;
  394. transform: translateY(0);
  395. transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out, background-color 0.3s ease;
  396. box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
  397. .controls-inner {
  398. display: flex;
  399. align-items: center;
  400. justify-content: space-between;
  401. padding: 20rpx 32rpx;
  402. position: relative;
  403. .left {
  404. width: 100rpx;
  405. display: flex;
  406. justify-content: flex-start;
  407. align-items: center;
  408. }
  409. .center {
  410. flex: 1;
  411. display: flex;
  412. flex-direction: column;
  413. align-items: center;
  414. justify-content: center;
  415. .title {
  416. font-size: 32rpx;
  417. font-weight: 500;
  418. color: #333;
  419. margin-bottom: 4rpx;
  420. white-space: nowrap;
  421. overflow: hidden;
  422. text-overflow: ellipsis;
  423. max-width: 320rpx;
  424. }
  425. .chapter {
  426. font-size: 24rpx;
  427. color: #666;
  428. white-space: nowrap;
  429. overflow: hidden;
  430. text-overflow: ellipsis;
  431. max-width: 320rpx;
  432. }
  433. }
  434. .right {
  435. width: 100rpx;
  436. display: flex;
  437. justify-content: flex-end;
  438. align-items: center;
  439. }
  440. }
  441. .progress-bar {
  442. height: 4rpx;
  443. background: #f0f0f0;
  444. width: 100%;
  445. position: relative;
  446. .progress-inner {
  447. height: 100%;
  448. background: #4a90e2;
  449. transition: width 0.3s;
  450. }
  451. }
  452. &.top-controls-hidden {
  453. transform: translateY(-100%);
  454. opacity: 0;
  455. }
  456. }
  457. .chapter-content {
  458. flex: 1;
  459. padding: 0 32rpx;
  460. font-size: 28rpx;
  461. color: #222;
  462. line-height: 2.2;
  463. padding-top: 160rpx;
  464. /* 为导航栏预留空间,不随状态变化 */
  465. padding-bottom: 180rpx;
  466. width: 100%;
  467. box-sizing: border-box;
  468. overflow-x: hidden;
  469. transition: color 0.3s ease, background-color 0.3s ease;
  470. .chapter-content-item {
  471. width: 100%;
  472. .chapter-title {
  473. font-size: 36rpx;
  474. font-weight: bold;
  475. margin: 20rpx 0 40rpx 0;
  476. text-align: center;
  477. word-break: break-word;
  478. white-space: normal;
  479. transition: color 0.3s ease;
  480. }
  481. .paragraph-content {
  482. width: 100%;
  483. .paragraph {
  484. text-indent: 2em;
  485. margin-bottom: 30rpx;
  486. line-height: 1.8;
  487. font-size: 30rpx;
  488. color: #333;
  489. word-wrap: break-word;
  490. word-break: normal;
  491. white-space: normal;
  492. transition: color 0.3s ease;
  493. }
  494. }
  495. }
  496. &.full-content {
  497. /* 不再修改顶部padding,保持内容位置不变 */
  498. }
  499. }
  500. .bottom-bar {
  501. position: fixed;
  502. left: 0;
  503. right: 0;
  504. bottom: 0;
  505. background: #fff;
  506. display: flex;
  507. justify-content: center;
  508. align-items: center;
  509. height: 180rpx;
  510. box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
  511. z-index: 100000;
  512. padding: 0 40rpx 10rpx 40rpx;
  513. transform: translateY(0);
  514. transition: transform 0.3s ease-in-out, background-color 0.3s ease;
  515. &.bottom-bar-hidden {
  516. transform: translateY(100%);
  517. }
  518. .bottom-left {
  519. display: flex;
  520. align-items: flex-end;
  521. gap: 48rpx;
  522. .bar-item {
  523. display: flex;
  524. flex-direction: column;
  525. align-items: center;
  526. justify-content: flex-end;
  527. .bar-icon {
  528. width: 48rpx;
  529. height: 48rpx;
  530. margin-bottom: 4rpx;
  531. margin-right: 1rpx;
  532. display: flex;
  533. align-items: center;
  534. justify-content: center;
  535. }
  536. .bar-label {
  537. font-size: 22rpx;
  538. color: #b3b3b3;
  539. margin-top: 2rpx;
  540. transition: color 0.3s ease;
  541. }
  542. }
  543. }
  544. .bottom-right {
  545. display: flex;
  546. align-items: flex-end;
  547. gap: 22rpx;
  548. margin-left: 40rpx;
  549. text-overflow: ellipsis;
  550. .outline-btn {
  551. flex-shrink: 0;
  552. min-width: 110rpx;
  553. padding: 0 26rpx;
  554. height: 60rpx;
  555. line-height: 60rpx;
  556. background: #fff;
  557. color: #223a7a;
  558. border: 2rpx solid #223a7a;
  559. border-radius: 32rpx;
  560. font-size: 26rpx;
  561. font-weight: bold;
  562. margin: 0;
  563. display: flex;
  564. align-items: center;
  565. justify-content: center;
  566. transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
  567. .btn-text {
  568. font-weight: bold;
  569. color: #223a7a;
  570. font-size: 26rpx;
  571. border-bottom: 2rpx solid #223a7a;
  572. padding-bottom: 2rpx;
  573. transition: color 0.3s ease, border-color 0.3s ease;
  574. }
  575. }
  576. }
  577. }
  578. }
  579. </style>