├── php-api/ # 改造后的PHP接口层 ├── java-ad-service/ # 若依框架微服务(广告+VIP+分账) ├── uniapp-reader/ # UniApp前端项目 │ ├── pages/ # 各端页面 │ └──
Du kannst nicht mehr als 25 Themen auswählen Themen müssen mit entweder einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

reader.vue 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741
  1. <template>
  2. <view class="reader-container" :style="readerStyles">
  3. <!-- 登录提示弹窗 -->
  4. <uni-popup ref="loginPopup" type="dialog">
  5. <uni-popup-dialog
  6. type="info"
  7. title="登录提示"
  8. content="您已阅读完免费章节,登录后可继续阅读"
  9. :duration="2000"
  10. :before-close="true"
  11. @close="closeLoginDialog"
  12. @confirm="goToLogin"
  13. ></uni-popup-dialog>
  14. </uni-popup>
  15. <!-- 顶部导航 -->
  16. <view class="reader-header">
  17. <uni-icons type="arrowleft" size="28" color="#fff" @click="goBack"></uni-icons>
  18. <text class="novel-title">{{ novelTitle }}</text>
  19. <view class="header-actions">
  20. <uni-icons type="more" size="28" color="#fff" @click="showMoreActions"></uni-icons>
  21. </view>
  22. </view>
  23. <!-- 阅读区域 -->
  24. <scroll-view scroll-y class="reader-content" :scroll-top="scrollTop" @scroll="onScroll">
  25. <view class="chapter-title">{{ chapterDetail.title }}</view>
  26. <rich-text :nodes="chapterContent" class="content-text"></rich-text>
  27. <!-- 章节结束提示 -->
  28. <view v-if="isChapterEnd" class="chapter-end">
  29. <text>本章结束</text>
  30. <button v-if="!isLastChapter" @click="nextChapter">下一章</button>
  31. <text v-else>已是最新章节</text>
  32. </view>
  33. </scroll-view>
  34. <!-- 底部操作栏 -->
  35. <view class="reader-footer">
  36. <view class="progress">
  37. <text>{{ currentChapterIndex + 1 }}/{{ chapters.length }}</text>
  38. <text>{{ readingProgress }}%</text>
  39. </view>
  40. <view class="actions">
  41. <button class="action-btn" @click="prevChapter" :disabled="isFirstChapter">
  42. <uni-icons type="arrow-up" size="24"></uni-icons>
  43. <text>上一章</text>
  44. </button>
  45. <button class="action-btn" @click="toggleMenu">
  46. <uni-icons type="list" size="24"></uni-icons>
  47. <text>目录</text>
  48. </button>
  49. <button class="action-btn" @click="toggleSettings">
  50. <uni-icons type="gear" size="24"></uni-icons>
  51. <text>设置</text>
  52. </button>
  53. <button class="action-btn" @click="nextChapter" :disabled="isLastChapter">
  54. <uni-icons type="arrow-down" size="24"></uni-icons>
  55. <text>下一章</text>
  56. </button>
  57. </view>
  58. </view>
  59. <!-- 目录抽屉 -->
  60. <uni-drawer ref="drawer" mode="right" :width="300">
  61. <view class="drawer-content">
  62. <text class="drawer-title">目录</text>
  63. <view class="chapter-stats">
  64. <text>共{{ chapters.length }}章</text>
  65. <text>已读{{ readChaptersCount }}章</text>
  66. </view>
  67. <scroll-view scroll-y class="chapter-list">
  68. <view
  69. v-for="(chapter, index) in chapters"
  70. :key="chapter.id"
  71. class="chapter-item"
  72. :class="{
  73. active: chapter.id === chapterDetail.id,
  74. read: isChapterRead(chapter.id),
  75. locked: isChapterLocked(index)
  76. }"
  77. @click="selectChapter(chapter, index)"
  78. >
  79. <text>{{ index + 1 }}. {{ chapter.title }}</text>
  80. <uni-icons v-if="isChapterLocked(index)" type="locked" size="16" color="#999"></uni-icons>
  81. </view>
  82. </scroll-view>
  83. </view>
  84. </uni-drawer>
  85. <!-- 设置面板 -->
  86. <uni-popup ref="settingsPopup" type="bottom">
  87. <view class="settings-panel">
  88. <text class="panel-title">阅读设置</text>
  89. <view class="setting-item">
  90. <text>字体大小</text>
  91. <slider :value="fontSize" min="24" max="40" @change="updateFontSize" />
  92. </view>
  93. <view class="setting-item">
  94. <text>背景颜色</text>
  95. <view class="color-options">
  96. <view
  97. v-for="color in backgroundColors"
  98. :key="color.value"
  99. class="color-option"
  100. :class="{ active: readerStyles.backgroundColor === color.value }"
  101. :style="{ backgroundColor: color.value }"
  102. @click="changeBackgroundColor(color.value)"
  103. ></view>
  104. </view>
  105. </view>
  106. <button class="close-btn" @click="$refs.settingsPopup.close()">关闭</button>
  107. </view>
  108. </uni-popup>
  109. </view>
  110. </template>
  111. <script>
  112. import config from '@/config'
  113. import novelService from '@/services/novelService'
  114. export default {
  115. data() {
  116. return {
  117. novelId: null,
  118. chapterId: null,
  119. novelTitle: '',
  120. chapterDetail: {},
  121. chapterContent: '',
  122. chapters: [],
  123. currentChapterIndex: 0,
  124. // 阅读统计
  125. readChapters: [],
  126. readingProgress: 0,
  127. scrollTop: 0,
  128. isChapterEnd: false,
  129. // 阅读设置
  130. fontSize: 32,
  131. backgroundColors: [
  132. { name: '护眼模式', value: '#f8f2e0' },
  133. { name: '白色', value: '#ffffff' },
  134. { name: '夜间', value: '#2c2c2c' },
  135. { name: '淡蓝', value: '#e8f4f8' }
  136. ],
  137. readerStyles: {
  138. fontSize: '32rpx',
  139. lineHeight: '1.8',
  140. backgroundColor: '#f8f2e0',
  141. color: '#333'
  142. }
  143. }
  144. },
  145. computed: {
  146. // 计算属性
  147. isFirstChapter() {
  148. return this.currentChapterIndex === 0
  149. },
  150. isLastChapter() {
  151. return this.currentChapterIndex === this.chapters.length - 1
  152. },
  153. readChaptersCount() {
  154. return this.readChapters.length
  155. },
  156. // 检查是否达到免费章节限制
  157. reachedFreeLimit() {
  158. const isLoggedIn = this.$store.getters.token
  159. return !isLoggedIn && this.readChaptersCount >= config.freeChapters
  160. }
  161. },
  162. async onLoad(options) {
  163. this.novelId = options.novelId
  164. this.chapterId = options.chapterId || null
  165. // 加载阅读进度
  166. this.loadReadingProgress()
  167. await this.loadNovelData()
  168. // 如果有指定章节ID,加载该章节,否则加载第一章
  169. if (this.chapterId) {
  170. await this.loadChapter(this.chapterId)
  171. } else {
  172. // 尝试从阅读记录中恢复
  173. const lastReadChapter = this.getLastReadChapter()
  174. if (lastReadChapter) {
  175. await this.loadChapter(lastReadChapter.id)
  176. } else {
  177. await this.loadFirstChapter()
  178. }
  179. }
  180. // 监听网络状态
  181. uni.onNetworkStatusChange(this.handleNetworkChange)
  182. },
  183. onUnload() {
  184. // 保存阅读进度
  185. this.saveReadingProgress()
  186. // 移除网络状态监听
  187. uni.offNetworkStatusChange(this.handleNetworkChange)
  188. },
  189. methods: {
  190. // 加载小说数据
  191. async loadNovelData() {
  192. try {
  193. uni.showLoading({ title: '加载中...' })
  194. // 加载小说基本信息 - 修复API路径
  195. const novelRes = await this.$http.get(`/novel/detail/${this.novelId}`)
  196. // 使用统一的响应解析方法
  197. const novelData = this.parseResponse(novelRes)
  198. console.log('小说详情响应:', novelData)
  199. if (novelData.code === 200 && novelData.data) {
  200. this.novelTitle = novelData.data.title
  201. uni.setNavigationBarTitle({ title: this.novelTitle })
  202. }
  203. // 加载章节列表 - 修复API路径
  204. const chapterRes = await this.$http.get(`/chapter/list/${this.novelId}`)
  205. // 使用统一的响应解析方法
  206. const chapterData = this.parseResponse(chapterRes)
  207. console.log('章节列表响应:', chapterData)
  208. if (chapterData.code === 200) {
  209. // 处理不同的响应格式
  210. if (Array.isArray(chapterData.rows)) {
  211. this.chapters = chapterData.rows
  212. } else if (Array.isArray(chapterData.data)) {
  213. this.chapters = chapterData.data
  214. }
  215. // 初始化当前章节索引
  216. if (this.chapterId) {
  217. this.currentChapterIndex = this.chapters.findIndex(ch => ch.id === this.chapterId)
  218. }
  219. }
  220. } catch (error) {
  221. console.error('加载小说数据失败:', error)
  222. uni.showToast({
  223. title: '加载失败',
  224. icon: 'none'
  225. })
  226. } finally {
  227. uni.hideLoading()
  228. }
  229. },
  230. // 加载章节内容
  231. async loadChapter(chapterId) {
  232. // 检查章节是否锁定
  233. const chapterIndex = this.chapters.findIndex(ch => ch.id === chapterId)
  234. if (this.isChapterLocked(chapterIndex)) {
  235. this.showLoginPrompt()
  236. return
  237. }
  238. try {
  239. uni.showLoading({ title: '加载中...' })
  240. const res = await this.$http.get(`/chapter/content/${chapterId}`)
  241. if (res.data) {
  242. this.chapterDetail = res.data
  243. this.chapterContent = this.formatContent(res.data.content)
  244. this.chapterId = chapterId
  245. this.currentChapterIndex = chapterIndex
  246. // 标记为已读
  247. this.markChapterAsRead(chapterId)
  248. // 计算阅读进度
  249. this.calculateReadingProgress()
  250. }
  251. } catch (error) {
  252. console.error('加载章节内容失败:', error)
  253. uni.showToast({
  254. title: '加载失败',
  255. icon: 'none'
  256. })
  257. } finally {
  258. uni.hideLoading()
  259. }
  260. },
  261. // 加载第一章
  262. async loadFirstChapter() {
  263. if (this.chapters.length > 0) {
  264. await this.loadChapter(this.chapters[0].id)
  265. }
  266. },
  267. // 格式化内容
  268. formatContent(content) {
  269. if (!content) return ''
  270. // 处理HTML标签和特殊字符
  271. return content
  272. .replace(/<br\s*\/?>/gi, '\n')
  273. .replace(/&nbsp;/g, ' ')
  274. .replace(/<p>/g, '\n\n')
  275. .replace(/<\/p>/g, '')
  276. .replace(/<[^>]+>/g, '')
  277. .replace(/\n{3,}/g, '\n\n')
  278. .trim()
  279. },
  280. // 标记章节为已读
  281. markChapterAsRead(chapterId) {
  282. if (!this.readChapters.includes(chapterId)) {
  283. this.readChapters.push(chapterId)
  284. this.saveReadingProgress()
  285. }
  286. },
  287. // 检查章节是否已读
  288. isChapterRead(chapterId) {
  289. return this.readChapters.includes(chapterId)
  290. },
  291. // 检查章节是否锁定
  292. isChapterLocked(chapterIndex) {
  293. // 已登录用户可以阅读所有章节
  294. if (this.$store.getters.token) return false
  295. // 未登录用户只能阅读前N章
  296. return chapterIndex >= config.freeChapters
  297. },
  298. // 加载阅读进度
  299. loadReadingProgress() {
  300. const progress = uni.getStorageSync(`readingProgress_${this.novelId}`)
  301. if (progress) {
  302. this.readChapters = progress.readChapters || []
  303. this.readerStyles = progress.readerStyles || this.readerStyles
  304. this.fontSize = progress.fontSize || 32
  305. }
  306. },
  307. // 保存阅读进度
  308. saveReadingProgress() {
  309. const progress = {
  310. novelId: this.novelId,
  311. chapterId: this.chapterId,
  312. readChapters: this.readChapters,
  313. readerStyles: this.readerStyles,
  314. fontSize: this.fontSize,
  315. timestamp: Date.now()
  316. }
  317. uni.setStorageSync(`readingProgress_${this.novelId}`, progress)
  318. // 同步到服务器(如果已登录)
  319. if (this.$store.getters.token) {
  320. this.$http.post('/reading/progress', progress)
  321. }
  322. },
  323. // 获取最后阅读的章节
  324. getLastReadChapter() {
  325. if (this.chapterId) {
  326. return this.chapters.find(ch => ch.id === this.chapterId)
  327. }
  328. const progress = uni.getStorageSync(`readingProgress_${this.novelId}`)
  329. if (progress && progress.chapterId) {
  330. return this.chapters.find(ch => ch.id === progress.chapterId)
  331. }
  332. return null
  333. },
  334. // 计算阅读进度
  335. calculateReadingProgress() {
  336. if (this.chapters.length === 0) return 0
  337. this.readingProgress = Math.round((this.readChaptersCount / this.chapters.length) * 100)
  338. },
  339. // 显示登录提示
  340. showLoginPrompt() {
  341. this.$refs.loginPopup.open()
  342. },
  343. // 关闭登录对话框
  344. closeLoginDialog() {
  345. this.$refs.loginPopup.close()
  346. },
  347. // 跳转到登录页面
  348. goToLogin() {
  349. uni.navigateTo({
  350. url: '/pages/login?redirect=' + encodeURIComponent(this.$route.fullPath)
  351. })
  352. },
  353. // 上一章
  354. prevChapter() {
  355. if (this.currentChapterIndex > 0) {
  356. const prevChapter = this.chapters[this.currentChapterIndex - 1]
  357. this.loadChapter(prevChapter.id)
  358. }
  359. },
  360. // 下一章
  361. nextChapter() {
  362. if (this.currentChapterIndex < this.chapters.length - 1) {
  363. const nextChapter = this.chapters[this.currentChapterIndex + 1]
  364. // 检查下一章是否锁定
  365. if (this.isChapterLocked(this.currentChapterIndex + 1)) {
  366. this.showLoginPrompt()
  367. return
  368. }
  369. this.loadChapter(nextChapter.id)
  370. }
  371. },
  372. // 选择章节
  373. selectChapter(chapter, index) {
  374. // 检查章节是否锁定
  375. if (this.isChapterLocked(index)) {
  376. this.showLoginPrompt()
  377. return
  378. }
  379. this.loadChapter(chapter.id)
  380. this.$refs.drawer.close()
  381. },
  382. // 显示更多操作
  383. showMoreActions() {
  384. uni.showActionSheet({
  385. itemList: ['添加到书架', '分享', '举报', '设置'],
  386. success: (res) => {
  387. switch (res.tapIndex) {
  388. case 0:
  389. this.addToBookshelf()
  390. break
  391. case 1:
  392. this.shareNovel()
  393. break
  394. case 2:
  395. this.reportNovel()
  396. break
  397. case 3:
  398. this.toggleSettings()
  399. break
  400. }
  401. }
  402. })
  403. },
  404. // 添加到书架
  405. addToBookshelf() {
  406. if (!this.$store.getters.token) {
  407. uni.showToast({
  408. title: '请先登录',
  409. icon: 'none'
  410. })
  411. return
  412. }
  413. this.$http.post('/bookshelf/add', {
  414. novelId: this.novelId
  415. }).then(res => {
  416. uni.showToast({
  417. title: '已添加到书架',
  418. icon: 'success'
  419. })
  420. }).catch(error => {
  421. uni.showToast({
  422. title: '添加失败',
  423. icon: 'none'
  424. })
  425. })
  426. },
  427. // 分享小说
  428. shareNovel() {
  429. uni.share({
  430. title: this.novelTitle,
  431. path: `/pages/novel/reader?novelId=${this.novelId}`,
  432. success: () => {
  433. uni.showToast({
  434. title: '分享成功',
  435. icon: 'success'
  436. })
  437. }
  438. })
  439. },
  440. // 举报小说
  441. reportNovel() {
  442. uni.navigateTo({
  443. url: `/pages/report?novelId=${this.novelId}`
  444. })
  445. },
  446. // 打开目录
  447. toggleMenu() {
  448. this.$refs.drawer.open()
  449. },
  450. // 打开设置
  451. toggleSettings() {
  452. this.$refs.settingsPopup.open()
  453. },
  454. // 更新字体大小
  455. updateFontSize(e) {
  456. this.fontSize = e.detail.value
  457. this.readerStyles.fontSize = `${this.fontSize}rpx`
  458. this.saveReadingProgress()
  459. },
  460. // 更改背景颜色
  461. changeBackgroundColor(color) {
  462. this.readerStyles.backgroundColor = color
  463. // 根据背景色调整文字颜色
  464. this.readerStyles.color = color === '#2c2c2c' ? '#f0f0f0' : '#333'
  465. this.saveReadingProgress()
  466. },
  467. // 返回上一页
  468. goBack() {
  469. uni.navigateBack()
  470. },
  471. // 滚动事件
  472. onScroll(e) {
  473. this.scrollTop = e.detail.scrollTop
  474. // 检测是否滚动到章节末尾
  475. const scrollHeight = e.detail.scrollHeight
  476. const scrollTop = e.detail.scrollTop
  477. const clientHeight = e.detail.clientHeight
  478. this.isChapterEnd = scrollHeight - scrollTop - clientHeight < 50
  479. },
  480. // 处理网络状态变化
  481. handleNetworkChange(res) {
  482. if (!res.isConnected) {
  483. uni.showToast({
  484. title: '网络已断开',
  485. icon: 'none'
  486. })
  487. }
  488. }
  489. }
  490. }
  491. </script>
  492. <style scoped>
  493. .reader-container {
  494. position: relative;
  495. height: 100vh;
  496. padding: 20rpx;
  497. box-sizing: border-box;
  498. transition: all 0.3s ease;
  499. }
  500. .reader-header {
  501. position: absolute;
  502. top: 0;
  503. left: 0;
  504. right: 0;
  505. display: flex;
  506. justify-content: space-between;
  507. align-items: center;
  508. padding: 20rpx 30rpx;
  509. background: rgba(0, 0, 0, 0.7);
  510. color: white;
  511. z-index: 100;
  512. }
  513. .novel-title {
  514. font-size: 32rpx;
  515. max-width: 60%;
  516. overflow: hidden;
  517. text-overflow: ellipsis;
  518. white-space: nowrap;
  519. }
  520. .reader-content {
  521. height: calc(100vh - 200rpx);
  522. padding-top: 80rpx;
  523. padding-bottom: 120rpx;
  524. }
  525. .chapter-title {
  526. font-size: 40rpx;
  527. font-weight: bold;
  528. text-align: center;
  529. margin-bottom: 40rpx;
  530. color: #2a5caa;
  531. }
  532. .content-text {
  533. font-size: 32rpx;
  534. line-height: 1.8;
  535. }
  536. .chapter-end {
  537. text-align: center;
  538. margin-top: 40rpx;
  539. padding: 20rpx;
  540. border-top: 1rpx solid #eee;
  541. }
  542. .reader-footer {
  543. position: absolute;
  544. bottom: 0;
  545. left: 0;
  546. right: 0;
  547. background: rgba(255, 255, 255, 0.9);
  548. padding: 20rpx;
  549. border-top: 1rpx solid #eee;
  550. }
  551. .progress {
  552. display: flex;
  553. justify-content: space-between;
  554. font-size: 28rpx;
  555. color: #666;
  556. margin-bottom: 20rpx;
  557. }
  558. .actions {
  559. display: flex;
  560. justify-content: space-between;
  561. }
  562. .action-btn {
  563. display: flex;
  564. flex-direction: column;
  565. align-items: center;
  566. background: none;
  567. border: none;
  568. font-size: 24rpx;
  569. padding: 10rpx;
  570. }
  571. .action-btn:disabled {
  572. opacity: 0.5;
  573. }
  574. .drawer-content {
  575. padding: 30rpx;
  576. }
  577. .drawer-title {
  578. font-size: 36rpx;
  579. font-weight: bold;
  580. display: block;
  581. margin-bottom: 20rpx;
  582. border-bottom: 1rpx solid #eee;
  583. padding-bottom: 20rpx;
  584. }
  585. .chapter-stats {
  586. display: flex;
  587. justify-content: space-between;
  588. margin-bottom: 20rpx;
  589. font-size: 24rpx;
  590. color: #666;
  591. }
  592. .chapter-list {
  593. height: calc(100vh - 200rpx);
  594. }
  595. .chapter-item {
  596. display: flex;
  597. justify-content: space-between;
  598. align-items: center;
  599. padding: 20rpx 10rpx;
  600. border-bottom: 1rpx solid #f0f0f0;
  601. font-size: 28rpx;
  602. }
  603. .chapter-item.active {
  604. color: #2a5caa;
  605. font-weight: bold;
  606. }
  607. .chapter-item.read {
  608. color: #666;
  609. }
  610. .chapter-item.locked {
  611. color: #999;
  612. }
  613. .settings-panel {
  614. padding: 30rpx;
  615. background: white;
  616. border-radius: 20rpx 20rpx 0 0;
  617. }
  618. .panel-title {
  619. font-size: 36rpx;
  620. font-weight: bold;
  621. display: block;
  622. margin-bottom: 30rpx;
  623. text-align: center;
  624. }
  625. .setting-item {
  626. margin-bottom: 30rpx;
  627. }
  628. .setting-item text {
  629. display: block;
  630. margin-bottom: 15rpx;
  631. font-size: 28rpx;
  632. }
  633. .color-options {
  634. display: flex;
  635. justify-content: space-between;
  636. }
  637. .color-option {
  638. width: 60rpx;
  639. height: 60rpx;
  640. border-radius: 50%;
  641. border: 2rpx solid #eee;
  642. }
  643. .color-option.active {
  644. border-color: #2a5caa;
  645. }
  646. .close-btn {
  647. margin-top: 30rpx;
  648. background: #f0f0f0;
  649. color: #333;
  650. }
  651. </style>