更新:增加“我的书单”功能

This commit is contained in:
2025-11-10 09:16:28 +08:00
parent 33f861fa14
commit 577e782cd8
25 changed files with 4515 additions and 37 deletions

207
pages/book/README.md Normal file
View File

@@ -0,0 +1,207 @@
# 我的书单功能模块
## 概述
本模块是从nuttyreading项目迁移并升级到Vue3+TypeScript+Pinia+TailwindCSS+WotUI+i18n技术栈的"我的书单"功能。
## 功能列表
### 1. 书单列表 (`pages/book/index.vue`)
- ✅ 显示用户已购买的所有书籍
- ✅ 分页加载
- ✅ 空状态处理
- ✅ 支持跳转到详情、阅读器、听书、书评页面
- ✅ iOS平台自动隐藏书评按钮
### 2. 书籍详情 (`pages/book/detail.vue`)
- ✅ 显示书籍封面、标题、作者、简介
- ✅ 显示阅读数、听书数、购买数统计
- ✅ 显示前2条书评非iOS
- ✅ 显示相关推荐书籍
- ✅ 根据购买状态显示不同操作按钮
- ✅ 购买弹窗
### 3. 书评系统 (`pages/book/review.vue`)
- ✅ 评论列表展示
- ✅ 发表评论(富文本编辑器)
- ✅ 点赞/取消点赞
- ✅ 回复评论
- ✅ 删除评论
- ✅ 分页加载更多
- ✅ Emoji支持待完善
### 4. 阅读器 (`pages/book/reader.vue`)
- ✅ 上下滚动模式
- ✅ 左右翻页模式
- ✅ 字体大小调节8个级别
- ✅ 主题切换5种主题
- ✅ 章节目录
- ✅ 阅读进度保存和恢复
- ✅ 图片内容显示
- ✅ 试读限制提示
### 5. 听书功能
#### 章节列表 (`pages/book/listen/index.vue`)
- ✅ 显示书籍信息
- ✅ 章节列表
- ✅ 章节锁定状态
- ✅ 音频文件检查
#### 音频播放器 (`pages/book/listen/player.vue`)
- ✅ 音频播放/暂停
- ✅ 进度条控制
- ✅ 快进/快退15秒
- ✅ 上一章/下一章
- ✅ 播放速度调节0.5x - 2x
- ✅ 自动播放下一章
- ✅ 封面旋转动画
## 技术栈
- **框架**: Vue3 Composition API
- **语言**: TypeScript
- **状态管理**: Pinia
- **UI组件**: WotUI
- **样式**: SCSS + TailwindCSS
- **国际化**: vue-i18n
## 文件结构
```
pages/book/
├── index.vue # 书单列表
├── detail.vue # 书籍详情
├── review.vue # 书评页面
├── reader.vue # 阅读器
└── listen/
├── index.vue # 听书章节列表
└── player.vue # 音频播放器
components/book/
├── CustomNavbar.vue # 自定义导航栏
├── BookCard.vue # 书籍卡片
└── CommentList.vue # 评论列表
api/modules/
└── book.ts # 书籍API
stores/
└── book.ts # 书籍状态管理
types/
└── book.d.ts # 类型定义
```
## API接口
所有API接口保持与原项目完全一致
- `bookAbroad/home/getbooks` - 获取我的书单
- `bookAbroad/home/getBookInfo` - 获取书籍详情
- `bookAbroad/home/getBookReadCount` - 获取统计数据
- `bookAbroad/home/getRecommendBook` - 获取推荐书籍
- `bookAbroad/getBookAbroadCommentTree` - 获取评论列表
- `bookAbroad/insertBookAbroadComment` - 发表评论
- `bookAbroad/insertBookAbroadCommentLike` - 点赞
- `bookAbroad/delBookAbroadCommentLike` - 取消点赞
- `bookAbroad/delBookAbroadComment` - 删除评论
- `bookAbroad/home/getBookChapter` - 获取章节列表
- `bookAbroad/home/getBookChapterContent` - 获取章节内容
- `bookAbroad/home/getBookReadRate` - 获取阅读进度
- `bookAbroad/home/insertBookReadRate` - 保存阅读进度
## 国际化
支持中文和英文两种语言所有文本通过i18n配置管理。
### 翻译键
- `book.*` - 书单相关
- `details.*` - 详情相关
- `listen.*` - 听书相关
- `common.*` - 通用文本
## 平台适配
### iOS特殊处理
- 书评功能在iOS平台自动隐藏
- 使用条件编译 `#ifdef APP-PLUS` 判断平台
### 刘海屏适配
- 所有页面自动适配状态栏高度
- 使用 `uni.getSystemInfoSync().safeArea` 获取安全区域
## 使用说明
### 1. 从书单列表进入
```typescript
uni.navigateTo({
url: '/pages/book/index'
})
```
### 2. 直接进入书籍详情
```typescript
uni.navigateTo({
url: `/pages/book/detail?id=${bookId}`
})
```
### 3. 进入阅读器
```typescript
// 已购买
uni.navigateTo({
url: `/pages/book/reader?isBuy=0&bookId=${bookId}`
})
// 试读
uni.navigateTo({
url: `/pages/book/reader?isBuy=1&bookId=${bookId}&count=${freeChapterCount}`
})
```
### 4. 进入听书
```typescript
uni.navigateTo({
url: `/pages/book/listen/index?bookId=${bookId}`
})
```
## 注意事项
1. **不要修改API接口**:所有接口地址和参数必须与原项目保持一致
2. **UI组件使用WotUI**不要使用uView或uni-ui组件
3. **国际化文本**所有文本必须通过i18n配置不能硬编码
4. **iOS平台**:注意书评功能的隐藏处理
5. **类型安全**充分利用TypeScript类型检查
## 待优化项
1. Emoji选择器组件需要集成完整的Emoji库
2. 阅读器可以添加更多主题
3. 音频播放器可以添加播放列表功能
4. 可以添加书签功能
5. 可以添加笔记功能
## 测试清单
- [ ] 书单列表加载和分页
- [ ] 书籍详情所有信息显示
- [ ] 书评发表、点赞、删除
- [ ] 阅读器两种模式切换
- [ ] 阅读器字体和主题设置
- [ ] 阅读进度保存和恢复
- [ ] 听书播放控制
- [ ] 听书速度调节
- [ ] iOS平台书评隐藏
- [ ] 试读/试听限制
- [ ] 国际化文本切换
## 更新日志
### v1.0.0 (2024-01-XX)
- ✅ 完成从Vue2到Vue3的迁移
- ✅ 完成TypeScript类型定义
- ✅ 完成Pinia状态管理
- ✅ 完成WotUI组件替换
- ✅ 完成国际化配置
- ✅ 完成所有功能页面

620
pages/book/detail.vue Normal file
View File

