更新:增加“我的书单”功能
This commit is contained in:
2
App.vue
2
App.vue
@@ -13,7 +13,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import "@/style/tailwind.css";
|
// @import "@/style/tailwind.css";
|
||||||
@import "@/style/ui.scss";
|
@import "@/style/ui.scss";
|
||||||
/* 覆盖 Tailwind 的默认 block 样式 */
|
/* 覆盖 Tailwind 的默认 block 样式 */
|
||||||
img, svg, video, canvas, audio, iframe, embed, object {
|
img, svg, video, canvas, audio, iframe, embed, object {
|
||||||
|
|||||||
204
api/modules/book.ts
Normal file
204
api/modules/book.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
// api/modules/book.ts
|
||||||
|
import { createRequestClient } from '../request'
|
||||||
|
import { SERVICE_MAP } from '../config'
|
||||||
|
import type { IApiResponse } from '../types'
|
||||||
|
import type {
|
||||||
|
IBook,
|
||||||
|
IBookDetail,
|
||||||
|
IPageData,
|
||||||
|
IChapter,
|
||||||
|
IChapterContent,
|
||||||
|
IComment,
|
||||||
|
IReadProgress
|
||||||
|
} from '@/types/book'
|
||||||
|
|
||||||
|
const client = createRequestClient({ baseURL: SERVICE_MAP.MAIN })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 书籍相关API
|
||||||
|
*/
|
||||||
|
export const bookApi = {
|
||||||
|
/**
|
||||||
|
* 获取我的书单列表
|
||||||
|
* @param current 当前页码
|
||||||
|
* @param limit 每页数量
|
||||||
|
*/
|
||||||
|
getMyBooks(current: number, limit: number) {
|
||||||
|
return client.request<IApiResponse<{ page: IPageData<IBook> }>>({
|
||||||
|
url: 'bookAbroad/home/getMyBooks',
|
||||||
|
method: 'POST',
|
||||||
|
data: { current, limit }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取书籍详情
|
||||||
|
* @param bookId 书籍ID
|
||||||
|
*/
|
||||||
|
getBookInfo(bookId: number) {
|
||||||
|
return client.request<IApiResponse<{ bookInfo: IBookDetail }>>({
|
||||||
|
url: 'bookAbroad/home/getBookInfo',
|
||||||
|
method: 'POST',
|
||||||
|
data: { bookId }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取书籍统计数据
|
||||||
|
* @param bookId 书籍ID
|
||||||
|
*/
|
||||||
|
getBookReadCount(bookId: number) {
|
||||||
|
return client.request<IApiResponse<{
|
||||||
|
readCount: number
|
||||||
|
listenCount: number
|
||||||
|
buyCount: number
|
||||||
|
}>>({
|
||||||
|
url: 'bookAbroad/home/getBookReadCount',
|
||||||
|
method: 'POST',
|
||||||
|
data: { bookId }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取推荐书籍
|
||||||
|
* @param bookId 书籍ID
|
||||||
|
*/
|
||||||
|
getRecommendBook(bookId: number) {
|
||||||
|
return client.request<IApiResponse<{ bookList: IBook[] }>>({
|
||||||
|
url: 'bookAbroad/home/getRecommendBook',
|
||||||
|
method: 'POST',
|
||||||
|
data: { bookId }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取评论列表
|
||||||
|
* @param bookId 书籍ID
|
||||||
|
* @param current 当前页码
|
||||||
|
* @param limit 每页数量
|
||||||
|
*/
|
||||||
|
getBookComments(bookId: number, current: number, limit: number) {
|
||||||
|
return client.request<IApiResponse<{
|
||||||
|
commentsTree: IComment[]
|
||||||
|
commentsCount: number
|
||||||
|
}>>({
|
||||||
|
url: 'bookAbroad/getBookAbroadCommentTree',
|
||||||
|
method: 'POST',
|
||||||
|
data: { bookId, current, limit }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发表评论
|
||||||
|
* @param bookId 书籍ID
|
||||||
|
* @param content 评论内容
|
||||||
|
* @param pid 父评论ID,默认为0表示顶级评论
|
||||||
|
*/
|
||||||
|
insertComment(bookId: number, content: string, pid: number = 0) {
|
||||||
|
return client.request<IApiResponse>({
|
||||||
|
url: 'bookAbroad/insertBookAbroadComment',
|
||||||
|
method: 'POST',
|
||||||
|
data: { bookId, content, pid }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点赞评论
|
||||||
|
* @param commentId 评论ID
|
||||||
|
*/
|
||||||
|
likeComment(commentId: number) {
|
||||||
|
return client.request<IApiResponse>({
|
||||||
|
url: 'bookAbroad/insertBookAbroadCommentLike',
|
||||||
|
method: 'POST',
|
||||||
|
data: { commentId }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消点赞
|
||||||
|
* @param commentId 评论ID
|
||||||
|
*/
|
||||||
|
unlikeComment(commentId: number) {
|
||||||
|
return client.request<IApiResponse>({
|
||||||
|
url: 'bookAbroad/delBookAbroadCommentLike',
|
||||||
|
method: 'POST',
|
||||||
|
data: { commentId }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除评论
|
||||||
|
* @param commentId 评论ID
|
||||||
|
*/
|
||||||
|
deleteComment(commentId: number) {
|
||||||
|
return client.request<IApiResponse>({
|
||||||
|
url: 'bookAbroad/delBookAbroadComment',
|
||||||
|
method: 'POST',
|
||||||
|
data: { commentId }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取章节列表
|
||||||
|
* @param bookId 书籍ID
|
||||||
|
*/
|
||||||
|
getBookChapter(data: { bookId: number; language: string }) {
|
||||||
|
return client.request<IApiResponse<{ chapterList: IChapter[] }>>({
|
||||||
|
url: 'bookAbroad/home/getBookChapter',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取章节内容
|
||||||
|
* @param chapterId 章节ID
|
||||||
|
*/
|
||||||
|
getChapterContent(chapterId: number) {
|
||||||
|
return client.request<IApiResponse<{ contentPage: IChapterContent[] }>>({
|
||||||
|
url: 'bookAbroad/home/getBookChapterContent',
|
||||||
|
method: 'POST',
|
||||||
|
data: { chapterId }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取阅读进度
|
||||||
|
* @param bookId 书籍ID
|
||||||
|
*/
|
||||||
|
getReadProgress(bookId: number) {
|
||||||
|
return client.request<IApiResponse<{ bookReadRate: IReadProgress | null }>>({
|
||||||
|
url: 'bookAbroad/home/getBookReadRate',
|
||||||
|
method: 'POST',
|
||||||
|
data: { bookId }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存阅读进度
|
||||||
|
* @param bookId 书籍ID
|
||||||
|
* @param chapterId 章节ID
|
||||||
|
* @param contentId 内容ID
|
||||||
|
*/
|
||||||
|
saveReadProgress(bookId: number, chapterId: number, contentId: number) {
|
||||||
|
return client.request<IApiResponse>({
|
||||||
|
url: 'bookAbroad/home/insertBookReadRate',
|
||||||
|
method: 'POST',
|
||||||
|
data: { bookId, chapterId, contentId }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取书籍语言列表
|
||||||
|
* @param bookId 书籍ID
|
||||||
|
* @param chapterId 章节ID
|
||||||
|
* @param contentId 内容ID
|
||||||
|
*/
|
||||||
|
getBookLanguages(bookId: number) {
|
||||||
|
return client.request<IApiResponse>({
|
||||||
|
url: 'bookAbroad/home/getBookLanguage',
|
||||||
|
method: 'POST',
|
||||||
|
data: { bookId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
168
components/book/BookCard.vue
Normal file
168
components/book/BookCard.vue
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<template>
|
||||||
|
<view class="book-card" @click="handleClick">
|
||||||
|
<image :src="book.images" class="cover" mode="aspectFill" />
|
||||||
|
<view class="info">
|
||||||
|
<text class="title">{{ book.name }}</text>
|
||||||
|
<view class="actions">
|
||||||
|
<view
|
||||||
|
class="action-btn read"
|
||||||
|
:style="isIOS ? 'width: 100px' : ''"
|
||||||
|
@click.stop="handleRead"
|
||||||
|
>
|
||||||
|
<view class="btn-icon read-icon">
|
||||||
|
<image src="@/static/icon/icon_look.png" mode="aspectFit" />
|
||||||
|
</view>
|
||||||
|
<text>{{ $t('book.read') }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="action-btn listen"
|
||||||
|
:style="isIOS ? 'width: 100px' : ''"
|
||||||
|
@click.stop="handleListen"
|
||||||
|
>
|
||||||
|
<view class="btn-icon listen-icon">
|
||||||
|
<image src="@/static/icon/icon_listen.png" mode="aspectFit" />
|
||||||
|
</view>
|
||||||
|
<text>{{ $t('book.listen') }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-if="!isIOS"
|
||||||
|
class="action-btn review"
|
||||||
|
@click.stop="handleReview"
|
||||||
|
>
|
||||||
|
<view class="btn-icon review-icon">
|
||||||
|
<image src="@/static/icon/icon_pl.png" mode="aspectFit" />
|
||||||
|
</view>
|
||||||
|
<text>{{ $t('book.comment') }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { IBook } from '@/types/book'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
book: IBook
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: []
|
||||||
|
read: []
|
||||||
|
listen: []
|
||||||
|
review: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isIOS = computed(() => {
|
||||||
|
// #ifdef APP-PLUS
|
||||||
|
return uni.getSystemInfoSync().platform === 'ios'
|
||||||
|
// #endif
|
||||||
|
// #ifndef APP-PLUS
|
||||||
|
return false
|
||||||
|
// #endif
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleClick = () => emit('click')
|
||||||
|
const handleRead = () => emit('read')
|
||||||
|
const handleListen = () => emit('listen')
|
||||||
|
const handleReview = () => emit('review')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.book-card {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 15rpx;
|
||||||
|
padding: 30rpx 0 30rpx 30rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 210rpx;
|
||||||
|
height: 270rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
flex: 1;
|
||||||
|
width: calc(100% - 210rpx);
|
||||||
|
padding-left: 40rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
padding-top: 40rpx;
|
||||||
|
display: block;
|
||||||
|
width: 95%;
|
||||||
|
font-size: 38rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
line-height: 40rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 50rpx;
|
||||||
|
margin-left: -15rpx;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 135rpx;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 85rpx;
|
||||||
|
height: 85rpx;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
image {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-icon {
|
||||||
|
background: #6bba6b;
|
||||||
|
box-shadow: 0 0 10px rgba(107, 186, 107, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listen-icon {
|
||||||
|
background: #f7cb5e;
|
||||||
|
box-shadow: 0 0 10px rgba(247, 203, 94, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-icon {
|
||||||
|
background: #888888;
|
||||||
|
box-shadow: 0 0 10px rgba(136, 136, 136, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
text {
|
||||||
|
display: block;
|
||||||
|
font-size: 26rpx;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 44rpx;
|
||||||
|
padding-top: 10rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
237
components/book/CommentList.vue
Normal file
237
components/book/CommentList.vue
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<template>
|
||||||
|
<view class="comment-list">
|
||||||
|
<view
|
||||||
|
v-for="comment in comments"
|
||||||
|
:key="comment.id"
|
||||||
|
class="comment-item"
|
||||||
|
>
|
||||||
|
<!-- 用户信息 -->
|
||||||
|
<view class="user-info">
|
||||||
|
<image
|
||||||
|
:src="comment.userEntity.avatar || defaultAvatar"
|
||||||
|
class="avatar"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view class="user-detail">
|
||||||
|
<text class="username">{{ getUserName(comment.userEntity) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 评论内容 -->
|
||||||
|
<view class="content" v-html="comment.content" />
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<view class="actions">
|
||||||
|
<view class="action-btn" @click="handleLike(comment)">
|
||||||
|
<wd-icon
|
||||||
|
:name="comment.isLike ? 'heart-filled' : 'heart'"
|
||||||
|
size="18px"
|
||||||
|
/>
|
||||||
|
<text>{{ comment.likeCount || 0 }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="comment.pid === 0" class="action-btn" @click="handleReply(comment)">
|
||||||
|
<wd-icon name="chat" size="16px" />
|
||||||
|
<text>{{ comment.children?.length || 0 }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="action-btn" @click="handleDelete(comment)">
|
||||||
|
<text class="time">{{ comment.createTime }}</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-if="isMyComment(comment)"
|
||||||
|
class="action-btn delete"
|
||||||
|
@click="handleDelete(comment)"
|
||||||
|
>
|
||||||
|
<wd-icon name="delete-outline" size="16px" />
|
||||||
|
<text>{{ $t('common.delete') }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 子评论 -->
|
||||||
|
<view v-if="comment.children?.length" class="children-comments">
|
||||||
|
<CommentList
|
||||||
|
:comments="comment.children"
|
||||||
|
@like="handleLike"
|
||||||
|
@reply="handleReply"
|
||||||
|
@delete="handleDelete"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 加载更多 -->
|
||||||
|
<view v-if="hasMore" class="load-more" @click="handleLoadMore">
|
||||||
|
<text>{{ $t('common.loadMore') }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import type { IComment } from '@/types/book'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
comments: IComment[]
|
||||||
|
hasMore?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
hasMore: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
like: [comment: IComment]
|
||||||
|
reply: [comment: IComment]
|
||||||
|
delete: [comment: IComment]
|
||||||
|
loadMore: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const defaultAvatar = '/static/icon/morenAvavter.png'
|
||||||
|
|
||||||
|
const isMyComment = (comment: IComment) => {
|
||||||
|
return comment.userEntity.id === userStore.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserName = (user: any) => {
|
||||||
|
return user.nickname || user.name || 'TA'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (time: string) => {
|
||||||
|
if (!time) return ''
|
||||||
|
// 简单的时间格式化,可以根据需要优化
|
||||||
|
try {
|
||||||
|
const date = new Date(time)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - date.getTime()
|
||||||
|
|
||||||
|
const minute = 60 * 1000
|
||||||
|
const hour = 60 * minute
|
||||||
|
const day = 24 * hour
|
||||||
|
|
||||||
|
if (diff < minute) {
|
||||||
|
return t('common.justNow')
|
||||||
|
} else if (diff < hour) {
|
||||||
|
return `${Math.floor(diff / minute)}${t('common.minutesAgo')}`
|
||||||
|
} else if (diff < day) {
|
||||||
|
return `${Math.floor(diff / hour)}${t('common.hoursAgo')}`
|
||||||
|
} else if (diff < 7 * day) {
|
||||||
|
return `${Math.floor(diff / day)}${t('common.daysAgo')}`
|
||||||
|
} else {
|
||||||
|
return `${date.getMonth() + 1}-${date.getDate()}`
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLike = (comment: IComment) => emit('like', comment)
|
||||||
|
const handleReply = (comment: IComment) => emit('reply', comment)
|
||||||
|
const handleDelete = (comment: IComment) => emit('delete', comment)
|
||||||
|
const handleLoadMore = () => emit('loadMore')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.comment-list {
|
||||||
|
.comment-item {
|
||||||
|
padding: 20rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f0f0f0;
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 15rpx;
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 15rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.username {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
display: block;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
line-height: 28rpx;
|
||||||
|
margin-top: 5rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
line-height: 40rpx;
|
||||||
|
margin-bottom: 15rpx;
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
|
::v-deep img {
|
||||||
|
width: 45rpx !important;
|
||||||
|
height: 45rpx !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30rpx;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.delete text {
|
||||||
|
color: #ff4703;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.children-comments {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
margin-left: 75rpx;
|
||||||
|
padding-left: 20rpx;
|
||||||
|
border-left: 2rpx solid #f0f0f0;
|
||||||
|
|
||||||
|
.comment-item {
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 15rpx 0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
padding: 30rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -90,7 +90,16 @@
|
|||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
"networkError": "Network Error",
|
"networkError": "Network Error",
|
||||||
"pleaseInput": "Please Input",
|
"pleaseInput": "Please Input",
|
||||||
"pleaseSelect": "Please Select"
|
"pleaseSelect": "Please Select",
|
||||||
|
"data_null": "No Data",
|
||||||
|
"submit_text": "Submit",
|
||||||
|
"cancel_text": "Cancel",
|
||||||
|
"confirm_text": "Confirm",
|
||||||
|
"limit_title": "Tips",
|
||||||
|
"justNow": "Just Now",
|
||||||
|
"minutesAgo": " mins ago",
|
||||||
|
"hoursAgo": " hours ago",
|
||||||
|
"daysAgo": " days ago"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"title": "My",
|
"title": "My",
|
||||||
@@ -100,6 +109,10 @@
|
|||||||
"about": "About Us",
|
"about": "About Us",
|
||||||
"feedback": "Feedback",
|
"feedback": "Feedback",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
|
"language": "Language",
|
||||||
|
"languageSelect": "Select Language",
|
||||||
|
"languageChangeSuccess": "Language changed successfully",
|
||||||
|
"languageChangeFailed": "Failed to change language",
|
||||||
"subscribe": "Subscribe",
|
"subscribe": "Subscribe",
|
||||||
"myAccount": "My Account",
|
"myAccount": "My Account",
|
||||||
"notSet": "Not Set",
|
"notSet": "Not Set",
|
||||||
@@ -188,5 +201,65 @@
|
|||||||
"yearCard": "Yearly",
|
"yearCard": "Yearly",
|
||||||
"days": "days",
|
"days": "days",
|
||||||
"selectPackage": "Please select a package"
|
"selectPackage": "Please select a package"
|
||||||
|
},
|
||||||
|
"book": {
|
||||||
|
"title": "My Books",
|
||||||
|
"myBook": "My Books",
|
||||||
|
"read": "Read",
|
||||||
|
"listen": "Listen",
|
||||||
|
"comment": "Review",
|
||||||
|
"choose": "Browse Books",
|
||||||
|
"nullText": "No books yet, go shopping~",
|
||||||
|
"afterPurchase": "Available after purchase",
|
||||||
|
"contents": "Contents",
|
||||||
|
"zjContents": "Chapter List",
|
||||||
|
"set": "Settings",
|
||||||
|
"font": "Font Size",
|
||||||
|
"bgColor": "Background",
|
||||||
|
"type": "Reading Mode",
|
||||||
|
"type_1": "Scroll",
|
||||||
|
"type_2": "Page Turn",
|
||||||
|
"voices_null": "No audio available",
|
||||||
|
"endText": "Trial ended, purchase to continue",
|
||||||
|
"language": "Book language"
|
||||||
|
},
|
||||||
|
"bookDetails": {
|
||||||
|
"title": "Book Details",
|
||||||
|
"title_comment": "Reviews",
|
||||||
|
"authorName": "Author: ",
|
||||||
|
"introduction": "Introduction",
|
||||||
|
"message": "Reviews",
|
||||||
|
"more": "More",
|
||||||
|
"relatedBooks": "Related Books",
|
||||||
|
"startReading": "Start Reading",
|
||||||
|
"startListening": "Start Listening",
|
||||||
|
"tryRead": "Try Reading",
|
||||||
|
"tryListen": "Try Listening",
|
||||||
|
"buy": "Buy Now",
|
||||||
|
"buttonText1": "Buy Now",
|
||||||
|
"buttonText2": "Purchased",
|
||||||
|
"list": "Select",
|
||||||
|
"makeComment": "Write Review",
|
||||||
|
"reply": "Reply to ",
|
||||||
|
"dpl": "'s comment",
|
||||||
|
"enterText": "Enter your comment",
|
||||||
|
"replyText": "Reply to ",
|
||||||
|
"supportSuccess": "Liked",
|
||||||
|
"supportCancel": "Unliked",
|
||||||
|
"deleteText": "Delete this comment?",
|
||||||
|
"deleteSuccess": "Deleted"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"readingCount": " reads",
|
||||||
|
"listenCount": " listens",
|
||||||
|
"purchased": " purchased"
|
||||||
|
},
|
||||||
|
"listen": {
|
||||||
|
"title": "Audio Book",
|
||||||
|
"speed": "Playback Speed",
|
||||||
|
"chapterList": "Chapter List"
|
||||||
|
},
|
||||||
|
"workOrder": {
|
||||||
|
"submit_success": "Submitted successfully"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,16 @@
|
|||||||
"failed": "操作失败",
|
"failed": "操作失败",
|
||||||
"networkError": "网络连接失败",
|
"networkError": "网络连接失败",
|
||||||
"pleaseInput": "请输入",
|
"pleaseInput": "请输入",
|
||||||
"pleaseSelect": "请选择"
|
"pleaseSelect": "请选择",
|
||||||
|
"data_null": "暂无数据",
|
||||||
|
"submit_text": "提交",
|
||||||
|
"cancel_text": "取消",
|
||||||
|
"confirm_text": "确定",
|
||||||
|
"limit_title": "提示",
|
||||||
|
"justNow": "刚刚",
|
||||||
|
"minutesAgo": "分钟前",
|
||||||
|
"hoursAgo": "小时前",
|
||||||
|
"daysAgo": "天前"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"title": "我的",
|
"title": "我的",
|
||||||
@@ -101,6 +110,10 @@
|
|||||||
"about": "关于我们",
|
"about": "关于我们",
|
||||||
"feedback": "问题反馈",
|
"feedback": "问题反馈",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
|
"language": "语言",
|
||||||
|
"languageSelect": "选择语言",
|
||||||
|
"languageChangeSuccess": "语言切换成功",
|
||||||
|
"languageChangeFailed": "语言切换失败",
|
||||||
"subscribe": "订阅",
|
"subscribe": "订阅",
|
||||||
"myAccount": "我的账户",
|
"myAccount": "我的账户",
|
||||||
"notSet": "未设置",
|
"notSet": "未设置",
|
||||||
@@ -189,5 +202,65 @@
|
|||||||
"yearCard": "年卡",
|
"yearCard": "年卡",
|
||||||
"days": "天",
|
"days": "天",
|
||||||
"selectPackage": "请选择套餐"
|
"selectPackage": "请选择套餐"
|
||||||
|
},
|
||||||
|
"book": {
|
||||||
|
"title": "我的书单",
|
||||||
|
"myBook": "我的书单",
|
||||||
|
"read": "阅读",
|
||||||
|
"listen": "听书",
|
||||||
|
"comment": "书评",
|
||||||
|
"choose": "去选书",
|
||||||
|
"nullText": "暂无书籍,快去选购吧~",
|
||||||
|
"afterPurchase": "购买后即可使用此功能",
|
||||||
|
"contents": "目录",
|
||||||
|
"zjContents": "章节目录",
|
||||||
|
"set": "设置",
|
||||||
|
"font": "字体大小",
|
||||||
|
"bgColor": "背景颜色",
|
||||||
|
"type": "阅读模式",
|
||||||
|
"type_1": "上下滚动",
|
||||||
|
"type_2": "左右翻页",
|
||||||
|
"voices_null": "暂无音频文件",
|
||||||
|
"endText": "试读已结束,购买后继续阅读",
|
||||||
|
"language": "书籍语言"
|
||||||
|
},
|
||||||
|
"bookDetails": {
|
||||||
|
"title": "书籍详情",
|
||||||
|
"title_comment": "书评",
|
||||||
|
"authorName": "作者:",
|
||||||
|
"introduction": "内容简介",
|
||||||
|
"message": "书评",
|
||||||
|
"more": "更多",
|
||||||
|
"relatedBooks": "相关推荐",
|
||||||
|
"startReading": "开始阅读",
|
||||||
|
"startListening": "开始听书",
|
||||||
|
"tryRead": "试读",
|
||||||
|
"tryListen": "试听",
|
||||||
|
"buy": "立即购买",
|
||||||
|
"buttonText1": "立即购买",
|
||||||
|
"buttonText2": "已购买",
|
||||||
|
"list": "选择规格",
|
||||||
|
"makeComment": "发表评论",
|
||||||
|
"reply": "回复",
|
||||||
|
"dpl": "的评论",
|
||||||
|
"enterText": "请输入评论内容",
|
||||||
|
"replyText": "回复",
|
||||||
|
"supportSuccess": "点赞成功",
|
||||||
|
"supportCancel": "取消点赞",
|
||||||
|
"deleteText": "确定删除此评论吗?",
|
||||||
|
"deleteSuccess": "删除成功"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"readingCount": "次阅读",
|
||||||
|
"listenCount": "次听书",
|
||||||
|
"purchased": "人购买"
|
||||||
|
},
|
||||||
|
"listen": {
|
||||||
|
"title": "听书",
|
||||||
|
"speed": "播放速度",
|
||||||
|
"chapterList": "章节列表"
|
||||||
|
},
|
||||||
|
"workOrder": {
|
||||||
|
"submit_success": "提交成功"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
main.js
16
main.js
@@ -7,21 +7,6 @@ let i18nConfig = {
|
|||||||
messages
|
messages
|
||||||
}
|
}
|
||||||
|
|
||||||
// #ifndef VUE3
|
|
||||||
import Vue from 'vue'
|
|
||||||
import VueI18n from 'vue-i18n''
|
|
||||||
Vue.use(VueI18n)
|
|
||||||
export i18n = new VueI18n(i18nConfig)
|
|
||||||
Vue.config.productionTip = false
|
|
||||||
App.mpType = 'app'
|
|
||||||
const app = new Vue({
|
|
||||||
i18n,
|
|
||||||
...App
|
|
||||||
})
|
|
||||||
app.$mount()
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// #ifdef VUE3
|
|
||||||
import { createSSRApp } from 'vue'
|
import { createSSRApp } from 'vue'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
@@ -35,4 +20,3 @@ export function createApp() {
|
|||||||
app
|
app
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// #endif
|
|
||||||
|
|||||||
42
pages.json
42
pages.json
@@ -73,6 +73,48 @@
|
|||||||
"navigationBarTitleText": "%user.feedback%",
|
"navigationBarTitleText": "%user.feedback%",
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
|
}, {
|
||||||
|
"path": "pages/user/myBook/index",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "%book.title%"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"path": "pages/book/index",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "%book.title%"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"path": "pages/book/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "%details.title%"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"path": "pages/book/review",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "%details.title_comment%"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"path": "pages/book/reader",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "%book.read%"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"path": "pages/book/listen/index",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "%listen.title%"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"path": "pages/book/listen/player",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "%listen.title%"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
// "tabBar": {
|
// "tabBar": {
|
||||||
|
|||||||
207
pages/book/README.md
Normal file
207
pages/book/README.md
Normal 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
620
pages/book/detail.vue
Normal 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
24
pages/book/index.vue
Normal 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
295
pages/book/listen/index.vue
Normal 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>
|
||||||
614
pages/book/listen/player.vue
Normal file
614
pages/book/listen/player.vue
Normal 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
888
pages/book/reader.vue
Normal 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
513
pages/book/review.vue
Normal 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>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<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 class="description bg-[red]">首页的内容是在线课程</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ const menuItems = computed(() => [
|
|||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
name: t('user.myBooklist'),
|
name: t('user.myBooklist'),
|
||||||
url: '/pages/book/index',
|
url: '/pages/user/myBook/index',
|
||||||
type: 'switchTab'
|
type: 'pageJump'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
|
|||||||
225
pages/user/myBook/index.vue
Normal file
225
pages/user/myBook/index.vue
Normal 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>
|
||||||
@@ -1,21 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="order-page">
|
<view class="order-page">
|
||||||
<!-- 自定义导航栏 -->
|
<!-- 自定义导航栏 -->
|
||||||
<view class="custom-navbar" :style="{ height: navbarHeight }">
|
<nav-bar :title="$t('user.myOrders')"></nav-bar>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 订单列表 -->
|
<!-- 订单列表 -->
|
||||||
<scroll-view
|
<scroll-view
|
||||||
v-if="orderList.length > 0"
|
v-if="orderList.length > 0"
|
||||||
scroll-y
|
scroll-y
|
||||||
class="order-scroll"
|
class="order-scroll"
|
||||||
:style="{ paddingTop: navbarHeight }"
|
|
||||||
@scrolltolower="loadMore"
|
@scrolltolower="loadMore"
|
||||||
>
|
>
|
||||||
<view class="order-list">
|
<view class="order-list">
|
||||||
|
|||||||
@@ -42,27 +42,72 @@
|
|||||||
</view>
|
</view>
|
||||||
</wd-popup>
|
</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 />
|
<wd-message-box />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useSysStore } from '@/stores/sys'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useMessage } from '@/uni_modules/wot-design-uni'
|
import { useMessage } from '@/uni_modules/wot-design-uni'
|
||||||
import { makePhoneCall, copyToClipboard } from '@/utils/index'
|
import { makePhoneCall, copyToClipboard } from '@/utils/index'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
|
const sysStore = useSysStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
// 导航栏高度
|
// 导航栏高度
|
||||||
const statusBarHeight = ref(0)
|
const statusBarHeight = ref(0)
|
||||||
const navbarHeight = ref('44px')
|
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(() => [
|
const settingItems = computed(() => [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
label: t('user.language'),
|
||||||
|
value: getCurrentLanguageName(),
|
||||||
|
type: 'language'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
label: t('user.hotline'),
|
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) => {
|
const handleSettingClick = (item: any) => {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
|
case 'language':
|
||||||
|
showLanguageSelect.value = true
|
||||||
|
break
|
||||||
case 'tel':
|
case 'tel':
|
||||||
handlePhoneCall(item.value, item.label)
|
handlePhoneCall(item.value, item.label)
|
||||||
break
|
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;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -207,6 +207,9 @@
|
|||||||
.block {
|
.block {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
.contents {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
.flex {
|
.flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
@@ -247,14 +250,20 @@
|
|||||||
border-style: var(--tw-border-style);
|
border-style: var(--tw-border-style);
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
}
|
}
|
||||||
|
.bg-\[blue\] {
|
||||||
|
background-color: blue;
|
||||||
|
}
|
||||||
.bg-\[red\] {
|
.bg-\[red\] {
|
||||||
background-color: red;
|
background-color: red;
|
||||||
}
|
}
|
||||||
|
.bg-\[transparent\] {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
.text-center {
|
.text-center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.text-left {
|
.text-\[\#000\] {
|
||||||
text-align: left;
|
color: #000;
|
||||||
}
|
}
|
||||||
.text-\[\#fff\] {
|
.text-\[\#fff\] {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
|||||||
88
stores/book.ts
Normal file
88
stores/book.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// stores/book.ts
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { IBookDetail, IBook, IReaderSettings, IReadProgress } from '@/types/book'
|
||||||
|
|
||||||
|
interface BookState {
|
||||||
|
currentBook: IBookDetail | null
|
||||||
|
myBooks: IBook[]
|
||||||
|
readerSettings: IReaderSettings
|
||||||
|
readProgressCache: Record<number, IReadProgress>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBookStore = defineStore('book', {
|
||||||
|
state: (): BookState => ({
|
||||||
|
// 当前查看的书籍
|
||||||
|
currentBook: null,
|
||||||
|
|
||||||
|
// 书单列表
|
||||||
|
myBooks: [],
|
||||||
|
|
||||||
|
// 阅读器设置
|
||||||
|
readerSettings: {
|
||||||
|
fontSize: 1,
|
||||||
|
lineHeight: 34,
|
||||||
|
theme: 'default',
|
||||||
|
readMode: 'scroll'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 阅读进度缓存
|
||||||
|
readProgressCache: {}
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
/**
|
||||||
|
* 获取指定书籍的阅读进度
|
||||||
|
*/
|
||||||
|
getReadProgress: (state) => (bookId: number) => {
|
||||||
|
return state.readProgressCache[bookId]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
/**
|
||||||
|
* 设置当前书籍
|
||||||
|
*/
|
||||||
|
setCurrentBook(book: IBookDetail) {
|
||||||
|
this.currentBook = book
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新阅读设置
|
||||||
|
*/
|
||||||
|
updateReaderSettings(settings: Partial<IReaderSettings>) {
|
||||||
|
Object.assign(this.readerSettings, settings)
|
||||||
|
// 持久化到本地存储
|
||||||
|
uni.setStorageSync('readerSettings', this.readerSettings)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存阅读进度
|
||||||
|
*/
|
||||||
|
cacheReadProgress(bookId: number, progress: IReadProgress) {
|
||||||
|
this.readProgressCache[bookId] = progress
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从本地存储恢复设置
|
||||||
|
*/
|
||||||
|
restoreSettings() {
|
||||||
|
try {
|
||||||
|
const settings = uni.getStorageSync('readerSettings')
|
||||||
|
if (settings) {
|
||||||
|
this.readerSettings = settings
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to restore reader settings:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置store
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.currentBook = null
|
||||||
|
this.myBooks = []
|
||||||
|
this.readProgressCache = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
22
stores/sys.ts
Normal file
22
stores/sys.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// stores/user.ts
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { setAuthToken, clearAuthToken } from '@/utils/auth'
|
||||||
|
import type { IUserInfo } from '@/types/user'
|
||||||
|
|
||||||
|
export const useSysStore = defineStore('sys', {
|
||||||
|
state: (): IUserInfo => ({
|
||||||
|
language: uni.getStorageSync('appLanguage') || 'zh-CN',
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
/** 设置语言 */
|
||||||
|
setLanguage(language: string) {
|
||||||
|
this.language = language
|
||||||
|
uni.setStorageSync('appLanguage', language)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -36,6 +36,12 @@ export const useUserStore = defineStore('user', {
|
|||||||
uni.setStorageSync('userInfo', userInfo)
|
uni.setStorageSync('userInfo', userInfo)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 设置语言 */
|
||||||
|
setLanguage(language: string) {
|
||||||
|
this.language = language
|
||||||
|
uni.setStorageSync('appLanguage', language)
|
||||||
|
},
|
||||||
|
|
||||||
/** 登出 */
|
/** 登出 */
|
||||||
logout() {
|
logout() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
91
types/book.d.ts
vendored
Normal file
91
types/book.d.ts
vendored
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// types/book.d.ts
|
||||||
|
/**
|
||||||
|
* 书籍相关类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 作者信息 */
|
||||||
|
export interface IAuthor {
|
||||||
|
authorName: string
|
||||||
|
introduction: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 价格信息 */
|
||||||
|
export interface IPriceData {
|
||||||
|
dictValue: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 书籍基础信息 */
|
||||||
|
export interface IBook {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
images: string
|
||||||
|
author?: IAuthor
|
||||||
|
isBuy: boolean
|
||||||
|
freeChapterCount: number
|
||||||
|
priceData?: IPriceData
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 书籍详情信息 */
|
||||||
|
export interface IBookDetail extends IBook {
|
||||||
|
author: IAuthor
|
||||||
|
readCount?: number
|
||||||
|
listenCount?: number
|
||||||
|
buyCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页数据 */
|
||||||
|
export interface IPageData<T> {
|
||||||
|
records: T[]
|
||||||
|
total: number
|
||||||
|
current: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 章节信息 */
|
||||||
|
export interface IChapter {
|
||||||
|
id: number
|
||||||
|
chapter: string
|
||||||
|
content?: string
|
||||||
|
voices?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 章节内容 */
|
||||||
|
export interface IChapterContent {
|
||||||
|
id: number
|
||||||
|
content: string
|
||||||
|
otherContent?: string // 图片尺寸信息 "width,height"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户实体 */
|
||||||
|
export interface IUserEntity {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
nickname: string
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 评论信息 */
|
||||||
|
export interface IComment {
|
||||||
|
id: number
|
||||||
|
content: string
|
||||||
|
createTime: string
|
||||||
|
isLike: number
|
||||||
|
likeCount: number
|
||||||
|
userEntity: IUserEntity
|
||||||
|
children?: IComment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 阅读进度 */
|
||||||
|
export interface IReadProgress {
|
||||||
|
bookId: number
|
||||||
|
chapterId: number
|
||||||
|
contentId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 阅读器设置 */
|
||||||
|
export interface IReaderSettings {
|
||||||
|
fontSize: number
|
||||||
|
lineHeight: number
|
||||||
|
theme: 'default' | 'blue' | 'green' | 'purple' | 'night'
|
||||||
|
readMode: 'scroll' | 'page'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user