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

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

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

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

View File

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