@@ -0,0 +1,620 @@
<template>
<view class="book-detail-page">
<!-- 导航栏 -->
<nav-bar :title="$t('bookDetails.title')"></nav-bar>
<scroll-view
scroll-y
class="detail-scroll"
:style="{ height: scrollHeight + 'px' }"
>
<!-- 书籍信息 -->
<view class="book-info">
<image :src="bookInfo.images" class="cover" mode="aspectFill" />
<text class="title">{{ bookInfo.name }}</text>
<text class="author">{{ $t('bookDetails.authorName') }}{{ bookInfo.author?.authorName }}</text>
<!-- 统计信息 -->
<view class="stats">
<view class="stat-item">
<image src="@/static/icon/icon_look_c.png" mode="aspectFit" />
<text>{{ readCount }}{{ $t('home.readingCount') }}</text>
</view>
<view class="divider" />
<view class="stat-item">
<image src="@/static/icon/icon_listen_c.png" mode="aspectFit" />
<text>{{ listenCount }}{{ $t('home.listenCount') }}</text>
</view>
<view class="divider" />
<view class="stat-item">
<image src="@/static/icon/icon_bug_c.png" mode="aspectFit" />
<text>{{ buyCount }}{{ $t('home.purchased') }}</text>
</view>
</view>
</view>
<!-- 简介 -->
<view class="introduction">
<text class="section-title">{{ $t('bookDetails.introduction') }}</text>
<text class="content">{{ bookInfo.author?.introduction }}</text>
</view>
<!-- 书评列表 (非iOS) -->
<view v-if="!isIOS" class="comments-section">
<view class="section-header">
<text class="section-title">{{ $t('bookDetails.message') }}</text>
<text v-if="commentList.length > 0" class="more-link" @click="goToReview">
{{ $t('bookDetails.more') }}
<wd-icon name="arrow-right" size="14px" />
</text>
</view>
<view class="comment-wrapper">
<CommentList
v-if="commentList.length > 0"
:comments="commentList.slice(0, 2)"
/>
<text v-else class="empty-text">{{ nullText }}</text>
</view>
</view>
<!-- 相关推荐 -->
<view class="related-books">
<text class="section-title">{{ $t('bookDetails.relatedBooks') }}</text>
<scroll-view v-if="relatedBooks.length > 0" scroll-x class="book-scroll">
<view class="book-list">
<view
class="book-item"
v-for="item in relatedBooks"
:key="item.id"
@click="goToDetail(item.id)"
>
<image :src="item.images" mode="aspectFill" />
<text>{{ item.name }}</text>
</view>
</view>
</scroll-view>
<text v-else class="empty-text">{{ nullBookText }}</text>
</view>
</scroll-view>
<!-- 底部操作栏 -->
<view class="action-bar">
<template v-if="bookInfo.isBuy">
<view class="action-btn read" @click="goToReader">
<text>{{ $t('bookDetails.startReading') }}</text>
</view>
<view class="action-btn purchased">
<wd-button disabled custom-class="purchased-btn">
{{ $t('bookDetails.buttonText2') }}
</wd-button>
</view>
<view class="action-btn listen" @click="goToListen">
<text>{{ $t('bookDetails.startListening') }}</text>
</view>
</template>
<template v-else>
<view v-if="bookInfo.freeChapterCount > 0" class="action-btn read" @click="goToReader">
<text>{{ $t('bookDetails.tryRead') }}</text>
</view>
<view class="action-btn buy" :class="{ 'buy-full': bookInfo.freeChapterCount === 0 }">
<wd-button type="primary" @click="showPurchasePopup">
{{ $t('bookDetails.buttonText1') }}
</wd-button>
</view>
<view v-if="bookInfo.freeChapterCount > 0" class="action-btn listen" @click="goToListen">
<text>{{ $t('bookDetails.tryListen') }}</text>
</view>
</template>
</view>
<!-- 购买弹窗 -->
<wd-popup v-model="purchaseVisible" position="bottom">
<view class="purchase-popup">
<view class="book-info-mini">
<image :src="bookInfo.images" mode="aspectFill" />
<view class="info">
<text class="name">{{ bookInfo.name }}</text>
<text v-if="bookInfo.priceData" class="price">
$ {{ bookInfo.priceData.dictValue }} NZD
</text>
</view>
</view>
<view class="spec-section">
<text class="spec-title">{{ $t('bookDetails.list') }}</text>
<view class="spec-item active">
<text>{{ bookInfo.name }}</text>
<text v-if="bookInfo.priceData" class="spec-price">
${{ bookInfo.priceData.dictValue }} NZD
</text>
</view>
</view>
<wd-button type="primary" custom-class="buy-btn" @click="handlePurchase">
{{ $t('bookDetails.buy') }}
</wd-button>
</view>
</wd-popup>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import { bookApi } from '@/api/modules/book'
import type { IBookDetail, IBook, IComment } from '@/types/book'
import CustomNavbar from '@/components/book/CustomNavbar.vue'
import CommentList from '@/components/book/CommentList.vue'
const { t } = useI18n()
// 路由参数
const bookId = ref(0)
const pageFrom = ref('')
// 数据状态
const bookInfo = ref<IBookDetail>({
id: 0,
name: '',
images: '',
author: { authorName: '', introduction: '' },
isBuy: false,
freeChapterCount: 0
})
const readCount = ref(0)
const listenCount = ref(0)
const buyCount = ref(0)
const commentList = ref<IComment[]>([])
const relatedBooks = ref<IBook[]>([])
const nullText = ref('')
const nullBookText = ref('')
const purchaseVisible = ref(false)
const scrollHeight = ref(0)
// 计算属性
const isIOS = computed(() => {
// #ifdef APP-PLUS
return uni.getSystemInfoSync().platform === 'ios'
// #endif
return false
})
// 生命周期
onLoad((options: any) => {
if (options.id) {
bookId.value = Number(options.id)
}
if (options.page) {
pageFrom.value = options.page
}
initScrollHeight()
loadBookCount()
loadRecommendBooks()
})
onShow(() => {
loadBookInfo()
if (!isIOS.value) {
loadComments()
}
})
onMounted(() => {
initScrollHeight()
})
// 初始化滚动区域高度
function initScrollHeight() {
const systemInfo = uni.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || 0
let navBarHeight = 44
if (systemInfo.model.includes('iPhone')) {
const modelNumber = parseInt(systemInfo.model.match(/\d+/)?.[0] || '0')
if (modelNumber >= 11) {
navBarHeight = 48
}
}
const totalNavHeight = statusBarHeight + navBarHeight
const actionBarHeight = 110 // rpx转px约55
scrollHeight.value = systemInfo.windowHeight - totalNavHeight - actionBarHeight / 2
}
// 加载书籍详情
async function loadBookInfo() {
try {
uni.showLoading({ title: t('global.loading') })
const res = await bookApi.getBookInfo(bookId.value)
uni.hideLoading()
if (res.bookInfo) {
bookInfo.value = res.bookInfo
}
} catch (error) {
uni.hideLoading()
console.error('Failed to load book info:', error)
}
}
// 加载统计数据
async function loadBookCount() {
try {
const res = await bookApi.getBookReadCount(bookId.value)
if (res.code === 0) {
readCount.value = res.readCount || 0
listenCount.value = res.listenCount || 0
buyCount.value = res.buyCount || 0
}
} catch (error) {
console.error('Failed to load book count:', error)
}
}
// 加载评论
async function loadComments() {
try {
const res = await bookApi.getBookComments(bookId.value, 1, 10)
if (res.commentsTree && res.commentsTree.length > 0) {
commentList.value = res.commentsTree
} else {
nullText.value = t('common.data_null')
}
} catch (error) {
nullText.value = t('common.data_null')
console.error('Failed to load comments:', error)
}
}
// 加载推荐书籍
async function loadRecommendBooks() {
try {
const res = await bookApi.getRecommendBook(bookId.value)
if (res.bookList && res.bookList.length > 0) {
relatedBooks.value = res.bookList
} else {
nullBookText.value = t('common.data_null')
}
} catch (error) {
nullBookText.value = t('common.data_null')
console.error('Failed to load recommend books:', error)
}
}
// 显示购买弹窗
function showPurchasePopup() {
purchaseVisible.value = true
}
// 处理购买
function handlePurchase() {
uni.navigateTo({
url: `/pages/book/order?id=${bookId.value}`
})
}
// 页面跳转
function goToReader() {
const isBuy = bookInfo.value.isBuy ? 0 : 1
const count = bookInfo.value.freeChapterCount || 0
uni.navigateTo({
url: `/pages/book/reader?isBuy=${isBuy}&bookId=${bookId.value}&count=${count}`
})
}
function goToListen() {
uni.navigateTo({
url: `/pages/book/listen/index?bookId=${bookId.value}`
})
}
function goToReview() {
uni.navigateTo({
url: `/pages/book/review?bookId=${bookId.value}&page=0`
})
}
function goToDetail(id: number) {
uni.navigateTo({
url: `/pages/book/detail?id=${id}`
})
}
function handleBack() {
if (pageFrom.value === 'order') {
uni.switchTab({
url: '/pages/index/index'
})
} else {
uni.navigateBack({ delta: 1 })
}
}
</script>
<style lang="scss" scoped>
.book-detail-page {
background: #fff;
min-height: 100vh;
.book-info {
text-align: center;
padding: 40rpx 30rpx;
.cover {
width: 300rpx;
height: 400rpx;
border-radius: 10rpx;
margin: 0 auto 20rpx;
}
.title {
display: block;
font-size: 42rpx;
font-weight: bold;
line-height: 50rpx;
padding: 20rpx 30rpx;
color: #333;
}
.author {
display: block;
font-size: 32rpx;
line-height: 34rpx;
padding: 10rpx 0;
color: #666;
}
.stats {
margin-top: 40rpx;
display: flex;
align-items: center;
justify-content: space-around;
.stat-item {
flex: 1;
text-align: center;
image {
width: 40rpx;
height: 40rpx;
display: block;
margin: 0 auto 10rpx;
}
text {
display: block;
font-size: 28rpx;
color: #999;
}
}
.divider {
width: 1rpx;
height: 45rpx;
background: #999;
}
}
}
.introduction {
padding: 40rpx 30rpx 0;
.section-title {
display: block;
font-size: 34rpx;
line-height: 50rpx;
padding-bottom: 10rpx;
color: #333;
font-weight: 500;
}
.content {
font-size: 30rpx;
line-height: 46rpx;
color: #999;
}
}
.comments-section {
padding: 40rpx 30rpx 0;
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
.section-title {
font-size: 34rpx;
line-height: 50rpx;
color: #333;
font-weight: 500;
}
.more-link {
color: #999;
font-size: 28rpx;
display: flex;
align-items: center;
}
}
.comment-wrapper {
background: #f7faf9;
border-radius: 10rpx;
padding: 20rpx;
}
}
.related-books {
padding: 40rpx 30rpx 40rpx;
.section-title {
display: block;
font-size: 34rpx;
line-height: 50rpx;
margin-bottom: 20rpx;
color: #333;
font-weight: 500;
}
.book-scroll {
white-space: nowrap;
.book-list {
display: inline-flex;
gap: 25rpx;
.book-item {
display: inline-block;
image {
width: 150rpx;
height: 190rpx;
border-radius: 10rpx;
display: block;
}
text {
display: block;
width: 150rpx;
text-align: center;
font-size: 28rpx;
color: #333;
line-height: 40rpx;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-top: 10rpx;
}
}
}
}
}
.empty-text {
display: block;
text-align: center;
padding: 50rpx 0;
font-size: 28rpx;
color: #999;
}
.bottom-placeholder {
height: 110rpx;
}
.action-bar {
width: 100%;
height: 110rpx;
background: #f7faf9;
position: fixed;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: space-around;
padding: 0 30rpx;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
.action-btn {
&.read, &.listen {
flex: 1;
text-align: center;
text {
font-size: 30rpx;
}
}
&.read text {
color: #6bba6b;
}
&.listen text {
color: #f7cb5e;
}
&.purchased, &.buy {
flex: 1;
padding: 0 20rpx;
}
&.buy-full {
flex: 1;
padding: 0 30rpx;
}
}
}
.purchase-popup {
padding: 60rpx 30rpx 40rpx;
.book-info-mini {
display: flex;
align-items: center;
margin-bottom: 40rpx;
image {
width: 120rpx;
height: 160rpx;
border-radius: 10rpx;
}
.info {
flex: 1;
padding-left: 40rpx;
.name {
display: block;
font-size: 30rpx;
line-height: 38rpx;
color: #333;
max-height: 76rpx;
overflow: hidden;
}
.price {
display: block;
padding-top: 20rpx;
font-size: 36rpx;
color: #ff4703;
font-weight: bold;
}
}
}
.spec-section {
margin-bottom: 40rpx;
.spec-title {
font-size: 30rpx;
color: #333;
display: block;
margin-bottom: 20rpx;
}
.spec-item {
border: 1rpx solid #ddd;
padding: 20rpx;
border-radius: 10rpx;
display: flex;
align-items: center;
justify-content: space-between;
&.active {
border-color: #54a966;
text {
color: #54a966;
}
}
text {
font-size: 26rpx;
line-height: 34rpx;
color: #333;
}
}
}
}
}
</style>

24
pages/book/index.vue Normal file
View File

@@ -0,0 +1,24 @@
<template>
<view class="container">
<view class="title bg-[red] text-center text-[#fff]">这是一个等待开发的图书首页</view>
<view class="description bg-[red]">内容是在线图书</view>
</view>
</template>
<script setup lang="ts">
</script>
<style>
.title {
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
}
.description {
font-size: 14px;
opacity: 0.6;
margin-bottom: 15px;
}
</style>

295
pages/book/listen/index.vue Normal file
View File

@@ -0,0 +1,295 @@
<template>
<view class="listen-page">
<!-- 导航栏 -->
<nav-bar :title="$t('listen.title')"></nav-bar>
<scroll-view
scroll-y
class="listen-scroll"
:style="{ height: scrollHeight + 'px' }"
>
<!-- 书籍信息 -->
<view class="book-info">
<image :src="bookInfo.images" class="cover" mode="aspectFill" />
<view class="info">
<text class="title">{{ bookInfo.name }}</text>
<text class="author">{{ $t('bookDetails.authorName') }}{{ bookInfo.author?.authorName }}</text>
</view>
<wd-button
v-if="!bookInfo.isBuy"
type="primary"
size="small"
@click="goToPurchase"
>
{{ $t('bookDetails.buy') }}
</wd-button>
</view>
<view class="divider-line" />
<!-- 章节列表 -->
<view class="chapter-section">
<text class="section-title">{{ $t('book.zjContents') }}</text>
<view v-if="chapterList.length > 0" class="chapter-list">
<view
v-for="(chapter, index) in chapterList"
:key="chapter.id"
class="chapter-item"
:class="{ active: index === activeIndex }"
@click="playChapter(chapter, index)"
>
<text class="chapter-text" :class="{ locked: isLocked(index) }">
{{ chapter.chapter }}{{ chapter.content ? ' - ' + chapter.content : '' }}
</text>
<wd-icon v-if="isLocked(index)" name="lock" size="20px" />
</view>
</view>
<text v-else class="empty-text">{{ nullText }}</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import { bookApi } from '@/api/modules/book'
import type { IBookDetail, IChapter } from '@/types/book'
import CustomNavbar from '@/components/book/CustomNavbar.vue'
const { t } = useI18n()
// 路由参数
const bookId = ref(0)
const fromIndex = ref(-1)
// 数据状态
const bookInfo = ref<IBookDetail>({
id: 0,
name: '',
images: '',
author: { authorName: '', introduction: '' },
isBuy: false,
freeChapterCount: 0
})
const chapterList = ref<IChapter[]>([])
const activeIndex = ref(-1)
const nullText = ref('')
const scrollHeight = ref(0)
// 计算属性
const isLocked = computed(() => (index: number) => {
return !bookInfo.value.isBuy && index + 1 > bookInfo.value.freeChapterCount
})
// 生命周期
onLoad((options: any) => {
if (options.bookId) {
bookId.value = Number(options.bookId)
}
if (options.index !== undefined) {
fromIndex.value = Number(options.index)
activeIndex.value = fromIndex.value
}
initScrollHeight()
loadBookInfo()
loadChapterList()
})
// 初始化滚动区域高度
function initScrollHeight() {
const systemInfo = uni.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || 0
let navBarHeight = 44
if (systemInfo.model.includes('iPhone')) {
const modelNumber = parseInt(systemInfo.model.match(/\d+/)?.[0] || '0')
if (modelNumber >= 11) {
navBarHeight = 48
}
}
const totalNavHeight = statusBarHeight + navBarHeight
scrollHeight.value = systemInfo.windowHeight - totalNavHeight
}
// 加载书籍信息
async function loadBookInfo() {
try {
const res = await bookApi.getBookInfo(bookId.value)
if (res.bookInfo) {
bookInfo.value = res.bookInfo
}
} catch (error) {
console.error('Failed to load book info:', error)
}
}
// 加载章节列表
async function loadChapterList() {
try {
uni.showLoading({ title: t('global.loading') })
const res = await bookApi.getBookChapter({
bookId: bookId.value
})
uni.hideLoading()
if (res.chapterList && res.chapterList.length > 0) {
chapterList.value = res.chapterList
} else {
nullText.value = t('common.data_null')
}
} catch (error) {
uni.hideLoading()
nullText.value = t('common.data_null')
console.error('Failed to load chapter list:', error)
}
}
// 播放章节
function playChapter(chapter: IChapter, index: number) {
// 检查是否锁定
if (isLocked.value(index)) {
uni.showToast({
title: t('book.afterPurchase'),
icon: 'none'
})
return
}
// 检查是否有音频
if (!chapter.voices) {
uni.showToast({
title: t('book.voices_null'),
icon: 'none'
})
return
}
// 更新选中索引
activeIndex.value = index
// 跳转到播放器页面
const isBuy = bookInfo.value.isBuy ? 0 : 1
uni.navigateTo({
url: `/pages/book/listen/player?bookId=${bookId.value}&isBuy=${isBuy}&count=${bookInfo.value.freeChapterCount}&index=${index}`
})
}
// 去购买
function goToPurchase() {
uni.navigateTo({
url: `/pages/book/order?id=${bookId.value}`
})
}
</script>
<style lang="scss" scoped>
.listen-page {
background: #f7faf9;
min-height: 100vh;
.listen-scroll {
.book-info {
margin: 20rpx;
padding: 30rpx;
background: #fff;
border-radius: 15rpx;
display: flex;
align-items: center;
position: relative;
.cover {
width: 180rpx;
height: 240rpx;
border-radius: 10rpx;
flex-shrink: 0;
}
.info {
flex: 1;
padding: 0 20rpx;
.title {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #333;
line-height: 44rpx;
max-height: 88rpx;
overflow: hidden;
}
.author {
display: block;
font-size: 30rpx;
padding-top: 15rpx;
color: #666;
}
}
}
.divider-line {
height: 20rpx;
background: #f7faf9;
}
.chapter-section {
background: #fff;
padding: 30rpx;
min-height: 400rpx;
.section-title {
display: block;
font-size: 34rpx;
line-height: 50rpx;
margin-bottom: 20rpx;
color: #333;
font-weight: 500;
}
.chapter-list {
.chapter-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18rpx 0;
border-bottom: 1rpx solid #dcdfe6;
&.active .chapter-text {
color: #54a966;
}
.chapter-text {
flex: 1;
font-size: 28rpx;
line-height: 50rpx;
color: #333;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
&.locked {
color: #999;
}
}
&:last-child {
border-bottom: none;
}
}
}
.empty-text {
display: block;
text-align: center;
padding: 100rpx 0;
font-size: 28rpx;
color: #999;
}
}
}
}
</style>

View File

@@ -0,0 +1,614 @@
<template>
<view class="audio-player-page">
<!-- 导航栏 -->
<nav-bar :title="currentChapter.chapter"></nav-bar>
<view class="player-content" :style="{ height: contentHeight + 'px' }">
<!-- 封面 -->
<view class="cover-section">
<image
:src="bookInfo.images"
class="cover-image"
:class="{ rotating: isPlaying }"
mode="aspectFill"
/>
</view>
<!-- 章节信息 -->
<view class="chapter-info">
<text class="chapter-title">{{ currentChapter.chapter }}</text>
<text v-if="currentChapter.content" class="chapter-subtitle">{{ currentChapter.content }}</text>
</view>
<!-- 进度条 -->
<view class="progress-section">
<text class="time">{{ formatTime(currentTime) }}</text>
<slider
:value="progress"
:max="100"
activeColor="#54a966"
backgroundColor="#e0e0e0"
block-color="#54a966"
block-size="12"
@change="onProgressChange"
@changing="onProgressChanging"
/>
<text class="time">{{ formatTime(duration) }}</text>
</view>
<!-- 播放控制 -->
<view class="controls">
<view class="control-btn" @click="prevChapter">
<wd-icon name="previous" size="32px"></wd-icon>
</view>
<view class="control-btn play-btn" @click="togglePlay">
<wd-icon
:name="isPlaying ? 'pause-circle' : 'play-circle'"
size="60px"
/>
</view>
<view class="control-btn" @click="nextChapter">
<wd-icon name="next" size="32px"></wd-icon>
</view>
<view class="control-btn" style="position: absolute; right: 20px;" @click="changeChapter">
<wd-icon name="menu-fold" size="32px"></wd-icon>
</view>
</view>
<!-- 播放速度 -->
<!-- <view class="speed-control">
<text class="speed-label">{{ $t('listen.speed') }}</text>
<view class="speed-options">
<view
v-for="speed in speeds"
:key="speed"
class="speed-btn"
:class="{ active: playbackRate === speed }"
@click="changeSpeed(speed)"
>
<text>{{ speed }}x</text>
</view>
</view>
</view> -->
</view>
<!-- 章节选择弹窗 -->
<wd-popup v-model="showChapterSelect" position="bottom" custom-style="height: 60vh;">
<view class="chapter-select-modal">
<view class="chapter-modal-header">
<text class="chapter-modal-title">{{ t('listen.chapterList') }}</text>
</view>
<scroll-view scroll-y class="chapter-list">
<view
v-for="(chapter, index) in chapterList"
:key="chapter.id"
class="chapter-item"
:class="{ 'chapter-item-active': currentChapterIndex === index }"
@click="selectChapter(index)"
>
<text class="chapter-title">{{ chapter.chapter }}</text>
</view>
</scroll-view>
</view>
</wd-popup>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onLoad, onHide, onUnload } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import { bookApi } from '@/api/modules/book'
import type { IBookDetail, IChapter } from '@/types/book'
const { t } = useI18n()
// 路由参数
const bookId = ref(0)
const isBuy = ref('0')
const count = ref(0)
const startIndex = ref(0)
// 数据状态
const bookInfo = ref<IBookDetail>({
id: 0,
name: '',
images: '',
author: { authorName: '', introduction: '' },
isBuy: false,
freeChapterCount: 0
})
const chapterList = ref<IChapter[]>([])
const currentChapterIndex = ref(0)
const currentChapter = ref<IChapter>({
id: 0,
chapter: '',
content: '',
voices: ''
})
// 音频状态
const audioContext = ref<UniApp.InnerAudioContext | null>(null)
const isPlaying = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const progress = ref(0)
const playbackRate = ref(1)
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2]
const contentHeight = ref(0)
const isChanging = ref(false)
const showChapterSelect = ref(false)
// 生命周期
onLoad((options: any) => {
if (options.bookId) bookId.value = Number(options.bookId)
if (options.isBuy) isBuy.value = options.isBuy
if (options.count) count.value = Number(options.count)
if (options.index !== undefined) {
startIndex.value = Number(options.index)
currentChapterIndex.value = startIndex.value
}
initContentHeight()
loadBookInfo()
loadChapterList()
})
onMounted(() => {
initAudioContext()
})
onHide(() => {
if (audioContext.value) {
audioContext.value.pause()
}
})
onUnload(() => {
if (audioContext.value) {
audioContext.value.destroy()
}
})
// 初始化内容高度
function initContentHeight() {
const systemInfo = uni.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || 0
let navBarHeight = 44
if (systemInfo.model.includes('iPhone')) {
const modelNumber = parseInt(systemInfo.model.match(/\d+/)?.[0] || '0')
if (modelNumber >= 11) {
navBarHeight = 48
}
}
const totalNavHeight = statusBarHeight + navBarHeight
contentHeight.value = systemInfo.windowHeight - totalNavHeight
}
// 初始化音频上下文
function initAudioContext() {
audioContext.value = uni.createInnerAudioContext()
audioContext.value.onPlay(() => {
isPlaying.value = true
})
audioContext.value.onPause(() => {
isPlaying.value = false
})
audioContext.value.onStop(() => {
isPlaying.value = false
})
audioContext.value.onTimeUpdate(() => {
if (!isChanging.value && audioContext.value) {
currentTime.value = audioContext.value.currentTime
duration.value = audioContext.value.duration
if (duration.value > 0) {
progress.value = (currentTime.value / duration.value) * 100
}
}
})
audioContext.value.onEnded(() => {
// 自动播放下一章
nextChapter()
})
audioContext.value.onError((err) => {
console.error('Audio error:', err)
uni.showToast({
title: '播放失败',
icon: 'none'
})
})
}
// 加载书籍信息
async function loadBookInfo() {
try {
const res = await bookApi.getBookInfo(bookId.value)
if (res.bookInfo) {
bookInfo.value = res.bookInfo
}
} catch (error) {
console.error('Failed to load book info:', error)
}
}
// 加载章节列表
async function loadChapterList() {
try {
const res = await bookApi.getBookChapter({
bookId: bookId.value
})
if (res.chapterList && res.chapterList.length > 0) {
chapterList.value = res.chapterList
// 播放当前章节
if (currentChapterIndex.value < chapterList.value.length) {
playChapter(chapterList.value[currentChapterIndex.value])
}
}
} catch (error) {
console.error('Failed to load chapter list:', error)
}
}
// 播放章节
function playChapter(chapter: IChapter) {
if (!chapter.voices) {
uni.showToast({
title: t('book.voices_null'),
icon: 'none'
})
return
}
currentChapter.value = chapter
if (audioContext.value) {
audioContext.value.src = chapter.voices
audioContext.value.playbackRate = playbackRate.value
audioContext.value.play()
}
}
// 切换播放/暂停
function togglePlay() {
if (!audioContext.value) return
if (isPlaying.value) {
audioContext.value.pause()
} else {
audioContext.value.play()
}
}
// 上一章
function prevChapter() {
if (currentChapterIndex.value > 0) {
currentChapterIndex.value--
playChapter(chapterList.value[currentChapterIndex.value])
} else {
uni.showToast({
title: '已经是第一章了',
icon: 'none'
})
}
}
// 下一章
function nextChapter() {
// 检查是否锁定
if (isBuy.value === '1' && currentChapterIndex.value + 1 >= count.value) {
uni.showModal({
title: t('common.limit_title'),
content: t('book.afterPurchase'),
confirmText: t('common.confirm_text'),
success: (res) => {
if (res.confirm) {
uni.navigateTo({
url: `/pages/book/order?id=${bookId.value}`
})
}
}
})
return
}
if (currentChapterIndex.value < chapterList.value.length - 1) {
currentChapterIndex.value++
playChapter(chapterList.value[currentChapterIndex.value])
} else {
uni.showToast({
title: '已经是最后一章了',
icon: 'none'
})
}
}
// 快退
function rewind() {
if (audioContext.value) {
const newTime = Math.max(0, currentTime.value - 15)
audioContext.value.seek(newTime)
}
}
// 快进
function fastForward() {
if (audioContext.value) {
const newTime = Math.min(duration.value, currentTime.value + 15)
audioContext.value.seek(newTime)
}
}
// 进度条改变中
function onProgressChanging(e: any) {
isChanging.value = true
const value = e.detail.value
progress.value = value
if (duration.value > 0) {
currentTime.value = (value / 100) * duration.value
}
}
// 进度条改变完成
function onProgressChange(e: any) {
const value = e.detail.value
if (audioContext.value && duration.value > 0) {
const newTime = (value / 100) * duration.value
audioContext.value.seek(newTime)
}
setTimeout(() => {
isChanging.value = false
}, 100)
}
// 改变播放速度
function changeSpeed(speed: number) {
playbackRate.value = speed
if (audioContext.value) {
audioContext.value.playbackRate = speed
}
}
// 切换章节
function changeChapter() {
showChapterSelect.value = true
}
// 选择章节
function selectChapter(index: number) {
if (index === currentChapterIndex.value) {
showChapterSelect.value = false
return
}
// 更新当前章节索引
currentChapterIndex.value = index
// 关闭弹窗
showChapterSelect.value = false
// 播放选中的章节
playChapter(chapterList.value[index])
}
// 格式化时间
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '00:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
</script>
<style lang="scss" scoped>
.audio-player-page {
background: linear-gradient(180deg, #f7faf9 0%, #fff 100%);
min-height: 100vh;
.player-content {
display: flex;
flex-direction: column;
padding: 40rpx 40rpx;
.cover-section {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 60rpx;
.cover-image {
width: 400rpx;
height: 400rpx;
border-radius: 50%;
box-shadow: 0 10rpx 40rpx rgba(0, 0, 0, 0.1);
&.rotating {
animation: rotate 20s linear infinite;
}
}
}
.chapter-info {
text-align: center;
margin-bottom: 60rpx;
.chapter-title {
display: block;
font-size: 40rpx;
font-weight: bold;
color: #333;
line-height: 50rpx;
margin-bottom: 20rpx;
}
.chapter-subtitle {
display: block;
font-size: 28rpx;
color: #666;
line-height: 36rpx;
}
}
.progress-section {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 60rpx;
.time {
font-size: 24rpx;
color: #999;
width: 80rpx;
text-align: center;
}
slider {
flex: 1;
}
}
.controls {
display: flex;
align-items: center;
justify-content: center;
gap: 40rpx;
margin-bottom: 60rpx;
.control-btn {
display: flex;
align-items: center;
justify-content: center;
color: #54a966;
&.play-btn {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: #54a966;
box-shadow: 0 8rpx 20rpx rgba(84, 169, 102, 0.3);
::v-deep .wd-icon {
color: #fff !important;
}
}
}
}
.speed-control {
text-align: center;
.speed-label {
display: block;
font-size: 28rpx;
color: #666;
margin-bottom: 20rpx;
}
.speed-options {
display: flex;
justify-content: center;
gap: 20rpx;
flex-wrap: wrap;
.speed-btn {
padding: 10rpx 30rpx;
border: 1rpx solid #ddd;
border-radius: 50rpx;
&.active {
background: #54a966;
border-color: #54a966;
text {
color: #fff;
}
}
text {
font-size: 26rpx;
color: #666;
}
}
}
}
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// 章节选择弹窗样式
.chapter-select-modal {
height: 100%;
background-color: #fff;
border-radius: 16px 16px 0 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chapter-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
.chapter-modal-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.chapter-modal-close {
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.chapter-list {
flex: 1;
padding: 0 20px;
}
.chapter-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
}
.chapter-item:last-child {
border-bottom: none;
}
.chapter-title {
flex: 1;
font-size: 16px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chapter-item-active .chapter-title {
color: #54a966;
font-weight: 500;
}
</style>

888
pages/book/reader.vue Normal file
View File

@@ -0,0 +1,888 @@
<template>
<view class="reader-page" :class="themeClass">
<!-- 顶部标题栏 (可隐藏) -->
<view class="reader-header" :style="{ top: notchHeight + 'px' }">
<wd-icon name="arrow-left" size="20px" @click="goBack" />
<text class="chapter-title">{{ currentChapterTitle }}</text>
</view>
<!-- 阅读内容区域 -->
<view class="reader-content" @click="toggleControls">
<!-- 左右翻页模式 -->
<view
v-if="readMode === 'page'"
class="page-mode"
:style="{ height: wrapHeight + 'px' }"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
>
<view
class="content-wrapper"
:style="{ top: currentTop + 'px' }"
>
<view
v-for="(item, index) in contentList"
:key="index"
:style="contentStyle"
class="content-item"
>
<text v-if="!isImage(item.content)">{{ item.content }}</text>
<image
v-else
:src="item.content"
:style="getImageStyle(item.otherContent)"
mode="aspectFit"
/>
</view>
</view>
<!-- 页码指示器 -->
<view v-show="showControls" class="page-indicator">
{{ currentPage }} / {{ totalPages }}
</view>
</view>
<!-- 上下滚动模式 -->
<scroll-view
v-else
scroll-y
class="scroll-mode"
:style="{ height: wrapHeightScroll + 'px' }"
:scroll-into-view="scrollTarget"
@scrolltolower="onScrollToBottom"
>
<view id="content-top" class="content-wrapper">
<view
v-for="(item, index) in contentList"
:key="index"
:style="contentStyle"
class="content-item"
>
<text v-if="!isImage(item.content)">{{ item.content }}</text>
<image
v-else
:src="item.content"
:style="getImageStyle(item.otherContent)"
mode="aspectFit"
/>
</view>
</view>
</scroll-view>
</view>
<!-- 底部操作栏 (可隐藏) -->
<view v-show="showControls" class="reader-footer">
<view class="footer-btn" :class="{ active: catalogVisible }" @click="showCatalog">
<text>{{ $t('book.contents') }}</text>
</view>
<view class="divider" />
<view class="footer-btn" :class="{ active: settingsVisible }" @click="showSettings">
<text>{{ $t('book.set') }}</text>
</view>
</view>
<!-- 目录弹窗 -->
<wd-popup v-model="catalogVisible" position="bottom" @close="closeCatalog">
<view class="catalog-popup">
<text class="popup-title">{{ $t('book.zjContents') }}</text>
<scroll-view scroll-y class="chapter-list">
<view
v-for="(chapter, index) in chapterList"
:key="chapter.id"
class="chapter-item"
:class="{ active: index === currentChapterIndex }"
@click="switchChapter(chapter, index)"
>
<text class="chapter-text" :class="{ locked: isLocked(index) }">
{{ chapter.chapter }}{{ chapter.content ? ' - ' + chapter.content : '' }}
</text>
<wd-icon v-if="isLocked(index)" name="lock" size="20px" />
</view>
</scroll-view>
</view>
</wd-popup>
<!-- 设置弹窗 -->
<wd-popup v-model="settingsVisible" position="bottom" @close="closeSettings">
<view class="settings-popup">
<!-- 切换语言 -->
<view class="setting-item">
<text class="setting-label">{{ $t('book.language') }}</text>
<wd-radio-group v-model="currentLanguage" shape="button" custom-class="bg-[transparent]" @change="changeBookLanguage">
<wd-radio v-for="lang in bookLanguages" :key="lang.language" :value="lang.language">{{ lang.language }}</wd-radio>
</wd-radio-group>
</view>
<!-- 字体大小 -->
<view class="setting-item">
<text class="setting-label">{{ $t('book.font') }}</text>
<slider
:value="fontSizeLevel"
:min="1"
:max="8"
activeColor="#54a966"
backgroundColor="#cad1bf"
block-color="#54a966"
block-size="18"
@change="onFontSizeChange"
/>
</view>
<!-- 背景颜色 -->
<view class="setting-item">
<text class="setting-label">{{ $t('book.bgColor') }}</text>
<view class="theme-options">
<view
v-for="theme in themes"
:key="theme.key"
class="theme-btn"
:class="[theme.key, { active: currentTheme === theme.key }]"
@click="changeTheme(theme.key)"
/>
</view>
</view>
<!-- 阅读模式 -->
<view class="setting-item">
<text class="setting-label">{{ $t('book.type') }}</text>
<view class="mode-options">
<view
class="mode-btn"
:class="{ active: readMode === 'scroll' }"
@click="changeReadMode('scroll')"
>
<text>{{ $t('book.type_1') }}</text>
</view>
<view
class="mode-btn"
:class="{ active: readMode === 'page' }"
@click="changeReadMode('page')"
>
<text>{{ $t('book.type_2') }}</text>
</view>
</view>
</view>
<!-- 底部占位 -->
<view class="setting-ooter-placeholder"></view>
</view>
</wd-popup>
<!-- 空状态 -->
<view v-if="chapterList.length === 0 && !loading" class="empty-state">
<text>{{ nullStatus }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onLoad, onShow, onHide, onBackPress } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import { useBookStore } from '@/stores/book'
import { bookApi } from '@/api/modules/book'
import type { IChapter, IChapterContent, IReadProgress } from '@/types/book'
const { t } = useI18n()
const bookStore = useBookStore()
// 路由参数
const bookId = ref(0)
const isBuy = ref('0')
const count = ref(0)
// 数据状态
const chapterList = ref<IChapter[]>([])
const contentList = ref<IChapterContent[]>([])
const currentChapterIndex = ref(0)
const currentChapterId = ref(0)
const currentContentId = ref(0)
const loading = ref(false)
const nullStatus = ref('')
// 阅读设置
const fontSizeLevel = ref(1)
const fontSize = ref(1)
const lineHeight = ref(34)
const currentTheme = ref('default')
const readMode = ref<'scroll' | 'page'>('scroll')
// 翻页模式相关
const currentPage = ref(1)
const totalPages = ref(0)
const currentTop = ref(0)
const wrapHeight = ref(0)
const wrapHeightScroll = ref(0)
const contentHeight = ref(0)
// UI状态
const showControls = ref(false)
const catalogVisible = ref(false)
const settingsVisible = ref(false)
const scrollTarget = ref('')
const notchHeight = ref(0)
// 触摸相关
const startX = ref(0)
const threshold = 50
// 主题配置
const themes = [
{ key: 'default' },
{ key: 'blue' },
{ key: 'green' },
{ key: 'purple' },
{ key: 'night' }
]
// 字体大小映射
const FONT_SIZE_MAP: Record<number, { fontSize: number; lineHeight: number }> = {
1: { fontSize: 1, lineHeight: 34 },
2: { fontSize: 1.25, lineHeight: 40 },
3: { fontSize: 1.45, lineHeight: 44 },
4: { fontSize: 1.6, lineHeight: 50 },
5: { fontSize: 1.65, lineHeight: 50 },
6: { fontSize: 1.7, lineHeight: 54 },
7: { fontSize: 1.75, lineHeight: 54 },
8: { fontSize: 1.8, lineHeight: 54 }
}
// 计算属性
const themeClass = computed(() => `theme-${currentTheme.value}`)
const contentStyle = computed(() => ({
fontSize: `${fontSize.value}rem`,
lineHeight: `${lineHeight.value}px`
}))
const currentChapterTitle = computed(() => {
const chapter = chapterList.value[currentChapterIndex.value]
if (!chapter) return ''
return chapter.chapter + (chapter.content ? ' - ' + chapter.content : '')
})
// 生命周期
onLoad((options: any) => {
if (options.bookId) bookId.value = Number(options.bookId)
if (options.isBuy) isBuy.value = options.isBuy
if (options.count) count.value = Number(options.count)
// 获取刘海高度
const systemInfo = uni.getSystemInfoSync()
notchHeight.value = systemInfo.safeArea?.top || 0
// 恢复阅读设置
bookStore.restoreSettings()
const settings = bookStore.readerSettings
fontSizeLevel.value = settings.fontSize
setFontSize(settings.fontSize)
currentTheme.value = settings.theme
readMode.value = settings.readMode
loadReadProgress()
getBookLanguages()
})
onShow(() => {
if (readMode.value === 'scroll') {
scrollTarget.value = 'content-top'
setTimeout(() => {
scrollTarget.value = ''
}, 300)
} else {
currentPage.value = 1
calculatePages()
}
})
onMounted(() => {
initHeights()
})
onHide(() => {
saveProgress()
})
onBackPress(() => {
saveProgress()
return false
})
// 初始化高度
function initHeights() {
const systemInfo = uni.getSystemInfoSync()
const windowHeight = systemInfo.windowHeight
// 翻页模式高度
const math = Math.floor((windowHeight - (notchHeight.value + 130)) / lineHeight.value)
wrapHeight.value = math * lineHeight.value
// 滚动模式高度
wrapHeightScroll.value = windowHeight - (notchHeight.value + 75)
}
// 加载阅读进度
async function loadReadProgress() {
try {
const res = await bookApi.getReadProgress(bookId.value)
if (res.bookReadRate) {
currentChapterId.value = res.bookReadRate.chapterId
currentContentId.value = res.bookReadRate.contentId
}
await loadChapterList()
} catch (error) {
console.error('Failed to load read progress:', error)
await loadChapterList()
}
}
// 加载章节列表
async function loadChapterList() {
try {
loading.value = true
const res = await bookApi.getBookChapter({
bookId: bookId.value,
language: currentLanguage.value
})
if (res.chapterList && res.chapterList.length > 0) {
chapterList.value = res.chapterList
// 找到上次阅读的章节
if (currentChapterId.value) {
const index = chapterList.value.findIndex(ch => ch.id === currentChapterId.value)
if (index !== -1) {
currentChapterIndex.value = index
await loadChapterContent(currentChapterId.value, index)
return
}
}
// 默认加载第一章
await loadChapterContent(chapterList.value[0].id, 0)
} else {
nullStatus.value = t('common.data_null')
}
} catch (error) {
nullStatus.value = t('common.data_null')
console.error('Failed to load chapter list:', error)
} finally {
loading.value = false
}
}
// 加载章节内容
async function loadChapterContent(chapterId: number, index: number) {
try {
uni.showLoading({ title: t('global.loading') })
const res = await bookApi.getChapterContent(chapterId)
uni.hideLoading()
if (res.contentPage && res.contentPage.length > 0) {
contentList.value = res.contentPage
currentChapterId.value = chapterId
currentChapterIndex.value = index
currentContentId.value = res.contentPage[0].id
// 重置页码
currentPage.value = 1
// 计算页数
if (readMode.value === 'page') {
setTimeout(() => {
calculatePages()
}, 100)
} else {
scrollTarget.value = 'content-top'
setTimeout(() => {
scrollTarget.value = ''
}, 300)
}
// 关闭弹窗
catalogVisible.value = false
}
} catch (error) {
uni.hideLoading()
console.error('Failed to load chapter content:', error)
}
}
// 切换章节
function switchChapter(chapter: IChapter, index: number) {
if (isLocked(index)) {
uni.showToast({
title: t('book.afterPurchase'),
icon: 'none'
})
return
}
loadChapterContent(chapter.id, index)
}
// 判断章节是否锁定
function isLocked(index: number): boolean {
return isBuy.value === '1' && index + 1 > count.value
}
// 判断是否是图片
function isImage(content: string): boolean {
return content.includes('oss-cn-beijing') || content.includes('http')
}
// 获取图片样式
function getImageStyle(otherContent?: string) {
if (!otherContent) return {}
const [width, height] = otherContent.split(',').map(Number)
const systemInfo = uni.getSystemInfoSync()
const maxWidth = systemInfo.windowWidth - 30
if (width > maxWidth) {
const ratio = maxWidth / width
return {
width: `${maxWidth}px`,
height: `${height * ratio}px`
}
}
return {
width: `${width}px`,
height: `${height}px`
}
}
// 计算页数
function calculatePages() {
uni.createSelectorQuery()
.select('.content-wrapper')
.boundingClientRect((data: any) => {
if (data) {
contentHeight.value = data.height
totalPages.value = Math.ceil(contentHeight.value / wrapHeight.value)
currentTop.value = 0
}
})
.exec()
}
// 触摸开始
function onTouchStart(event: any) {
startX.value = event.touches[0].clientX
}
// 触摸结束
function onTouchEnd(event: any) {
const endX = event.changedTouches[0].clientX
const distanceX = endX - startX.value
if (Math.abs(distanceX) > threshold) {
if (distanceX > 0) {
// 上一页
if (currentPage.value > 1) {
currentPage.value--
currentTop.value = -wrapHeight.value * (currentPage.value - 1)
}
} else {
// 下一页
if (currentPage.value < totalPages.value) {
currentPage.value++
currentTop.value = -wrapHeight.value * (currentPage.value - 1)
} else if (currentPage.value === totalPages.value) {
onScrollToBottom()
}
}
}
}
// 滚动到底部
function onScrollToBottom() {
if (isBuy.value === '1' && currentChapterIndex.value + 1 === count.value) {
uni.showModal({
title: t('common.limit_title'),
content: t('book.endText'),
cancelText: t('common.cancel_text'),
confirmText: t('common.confirm_text'),
success: (res) => {
if (res.confirm) {
uni.navigateTo({
url: `/pages/book/order?id=${bookId.value}`
})
}
}
})
}
}
// 切换控制栏显示
function toggleControls() {
showControls.value = !showControls.value
}
// 显示目录
function showCatalog() {
catalogVisible.value = true
settingsVisible.value = false
}
// 关闭目录
function closeCatalog() {
catalogVisible.value = false
showControls.value = false
}
// 显示设置
function showSettings() {
console.log('currentLanguage', currentLanguage.value)
settingsVisible.value = true
catalogVisible.value = false
}
// 关闭设置
function closeSettings() {
settingsVisible.value = false
showControls.value = false
}
// 字体大小改变
function onFontSizeChange(e: any) {
const level = e.detail.value
fontSizeLevel.value = level
setFontSize(level)
// 保存设置
bookStore.updateReaderSettings({ fontSize: level, lineHeight: lineHeight.value })
// 重新计算页数
if (readMode.value === 'page') {
setTimeout(() => {
initHeights()
calculatePages()
}, 100)
}
}
// 设置字体大小
function setFontSize(level: number) {
const config = FONT_SIZE_MAP[level] || FONT_SIZE_MAP[1]
fontSize.value = config.fontSize
lineHeight.value = config.lineHeight
}
// 切换主题
function changeTheme(theme: string) {
currentTheme.value = theme
bookStore.updateReaderSettings({ theme: theme as any })
}
// 切换阅读模式
function changeReadMode(mode: 'scroll' | 'page') {
readMode.value = mode
bookStore.updateReaderSettings({ readMode: mode })
if (mode === 'page') {
setTimeout(() => {
calculatePages()
}, 100)
} else {
scrollTarget.value = 'content-top'
setTimeout(() => {
scrollTarget.value = ''
}, 300)
}
currentPage.value = 1
}
// 语言配置
const bookLanguages = ref([])
const currentLanguage = ref('')
onMounted(() => {
currentLanguage.value = uni.getStorageSync('currentBookLanguage') || ''
console.log('currentLanguage', currentLanguage.value)
})
const getBookLanguages = async () => {
const res = await bookApi.getBookLanguages(bookId.value)
bookLanguages.value = res.languageList || []
if (bookLanguages.value.length > 0 && !currentLanguage.value) {
currentLanguage.value = bookLanguages.value[0].language
}
}
const changeBookLanguage = (language: any) => {
uni.setStorageSync('currentBookLanguage', language.value)
loadChapterList()
}
// 保存进度
async function saveProgress() {
if (isBuy.value === '0') {
try {
await bookApi.saveReadProgress(bookId.value, currentChapterId.value, currentContentId.value)
} catch (error) {
console.error('Failed to save progress:', error)
}
}
}
// 返回
function goBack() {
uni.navigateTo({
url: '/pages/book/detail?id=' + bookId.value
})
}
</script>
<style lang="scss" scoped>
.reader-page {
min-height: 100vh;
background: #fff;
color: #333;
&.theme-blue {
background: #e8e3d0;
}
&.theme-green {
background: #d1edd1;
}
&.theme-purple {
background: #dae4ee;
}
&.theme-night {
background: #1a1a1a;
color: #fff;
}
.reader-header {
position: fixed;
left: 0;
right: 0;
z-index: 999;
background: inherit;
display: flex;
align-items: center;
padding: 0 20rpx;
height: 44px;
.chapter-title {
flex: 1;
font-size: 28rpx;
color: inherit;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-left: 20rpx;
}
}
.reader-content {
padding-top: 44px;
box-sizing: border-box;
.page-mode {
overflow: hidden;
position: relative;
.content-wrapper {
position: relative;
padding: 0 50rpx;
transition: top 0.3s ease;
}
.page-indicator {
position: fixed;
bottom: 5px;
left: 30rpx;
font-size: 26rpx;
color: #999;
}
}
.scroll-mode {
.content-wrapper {
padding: 0 50rpx 20rpx;
}
}
.content-item {
color: inherit;
text-indent: 2em;
letter-spacing: 0.2rem;
text-align: justify;
margin-bottom: 10rpx;
image {
display: block;
margin: 20rpx auto;
}
}
}
.reader-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 55px;
background: #f7faf9;
display: flex;
align-items: center;
z-index: 999;
.footer-btn {
flex: 1;
text-align: center;
text {
font-size: 32rpx;
color: #333;
}
&.active text {
color: #54a966;
}
}
.divider {
width: 1rpx;
height: 45rpx;
background: #999;
}
}
.catalog-popup {
max-height: 70vh;
.popup-title {
display: block;
height: 50px;
line-height: 50px;
text-align: center;
font-size: 32rpx;
color: #54a966;
border-bottom: 1rpx solid #54a966;
}
.chapter-list {
max-height: calc(70vh - 50px);
.chapter-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 30rpx;
border-bottom: 1rpx solid #dcdfe6;
&.active .chapter-text {
color: #54a966;
}
.chapter-text {
flex: 1;
font-size: 28rpx;
line-height: 50rpx;
color: #333;
&.locked {
color: #999;
}
}
}
}
}
.settings-popup {
padding: 40rpx 30rpx;
background-color: #f5f5f5;
.setting-item {
margin-bottom: 40rpx;
.setting-label {
display: block;
font-size: 30rpx;
color: #333;
margin-bottom: 20rpx;
}
.theme-options {
display: flex;
gap: 36rpx;
.theme-btn {
width: 76rpx;
height: 76rpx;
border-radius: 50%;
border: 2rpx solid transparent;
&.active {
border-color: #333;
}
&.default {
background: #fff;
}
&.blue {
background: #e8e3d0;
}
&.green {
background: #d1edd1;
}
&.purple {
background: #dae4ee;
}
&.night {
background: #1a1a1a;
}
}
}
.mode-options {
display: flex;
gap: 20rpx;
.mode-btn {
flex: 1;
padding: 20rpx;
text-align: center;
border: 1rpx solid #ddd;
border-radius: 10rpx;
&.active {
border-color: #54a966;
text {
color: #54a966;
}
}
text {
font-size: 28rpx;
color: #333;
}
}
}
}
/* 底部占位 */
.setting-ooter-placeholder {
height: 80rpx;
}
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
text {
font-size: 28rpx;
color: #999;
}
}
}
</style>

513
pages/book/review.vue Normal file
View File

@@ -0,0 +1,513 @@
<template>
<view class="book-review-page">
<!-- 导航栏 -->
<nav-bar :title="$t('bookDetails.title_comment')"></nav-bar>
<scroll-view
scroll-y
class="review-scroll"
:style="{ height: scrollHeight + 'px' }"
@scrolltolower="loadMore"
>
<!-- 书籍信息卡片 -->
<view class="book-card">
<image :src="bookInfo.images" class="cover" mode="aspectFill" />
<view class="info">
<text class="title">{{ bookInfo.name }}</text>
<text class="author">{{ $t('bookDetails.authorName') }}{{ bookInfo.author?.authorName }}</text>
</view>
<wd-button
v-if="bookInfo.isBuy"
type="primary"
size="small"
@click="showCommentDialog()"
>
{{ $t('bookDetails.makeComment') }}
</wd-button>
</view>
<view class="divider-line" />
<!-- 评论列表 -->
<view class="comments-wrapper">
<view class="section-header">
<text class="section-title">{{ $t('bookDetails.message') }}</text>
</view>
<CommentList
v-if="commentList.length > 0"
:comments="commentList"
:has-more="hasMore"
@like="handleLike"
@reply="showCommentDialog"
@delete="handleDelete"
@load-more="loadMore"
/>
<text v-else class="empty-text">{{ nullText }}</text>
</view>
</scroll-view>
<!-- 评论输入弹窗 -->
<wd-popup v-model="commentVisible" position="bottom">
<view class="comment-dialog">
<text class="dialog-title">
{{ replyTarget ? $t('bookDetails.reply') + replyTarget.name + $t('bookDetails.dpl') : $t('bookDetails.makeComment') }}
</text>
<!-- 富文本编辑器 -->
<editor
id="editor"
class="editor"
:placeholder="placeholder"
@ready="onEditorReady"
@statuschange="onStatusChange"
/>
<!-- Emoji选择器 -->
<view class="emoji-section">
<view class="emoji-btn" @click="toggleEmoji">
<image src="@/static/biaoqing.png" mode="aspectFit" />
<text>Emoji</text>
</view>
<!-- 这里需要一个Emoji选择器组件暂时简化处理 -->
<view v-if="showEmoji" class="emoji-picker">
<text class="emoji-tip">Emoji功能待集成</text>
</view>
</view>
<wd-button
type="primary"
custom-class="submit-btn"
@click="submitComment"
>
{{ $t('common.submit_text') }}
</wd-button>
</view>
</wd-popup>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import { bookApi } from '@/api/modules/book'
import type { IBookDetail, IComment } from '@/types/book'
import CustomNavbar from '@/components/book/CustomNavbar.vue'
import CommentList from '@/components/book/CommentList.vue'
const { t } = useI18n()
// 路由参数
const bookId = ref(0)
const pageType = ref('')
// 数据状态
const bookInfo = ref<IBookDetail>({
id: 0,
name: '',
images: '',
author: { authorName: '', introduction: '' },
isBuy: false,
freeChapterCount: 0
})
const commentList = ref<IComment[]>([])
const page = ref({
current: 1,
limit: 10
})
const commentsCount = ref(0)
const nullText = ref('')
const scrollHeight = ref(0)
// 评论相关
const commentVisible = ref(false)
const replyTarget = ref<{ id: number; name: string } | null>(null)
const placeholder = ref('')
const showEmoji = ref(false)
const editorCtx = ref<any>(null)
const htmlContent = ref('')
// 计算属性
const hasMore = computed(() => commentList.value.length < commentsCount.value)
// 生命周期
onLoad((options: any) => {
if (options.bookId) {
bookId.value = Number(options.bookId)
}
if (options.page) {
pageType.value = options.page
}
initScrollHeight()
loadBookInfo()
loadComments()
})
// 初始化滚动区域高度
function initScrollHeight() {
const systemInfo = uni.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || 0
let navBarHeight = 44
if (systemInfo.model.includes('iPhone')) {
const modelNumber = parseInt(systemInfo.model.match(/\d+/)?.[0] || '0')
if (modelNumber >= 11) {
navBarHeight = 48
}
}
const totalNavHeight = statusBarHeight + navBarHeight
scrollHeight.value = systemInfo.windowHeight - totalNavHeight
}
// 加载书籍信息
async function loadBookInfo() {
try {
const res = await bookApi.getBookInfo(bookId.value)
if (res.bookInfo) {
bookInfo.value = res.bookInfo
}
} catch (error) {
console.error('Failed to load book info:', error)
}
}
// 加载评论列表
async function loadComments() {
if (!hasMore.value && commentList.value.length > 0) {
return
}
try {
uni.showLoading({ title: t('global.loading') })
const res = await bookApi.getBookComments(bookId.value, page.value.current, page.value.limit)
uni.hideLoading()
commentsCount.value = res.commentsCount || 0
if (res.commentsTree && res.commentsTree.length > 0) {
commentList.value = [...commentList.value, ...res.commentsTree]
page.value.current += 1
} else if (commentList.value.length === 0) {
nullText.value = t('common.data_null')
}
} catch (error) {
uni.hideLoading()
nullText.value = t('common.data_null')
console.error('Failed to load comments:', error)
}
}
// 加载更多
function loadMore() {
if (hasMore.value) {
loadComments()
}
}
// 显示评论对话框
function showCommentDialog(comment?: IComment) {
if (!bookInfo.value.isBuy) {
uni.showToast({
title: t('book.afterPurchase'),
icon: 'none'
})
return
}
if (comment) {
const userName = comment.userEntity.nickname || comment.userEntity.name || 'TA'
replyTarget.value = { id: comment.id, name: userName }
placeholder.value = t('bookDetails.replyText') + userName + t('bookDetails.dpl')
} else {
replyTarget.value = null
placeholder.value = t('bookDetails.enterText')
}
htmlContent.value = ''
commentVisible.value = true
showEmoji.value = false
}
// 编辑器就绪
function onEditorReady() {
uni.createSelectorQuery()
.select('#editor')
.context((res) => {
editorCtx.value = res.context
editorCtx.value.clear()
})
.exec()
}
// 编辑器状态变化
function onStatusChange(e: any) {
// 可以在这里处理编辑器状态
}
// 获取编辑器内容
function getEditorContent(): Promise<string> {
return new Promise((resolve, reject) => {
if (!editorCtx.value) {
reject('Editor not ready')
return
}
editorCtx.value.getContents({
success: (res: any) => {
resolve(res.html || '')
},
fail: (err: any) => {
reject(err)
}
})
})
}
// 提交评论
async function submitComment() {
try {
const content = await getEditorContent()
if (!content || content === '<p><br></p>') {
uni.showToast({
title: t('bookDetails.enterText'),
icon: 'none'
})
return
}
const pid = replyTarget.value?.id || 0
await bookApi.insertComment(bookId.value, content, pid)
uni.showToast({
title: t('workOrder.submit_success'),
icon: 'success',
duration: 500
})
setTimeout(() => {
editorCtx.value?.clear()
resetComments()
}, 500)
} catch (error) {
console.error('Failed to submit comment:', error)
}
}
// 点赞/取消点赞
async function handleLike(comment: IComment) {
if (!bookInfo.value.isBuy) {
uni.showToast({
title: t('book.afterPurchase'),
icon: 'none'
})
return
}
try {
if (comment.isLike === 0) {
await bookApi.likeComment(comment.id)
uni.showToast({
title: t('bookDetails.supportSuccess'),
icon: 'success',
duration: 1000
})
} else {
await bookApi.unlikeComment(comment.id)
uni.showToast({
title: t('bookDetails.supportCancel'),
icon: 'success',
duration: 1000
})
}
setTimeout(() => {
resetComments()
}, 200)
} catch (error) {
console.error('Failed to like comment:', error)
}
}
// 删除评论
function handleDelete(comment: IComment) {
uni.showModal({
title: t('common.limit_title'),
content: t('bookDetails.deleteText'),
cancelText: t('common.cancel_text'),
confirmText: t('common.confirm_text'),
success: async (res) => {
if (res.confirm) {
try {
await bookApi.deleteComment(comment.id)
uni.showToast({
title: t('bookDetails.deleteSuccess'),
icon: 'success',
duration: 500
})
setTimeout(() => {
resetComments()
}, 500)
} catch (error) {
console.error('Failed to delete comment:', error)
}
}
}
})
}
// 重置评论列表
function resetComments() {
commentVisible.value = false
commentList.value = []
page.value.current = 1
nullText.value = ''
loadComments()
}
// 切换Emoji选择器
function toggleEmoji() {
showEmoji.value = !showEmoji.value
}
</script>
<style lang="scss" scoped>
.book-review-page {
background: #f7faf9;
min-height: 100vh;
.review-scroll {
.book-card {
margin: 20rpx;
padding: 30rpx;
background: #fff;
border-radius: 15rpx;
display: flex;
align-items: center;
.cover {
width: 180rpx;
height: 240rpx;
border-radius: 10rpx;
flex-shrink: 0;
}
.info {
flex: 1;
padding: 0 20rpx;
.title {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #333;
line-height: 44rpx;
max-height: 88rpx;
overflow: hidden;
}
.author {
display: block;
font-size: 30rpx;
padding-top: 15rpx;
color: #666;
}
}
}
.divider-line {
height: 20rpx;
background: #f7faf9;
}
.comments-wrapper {
background: #fff;
padding: 30rpx;
min-height: 400rpx;
.section-header {
margin-bottom: 20rpx;
.section-title {
font-size: 34rpx;
line-height: 50rpx;
color: #333;
font-weight: 500;
}
}
}
.empty-text {
display: block;
text-align: center;
padding: 100rpx 0;
font-size: 28rpx;
color: #999;
}
}
.comment-dialog {
padding: 40rpx;
.dialog-title {
display: block;
font-size: 34rpx;
color: #333;
text-align: center;
margin-bottom: 30rpx;
}
.editor {
border: 1rpx solid #ddd;
width: 100%;
min-height: 200rpx;
padding: 10rpx;
border-radius: 10rpx;
margin-bottom: 20rpx;
}
.emoji-section {
margin-bottom: 20rpx;
.emoji-btn {
width: 110rpx;
height: 110rpx;
border-radius: 20rpx;
background-color: #f4f5f7;
text-align: center;
padding-top: 20rpx;
image {
width: 45rpx;
height: 45rpx;
display: block;
margin: 0 auto 5rpx;
}
text {
display: block;
font-size: 26rpx;
line-height: 36rpx;
color: #999;
}
}
.emoji-picker {
margin-top: 20rpx;
padding: 20rpx;
background: #f4f5f7;
border-radius: 10rpx;
.emoji-tip {
font-size: 26rpx;
color: #999;
}
}
}
}
}
</style>

View File

@@ -1,6 +1,7 @@
<template>
<view class="container">
<view class="title bg-[red] text-center text-[#fff]">这是一个等待开发的首页</view>
<view class="title bg-[blue] text-center text-[#000]">这是一个等待开发的首页</view>
<view class="title bg-[blue] text-center text-[#fff]">这是一个等待开发的首页</view>
<view class="description bg-[red]">首页的内容是在线课程</view>
</view>
</template>

View File

@@ -96,8 +96,8 @@ const menuItems = computed(() => [
{
id: 2,
name: t('user.myBooklist'),
url: '/pages/book/index',
type: 'switchTab'
url: '/pages/user/myBook/index',
type: 'pageJump'
},
{
id: 3,

225
pages/user/myBook/index.vue Normal file
View File

@@ -0,0 +1,225 @@
<template>
<view class="my-book-page">
<!-- 自定义导航栏 -->
<nav-bar :title="$t('book.myBook')"></nav-bar>
<!-- 书籍列表 -->
<scroll-view
v-if="(bookList && bookList.length > 0) || loading"
scroll-y
class="book-scroll"
:style="{ height: scrollHeight + 'px' }"
:scroll-into-view="scrollTarget"
@scrolltolower="loadMore"
>
<view :id="scrollTarget" />
<view class="book-list">
<BookCard
v-for="item in bookList"
:key="item.id"
:book="item"
@click="goToDetail(item.id)"
@read="goToReader(item.id)"
@listen="goToListen(item.id)"
@review="goToReview(item.id)"
/>
<!-- 加载提示 -->
<view v-if="showLoadMore && hasMore" class="load-tip">
<text>{{ $t('common.loadMore') }}</text>
</view>
<view v-if="!hasMore && bookList && bookList.length > 0" class="load-tip">
<text>{{ $t('common.noMore') }}</text>
</view>
</view>
</scroll-view>
<!-- 空状态 -->
<view v-else-if="!loading" class="empty-state">
<image src="@/static/null_img.png" mode="aspectFit" />
<text class="empty-text">{{ $t('book.nullText') }}</text>
<wd-button type="primary" @click="goToBuy">
{{ $t('book.choose') }}
</wd-button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import { bookApi } from '@/api/modules/book'
import type { IBook } from '@/types/book'
import BookCard from '@/components/book/BookCard.vue'
const { t } = useI18n()
// 数据状态
const bookList = ref<IBook[]>([])
const loading = ref(false)
const page = ref({
current: 1,
limit: 10
})
const total = ref(0)
const scrollTarget = ref('top')
const scrollHeight = ref(0)
const showLoadMore = ref(false)
// 计算属性
const hasMore = computed(() => bookList.value && bookList.value.length < total.value)
// 生命周期
onMounted(() => {
initScrollHeight()
loadBookList()
})
// 初始化滚动区域高度
function initScrollHeight() {
const systemInfo = uni.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || 0
let navBarHeight = 44
if (systemInfo.model.includes('iPhone')) {
const modelNumber = parseInt(systemInfo.model.match(/\d+/)?.[0] || '0')
if (modelNumber >= 11) {
navBarHeight = 48
}
}
const totalNavHeight = statusBarHeight + navBarHeight
scrollHeight.value = systemInfo.windowHeight - totalNavHeight
}
// 加载书单列表
async function loadBookList() {
if (loading.value || (!hasMore.value && bookList.value && bookList.value.length > 0)) {
return
}
loading.value = true
uni.showLoading({ title: t('global.loading') })
try {
const res = await bookApi.getMyBooks(page.value.current, page.value.limit)
uni.hideLoading()
if (res.page && res.page.records) {
total.value = res.page.total
const newBooks = res.page.records
if (newBooks.length > 0) {
bookList.value = [...(bookList.value || []), ...newBooks]
page.value.current += 1
showLoadMore.value = true
}
}
} catch (error) {
uni.hideLoading()
console.error('Failed to load book list:', error)
} finally {
loading.value = false
}
}
// 加载更多
function loadMore() {
if (!loading.value && hasMore.value) {
loadBookList()
}
}
// 页面跳转
function goToDetail(bookId: number) {
uni.navigateTo({
url: `/pages/book/detail?id=${bookId}`
})
}
function goToReader(bookId: number) {
uni.navigateTo({
url: `/pages/book/reader?isBuy=0&bookId=${bookId}`
})
}
function goToListen(bookId: number) {
uni.navigateTo({
url: `/pages/book/listen/index?bookId=${bookId}`
})
}
function goToReview(bookId: number) {
uni.navigateTo({
url: `/pages/book/review?bookId=${bookId}&page=0`
})
}
function goToBuy() {
uni.switchTab({
url: '/pages/book/index'
})
}
// 页面显示时重新加载
onShow(() => {
// 回到顶部
scrollTarget.value = 'top'
setTimeout(() => {
scrollTarget.value = ''
}, 300)
// 重置并重新加载
bookList.value = []
page.value.current = 1
total.value = 0
showLoadMore.value = false
loadBookList()
})
</script>
<style lang="scss" scoped>
.my-book-page {
background: #f7faf9;
min-height: 100vh;
.book-scroll {
.book-list {
padding: 0 20rpx 10rpx;
}
}
.load-tip {
font-size: 30rpx;
text-align: center;
padding: 40rpx 0;
color: #ccc;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 200rpx;
image {
width: 400rpx;
height: 300rpx;
margin-bottom: 40rpx;
}
.empty-text {
font-size: 28rpx;
color: #999;
margin-bottom: 50rpx;
}
button {
width: 180rpx;
height: 60rpx;
font-size: 28rpx;
border-radius: 50rpx;
}
}
}
</style>

View File

@@ -1,21 +1,13 @@
<template>
<view class="order-page">
<!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ height: navbarHeight }">
<view class="navbar-content" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="navbar-left" @click="goBack">
<wd-icon name="arrow-left" size="18px" color="#333" />
</view>
<text class="navbar-title">{{ $t('user.myOrders') }}</text>
</view>
</view>
<nav-bar :title="$t('user.myOrders')"></nav-bar>
<!-- 订单列表 -->
<scroll-view
v-if="orderList.length > 0"
scroll-y
class="order-scroll"
:style="{ paddingTop: navbarHeight }"
@scrolltolower="loadMore"
>
<view class="order-list">

View File

@@ -42,27 +42,72 @@
</view>
</wd-popup>
<!-- 语言选择弹窗 -->
<wd-popup v-model="showLanguageSelect" position="bottom" :closeable="true">
<view class="language-modal">
<text class="modal-title">{{ $t('user.languageSelect') }}</text>
<view class="language-list">
<view
v-for="lang in availableLanguages"
:key="lang.code"
class="language-item"
:class="{ active: (userStore.language || uni.getLocale()) === lang.code }"
@click="selectLanguage(lang.code)"
>
<text class="language-name">{{ lang.name }}</text>
<wd-icon v-if="(userStore.language || uni.getLocale()) === lang.code" name="check" size="20px" color="#54a966" />
</view>
</view>
</view>
</wd-popup>
<wd-message-box />
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useSysStore } from '@/stores/sys'
import { useUserStore } from '@/stores/user'
import { useI18n } from 'vue-i18n'
import { useMessage } from '@/uni_modules/wot-design-uni'
import { makePhoneCall, copyToClipboard } from '@/utils/index'
const { t } = useI18n()
const { t, locale } = useI18n()
const sysStore = useSysStore()
const userStore = useUserStore()
const message = useMessage()
// 导航栏高度
const statusBarHeight = ref(0)
const navbarHeight = ref('44px')
// 弹窗状态
const showQrCode = ref(false)
const showLanguageSelect = ref(false)
// 可选语言列表
const availableLanguages = computed(() => [
{ code: 'en', name: t('locale.en') },
{ code: 'zh-Hans', name: t('locale.zh-hans') }
])
// 获取当前语言名称
const getCurrentLanguageName = () => {
const currentLang = userStore.language || uni.getLocale()
const language = availableLanguages.value.find(lang => lang.code === currentLang)
return language ? language.name : currentLang
}
// 设置项列表
const settingItems = computed(() => [
{
id: 0,
label: t('user.language'),
value: getCurrentLanguageName(),
type: 'language'
},
{
id: 1,
label: t('user.hotline'),
@@ -89,9 +134,6 @@ const settingItems = computed(() => [
}
])
// 弹窗状态
const showQrCode = ref(false)
/**
* 获取导航栏高度
*/
@@ -113,6 +155,9 @@ const getNavbarHeight = () => {
*/
const handleSettingClick = (item: any) => {
switch (item.type) {
case 'language':
showLanguageSelect.value = true
break
case 'tel':
handlePhoneCall(item.value, item.label)
break
@@ -152,6 +197,27 @@ const previewQrCode = () => {
})
}
/**
* 选择语言
*/
const selectLanguage = (languageCode: string) => {
try {
uni.setLocale(languageCode)
// 保存语言设置到用户存储
sysStore.setLanguage(languageCode)
// 更新i18n语言
locale.value = languageCode
} catch (error) {
console.error('语言切换失败:', error)
uni.showToast({
title: t('user.languageChangeFailed'),
icon: 'none'
})
}
}
/**
* 检查版本更新
*/
@@ -337,4 +403,40 @@ $theme-color: #54a966;
margin: 0 auto;
}
}
.language-modal {
padding: 40rpx;
.modal-title {
display: block;
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 30rpx;
text-align: center;
}
.language-list {
.language-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx 20rpx;
border-bottom: 1rpx solid #f0f0f0;
&.active {
background-color: #f7faf9;
}
&:last-child {
border-bottom: none;
}
.language-name {
font-size: 30rpx;
color: #333;
}
}
}
}
</style>