更新:增加“图书首页”功能

This commit is contained in:
2025-11-10 17:38:23 +08:00
parent 577e782cd8
commit e39f47855b
18 changed files with 1634 additions and 430 deletions

View File

@@ -1,207 +0,0 @@
# 我的书单功能模块
## 概述
本模块是从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组件替换
- ✅ 完成国际化配置
- ✅ 完成所有功能页面

View File

@@ -1,24 +1,844 @@
<template>
<view class="container">
<view class="title bg-[red] text-center text-[#fff]">这是一个等待开发的图书首页</view>
<view class="description bg-[red]">内容是在线图书</view>
<view class="home-page">
<!-- 顶部背景区域 -->
<view class="home-bg" :style="{ paddingTop: notchHeight + 'px' }">
<wd-search
hide-cancel
light
clearable
class="search-bar"
:placeholder="$t('home.searchPlaceholder')"
@search="handleSearch"
/>
<view class="icon-hua">
<image
src="../../static/home_icon.png"
mode="aspectFit"
class="icon-hua-img"
/>
</view>
</view>
<!-- 内容区域 -->
<view class="content-wrapper">
<!-- 我的书单 & 推荐图书模块 -->
<view class="mine-block">
<!-- 我的书单 -->
<view class="mine-1">
<text class="mine-title">{{ $t('home.block1') }}</text>
<view
v-if="myBooksList.length > 0"
class="mine-more"
@click="handleMoreClick"
>
{{ $t('home.more') }}
<image src="@/static/icon/icon_right.png" />
</view>
<view v-if="myBooksList.length > 0" class="mine-1-list">
<view
v-for="(item, index) in myBooksList"
:key="index"
class="mine-item"
@click="handleMyBookClick(item.id)"
>
<image :src="item.images" />
<text>{{ item.name }}</text>
</view>
</view>
<text v-else class="zanwu">{{ $t('common.data_null') }}</text>
</view>
<!-- 推荐图书 -->
<view class="mine-2">
<text class="mine-title">{{ $t('home.block2') }}</text>
<swiper
v-if="recommendBooksList.length > 0"
autoplay
:interval="3000"
:duration="500"
class="recommend-list"
>
<swiper-item
v-for="(item, index) in recommendBooksList"
:key="index"
class="recommend-item"
@click="handleBookClick(item.id)"
>
<image :src="item.images" width="100%" height="100%" />
<text>{{ item.name }}</text>
</swiper-item>
</swiper>
</view>
</view>
<!-- 活动图书模块 -->
<view v-if="showActivity" class="activity-block">
<text class="activity-title">{{ $t('home.activityTitle') }}</text>
<scroll-view class="scroll-view" scroll-x :show-scrollbar="false">
<view class="activity-label-list">
<view
v-for="(item, index) in activityLabelList"
:key="index"
:class="[
'activity-label-item',
currentActivityIndex === index ? 'active-label' : ''
]"
@click="handleActivityLabelClick(item.id, index)"
>
<text>{{ item.title }}</text>
</view>
</view>
</scroll-view>
<scroll-view
v-if="activityList.length > 0"
class="scroll-view"
scroll-x
:show-scrollbar="false"
>
<view class="activity-list">
<view
v-for="(item, index) in activityList"
:key="index"
class="activity-item"
@click="handleBookClick(item.bookId)"
>
<image :src="item.images" />
<text class="activity-text">{{ item.name }}</text>
</view>
</view>
</scroll-view>
<text v-else class="zanwu" style="padding: 80rpx 0">{{ $t('global.dataNull') }}</text>
</view>
<!-- 分类图书模块 -->
<view v-if="showCategory" class="book-block">
<!-- 一级分类标签 -->
<scroll-view class="scroll-view" scroll-x :show-scrollbar="false">
<view class="book-tab-one">
<view
v-for="(item, index) in categoryLevel1List"
:key="index"
:class="[
'tab-one-item',
currentLevel1Index === index ? 'tab-one-active' : ''
]"
@click="handleCategoryLevel1Click(item.id, index)"
>
<text>{{ item.title }}</text>
</view>
</view>
</scroll-view>
<!-- 二级分类标签 -->
<scroll-view
v-if="categoryLevel2List.length > 0"
class="scroll-view"
scroll-x
:show-scrollbar="false"
style="background: #fff; margin-top: 15rpx"
>
<view class="book-tab-two">
<view
v-for="(item, index) in categoryLevel2List"
:key="index"
:class="[
'tab-two-item',
currentLevel2Index === index ? 'tab-two-active' : ''
]"
@click="handleCategoryLevel2Click(item.id, index)"
>
<text>{{ item.title }}</text>
</view>
</view>
</scroll-view>
<!-- 分类图书列表 -->
<view v-if="categoryBookList.length > 0" class="book-list">
<view
v-for="(item, index) in categoryBookList"
:key="index"
class="book-item"
@click="handleBookClick(item.bookId)"
>
<image :src="item.images" />
<text class="book-text">{{ item.name }}</text>
<text v-if="formatPrice(item)" class="book-price">{{
formatPrice(item)
}}</text>
<text v-if="formatStats(item)" class="book-flag">{{
formatStats(item)
}}</text>
</view>
</view>
<text v-else class="zanwu" style="padding: 100rpx 0">{{ $t('global.dataNull') }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { homeApi } from '@/api/modules/home'
import type {
IBook,
IBookWithStats,
ILabel,
IVipInfo
} from '@/types/book'
const { t } = useI18n()
// 状态定义
const notchHeight = ref(0)
const showMyBooks = ref(false)
const showActivity = ref(false)
const showCategory = ref(false)
// 我的书单
const myBooksList = ref<IBook[]>([])
// 推荐图书
const recommendBooksList = ref<IBook[]>([])
// 活动图书
const activityLabelList = ref<ILabel[]>([])
const activityList = ref<IBookWithStats[]>([])
const currentActivityIndex = ref(0)
// 分类图书
const categoryLevel1List = ref<ILabel[]>([])
const categoryLevel2List = ref<ILabel[]>([])
const categoryBookList = ref<IBookWithStats[]>([])
const currentLevel1Index = ref(0)
const currentLevel2Index = ref(0)
// VIP信息
const vipInfo = ref<IVipInfo | null>(null)
/**
* 获取VIP信息
*/
const getVipInfo = async () => {
try {
const res = await homeApi.getVipInfo()
if (res.vipInfo) {
vipInfo.value = res.vipInfo
}
} catch (error) {
console.error('获取VIP信息失败:', error)
}
}
/**
* 获取我的书单
*/
const getMyBooks = async () => {
try {
const res = await homeApi.getMyBooks(1, 10)
if (res && res.code === 0) {
showMyBooks.value = true
if (res.page.records && res.page.records.length > 0) {
myBooksList.value = res.page.records
}
} else {
// 未登录,跳转到登录页
uni.navigateTo({
url: '/pages/login/login'
})
}
} catch (error) {
console.error('获取我的书单失败:', error)
}
}
/**
* 获取推荐图书
*/
const getRecommendBooks = async () => {
try {
const res = await homeApi.getRecommendBooks()
if (res.books && res.books.length > 0) {
recommendBooksList.value = res.books
}
} catch (error) {
console.error('获取推荐图书失败:', error)
}
}
/**
* 获取活动标签列表
*/
const getActivityLabels = async () => {
try {
const res = await homeApi.getBookLabelList(1)
showActivity.value = true
if (res.lableList && res.lableList.length > 0) {
activityLabelList.value = res.lableList
// 默认加载第一个标签的图书列表
await getBooksByLabel(res.lableList[0].id, 'activity')
}
} catch (error) {
console.error('获取活动标签失败:', error)
}
}
/**
* 获取分类标签列表
*/
const getCategoryLabels = async () => {
try {
const res = await homeApi.getBookLabelList(0)
showCategory.value = true
if (res.lableList && res.lableList.length > 0) {
categoryLevel1List.value = res.lableList
// 默认加载第一个标签的二级标签
await getSubLabels(res.lableList[0].id, 0)
}
} catch (error) {
console.error('获取分类标签失败:', error)
}
}
/**
* 获取二级标签列表
*/
const getSubLabels = async (pid: number, index: number) => {
try {
const res = await homeApi.getSubLabelList(pid)
currentLevel1Index.value = index
if (res.lableList && res.lableList.length > 0) {
categoryLevel2List.value = res.lableList
currentLevel2Index.value = 0
// 加载第一个二级标签的图书列表
await getBooksByLabel(res.lableList[0].id, 'category')
} else {
// 没有二级标签,直接加载一级标签的图书列表
categoryLevel2List.value = []
await getBooksByLabel(pid, 'category')
}
} catch (error) {
console.error('获取二级标签失败:', error)
}
}
/**
* 根据标签获取图书列表
*/
const getBooksByLabel = async (
labelId: number,
type: 'activity' | 'category'
) => {
uni.showLoading({ title: t('common.loading') })
try {
const res = await homeApi.getBooksByLabel(labelId)
if (type === 'activity') {
if (res.bookList && res.bookList.length > 0) {
activityList.value = res.bookList
} else {
activityList.value = []
}
} else {
if (res.bookList && res.bookList.length > 0) {
categoryBookList.value = res.bookList
} else {
categoryBookList.value = []
}
}
} catch (error) {
console.error('获取图书列表失败:', error)
} finally {
uni.hideLoading()
}
}
/**
* 格式化价格
*/
const formatPrice = (book: IBookWithStats): string => {
// 已购买不显示价格
if (book.isBuy) return ''
// VIP用户且图书为VIP专享
if (vipInfo.value?.id && book.isVip === '2') {
const price = book.sysDictData?.dictValue
return price ? `$ ${price} NZD` : ''
}
// 普通用户
if (!vipInfo.value?.id) {
const price = book.sysDictData?.dictValue
return price ? `$ ${price} NZD` : ''
}
return ''
}
/**
* 格式化统计信息
*/
const formatStats = (book: IBookWithStats): string => {
if (book.readCount && book.readCount > 0) {
return `${book.readCount}${t('home.readingCount')}`
}
if (book.buyCount && book.buyCount > 0) {
return `${book.buyCount}${t('home.purchased')}`
}
return ''
}
/**
* 处理搜索点击
*/
const handleSearch = ({ value }: { value: string }) => {
uni.navigateTo({
url: `/pages/book/search?keyword=${value}`
})
}
/**
* 处理我的书单图书点击
*/
const handleMyBookClick = (bookId: number) => {
uni.navigateTo({
url: `/pages/book/reader?isBuy=0&bookId=${bookId}`
})
}
/**
* 处理图书点击
*/
const handleBookClick = (bookId: number) => {
uni.navigateTo({
url: `/pages/book/detail?id=${bookId}`
})
}
/**
* 处理更多按钮点击
*/
const handleMoreClick = () => {
uni.switchTab({
url: '/pages/book/index'
})
}
/**
* 处理活动标签点击
*/
const handleActivityLabelClick = async (labelId: number, index: number) => {
currentActivityIndex.value = index
await getBooksByLabel(labelId, 'activity')
}
/**
* 处理一级分类标签点击
*/
const handleCategoryLevel1Click = async (labelId: number, index: number) => {
await getSubLabels(labelId, index)
}
/**
* 处理二级分类标签点击
*/
const handleCategoryLevel2Click = async (labelId: number, index: number) => {
currentLevel2Index.value = index
await getBooksByLabel(labelId, 'category')
}
/**
* 页面加载
*/
onMounted(() => {
// 获取刘海高度
const systemInfo = uni.getSystemInfoSync()
notchHeight.value = systemInfo.safeArea.top
// 重置活动标签选中状态
currentActivityIndex.value = 0
showActivity.value = false
getVipInfo()
getMyBooks()
getRecommendBooks()
getActivityLabels()
getCategoryLabels()
})
</script>
<style>
.title {
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
<style lang="scss" scoped>
.home-page {
min-height: 100vh;
background: #f7faf9;
}
.home-bg {
background-image: url('@/static/icon/home_bg.jpg');
background-position: center center;
background-repeat: no-repeat;
background-size: cover;
padding: 30rpx;
position: relative;
height: 340rpx;
.icon-hua {
width: 100%;
text-align: center;
display: block;
padding-top: 20rpx;
.icon-hua-img {
width: 100%;
height: 160rpx;
}
}
}
.search-bar {
background-color: transparent !important;
}
.content-wrapper {
padding-bottom: 40rpx;
}
.mine-block {
padding: 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
.mine-1,
.mine-2 {
width: 49%;
height: 290rpx;
border-radius: 15rpx;
padding: 30rpx;
position: relative;
.mine-title {
font-size: 32rpx;
color: #333;
line-height: 32rpx;
}
.mine-more {
position: absolute;
top: 30rpx;
right: 5rpx;
font-size: 28rpx;
line-height: 34rpx;
color: #999;
image {
display: inline-block;
vertical-align: text-bottom;
width: 34rpx;
height: 34rpx;
}
}
.mine-1-list {
width: 260rpx;
overflow: hidden;
display: flex;
align-items: center;
.mine-item {
width: 110rpx;
text-align: center;
margin: 20rpx 25rpx 0 0;
image {
width: 110rpx;
height: 135rpx;
border-radius: 10rpx;
}
text {
display: block;
width: 120rpx;
font-size: 28rpx;
color: #333;
line-height: 32rpx;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-top: 10rpx;
}
}
}
.recommend-list {
width: 310rpx;
height: 164rpx;
margin-top: 20rpx;
.recommend-item {
display: flex;
align-items: center;
image {
width: 120rpx;
height: 164rpx;
border-radius: 10rpx;
}
text {
display: block;
width: 190rpx;
font-size: 28rpx;
color: #333;
line-height: 40rpx;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-left: 15rpx;
}
}
}
}
.description {
font-size: 14px;
opacity: 0.6;
margin-bottom: 15px;
.mine-1 {
background-image: linear-gradient(60deg, #fff9e9 20%, #fffbf2 100%);
}
.mine-2 {
background-image: linear-gradient(60deg, #fef0f0 20%, #fdf9f8 100%);
}
}
.activity-block {
margin: 0 20rpx;
padding: 20rpx;
background: #fff;
border-radius: 15rpx;
min-height: 445rpx;
.activity-title {
display: block;
font-size: 38rpx;
color: #333;
line-height: 40rpx;
padding: 15rpx 0 20rpx;
}
.activity-label-list {
display: flex;
align-items: center;
margin-top: 10rpx;
padding-left: 10rpx;
.activity-label-item {
display: flex;
align-items: center;
background-color: #d7ece8;
border-radius: 10rpx;
margin-right: 20rpx;
padding: 0 10rpx;
flex-shrink: 0;
text {
display: inline-block;
text-align: center;
min-width: 110rpx;
height: 58rpx;
line-height: 58rpx;
color: #55aa7f;
font-size: 30rpx;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.active-label {
background-color: #55aa7f;
text {
color: #fff;
}
}
}
.activity-list {
width: 100%;
margin-top: 20rpx;
padding-left: 10rpx;
display: flex;
align-items: center;
.activity-item {
margin-right: 25rpx;
text-align: center;
flex-shrink: 0;
image {
width: 160rpx;
height: 200rpx;
border-radius: 10rpx;
}
.activity-text {
display: block;
width: 160rpx;
font-size: 28rpx;
color: #333;
padding-top: 10rpx;
line-height: 40rpx;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
}
.book-block {
padding: 20rpx 20rpx 0;
.book-tab-one {
display: flex;
align-items: center;
.tab-one-item {
display: flex;
align-items: center;
justify-content: center;
background-color: #d7ece8;
border-radius: 15rpx;
margin-right: 15rpx;
text-align: center;
min-width: 25%;
height: 160rpx;
flex-shrink: 0;
text {
display: flex;
width: 98%;
font-size: 32rpx;
color: #55aa7f;
line-height: 50rpx;
overflow: hidden;
}
}
.tab-one-item:last-child {
margin-right: 0;
}
.tab-one-active {
background-color: #55aa7f;
text {
color: #fff;
}
}
}
.book-tab-two {
display: flex;
align-items: center;
height: 70rpx;
border-radius: 10rpx;
.tab-two-item {
min-width: 25%;
display: flex;
align-items: center;
justify-content: center;
border-right: 1rpx solid #acacac33;
flex-shrink: 0;
text {
display: block;
text-align: center;
width: 165rpx;
font-size: 28rpx;
line-height: 48rpx;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.tab-two-item:last-child {
border-right: none;
}
.tab-two-active {
text {
color: #55aa7f;
font-weight: bold;
}
}
}
.book-list {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
margin-top: 20rpx;
.book-item {
width: 49%;
background-color: #fff;
border-radius: 15rpx;
height: 575rpx;
position: relative;
margin-bottom: 20rpx;
image {
width: 85%;
margin: 25rpx auto 0;
border-radius: 15rpx;
height: 380rpx;
display: block;
}
.book-text {
display: block;
font-size: 28rpx;
color: #333;
line-height: 36rpx;
width: 80%;
margin: 15rpx auto 0;
max-height: 72rpx;
overflow: hidden;
}
.book-price {
position: absolute;
font-size: 28rpx;
color: #ff4703;
left: 30rpx;
bottom: 20rpx;
}
.book-flag {
display: block;
font-size: 26rpx;
color: #999;
position: absolute;
right: 6%;
bottom: 20rpx;
}
}
}
}
.zanwu {
display: block;
text-align: center;
font-size: 28rpx;
color: #999;
padding: 40rpx 0;
}
.scroll-view {
white-space: nowrap;
}
</style>

View File

@@ -99,6 +99,8 @@
<wd-icon v-if="isLocked(index)" name="lock" size="20px" />
</view>
</scroll-view>
<!-- 底部占位 -->
<view class="setting-ooter-placeholder"></view>
</view>
</wd-popup>
@@ -108,7 +110,7 @@
<!-- 切换语言 -->
<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-group v-model="currentLanguage" shape="button" style="background-color: transparent;" @change="changeBookLanguage">
<wd-radio v-for="lang in bookLanguages" :key="lang.language" :value="lang.language">{{ lang.language }}</wd-radio>
</wd-radio-group>
</view>
@@ -162,10 +164,9 @@
</view>
</view>
</view>
<!-- 底部占位 -->
<view class="setting-ooter-placeholder"></view>
</view>
<!-- 底部占位 -->
<view class="setting-ooter-placeholder"></view>
</wd-popup>
<!-- 空状态 -->
@@ -407,7 +408,7 @@ async function loadChapterContent(chapterId: number, index: number) {
}
// 切换章节
function switchChapter(chapter: IChapter, index: number) {
async function switchChapter(chapter: IChapter, index: number) {
if (isLocked(index)) {
uni.showToast({
title: t('book.afterPurchase'),
@@ -416,7 +417,8 @@ function switchChapter(chapter: IChapter, index: number) {
return
}
loadChapterContent(chapter.id, index)
await loadChapterContent(chapter.id, index)
showControls.value = false
}
// 判断章节是否锁定
@@ -796,6 +798,10 @@ function goBack() {
.setting-item {
margin-bottom: 40rpx;
&:last-child {
margin-bottom: 0;
}
.setting-label {
display: block;
@@ -866,11 +872,6 @@ function goBack() {
}
}
}
/* 底部占位 */
.setting-ooter-placeholder {
height: 80rpx;
}
}
.empty-state {
@@ -884,5 +885,10 @@ function goBack() {
color: #999;
}
}
/* 底部占位 */
.setting-ooter-placeholder {
height: 55px;
}
}
</style>

293
pages/book/search.vue Normal file
View File

@@ -0,0 +1,293 @@
<template>
<view class="search-page">
<!-- 搜索栏 -->
<nav-bar>
<template #title>
<view class="search-box">
<wd-search
v-model="keyword"
hide-cancel
@search="handleSearch"
/>
</view>
</template>
</nav-bar>
<!-- 搜索结果 -->
<view class="search-results">
<view v-if="loading" class="loading-wrapper">
<wd-loading />
</view>
<view v-else-if="searchResults.length > 0" class="book-list">
<view
v-for="(item, index) in searchResults"
:key="index"
class="book-item"
@click="handleBookClick(item.bookId)"
>
<image :src="item.images" />
<text class="book-text">{{ item.name }}</text>
<text v-if="formatPrice(item)" class="book-price">{{
formatPrice(item)
}}</text>
<text v-if="formatStats(item)" class="book-flag">{{
formatStats(item)
}}</text>
</view>
</view>
<view v-else-if="isEmpty" class="empty-wrapper">
<wd-status-tip image="content" :tip="$t('global.searchNoResult')" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import { homeApi } from '@/api/modules/home'
import type { IBookWithStats, IVipInfo } from '@/types/home'
const { t } = useI18n()
// 状态定义
const notchHeight = ref(0)
const keyword = ref('')
const searchResults = ref<IBookWithStats[]>([])
const loading = ref(false)
const isEmpty = ref(false)
const vipInfo = ref<IVipInfo | null>(null)
// 获取URL参数
onLoad((options: any) => {
keyword.value = options.keyword
handleSearch()
})
/**
* 获取VIP信息
*/
const getVipInfo = async () => {
try {
const res = await homeApi.getVipInfo()
if (res.vipInfo) {
vipInfo.value = res.vipInfo
}
} catch (error) {
console.error('获取VIP信息失败:', error)
}
}
/**
* 处理搜索
*/
const handleSearch = async () => {
if (!keyword.value.trim()) {
return
}
loading.value = true
isEmpty.value = false
try {
const res = await homeApi.searchBooks({
key: keyword.value.trim(),
page: 1,
limit: 10,
})
if (res.bookList && res.bookList.length > 0) {
searchResults.value = res.bookList
isEmpty.value = false
} else {
searchResults.value = []
isEmpty.value = true
}
} catch (error) {
console.error('搜索失败:', error)
searchResults.value = []
isEmpty.value = true
} finally {
loading.value = false
}
}
/**
* 处理清空
*/
const handleClear = () => {
keyword.value = ''
searchResults.value = []
isEmpty.value = false
}
/**
* 处理图书点击
*/
const handleBookClick = (bookId: number) => {
uni.navigateTo({
url: `/pages/book/detail?id=${bookId}`
})
}
/**
* 格式化价格
*/
const formatPrice = (book: IBookWithStats): string => {
// 已购买不显示价格
if (book.isBuy) return ''
// VIP用户且图书为VIP专享
if (vipInfo.value?.id && book.isVip === '2') {
const price = book.sysDictData?.dictValue
return price ? `$ ${price} NZD` : ''
}
// 普通用户
if (!vipInfo.value?.id) {
const price = book.sysDictData?.dictValue
return price ? `$ ${price} NZD` : ''
}
return ''
}
/**
* 格式化统计信息
*/
const formatStats = (book: IBookWithStats): string => {
if (book.readCount && book.readCount > 0) {
return `${book.readCount}${t('home.readingCount')}`
}
if (book.buyCount && book.buyCount > 0) {
return `${book.buyCount}${t('home.purchased')}`
}
return ''
}
/**
* 页面加载
*/
onMounted(async () => {
// 获取刘海高度
const systemInfo = uni.getSystemInfoSync()
notchHeight.value = systemInfo.safeArea.top
// 获取VIP信息
await getVipInfo()
})
</script>
<style lang="scss" scoped>
.search-page {
min-height: 100vh;
background: #f7faf9;
}
.search-box {
display: flex;
height: 100%;
align-items: center;
--wot-search-padding: 0;
--wot-search-side-padding: 0;
:deep() {
.wd-search {
background: transparent;
}
}
}
.search-header {
background: #fff;
padding: 20rpx;
padding-bottom: 20rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
.search-bar-wrapper {
display: flex;
align-items: center;
.back-icon {
margin-right: 20rpx;
flex-shrink: 0;
}
.search-input {
flex: 1;
}
}
}
.search-results {
padding: 20rpx;
}
.loading-wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: 100rpx 0;
}
.empty-wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: 100rpx 0;
}
.book-list {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
.book-item {
width: 49%;
background-color: #fff;
border-radius: 15rpx;
height: 575rpx;
position: relative;
margin-bottom: 20rpx;
image {
width: 85%;
margin: 25rpx auto 0;
border-radius: 15rpx;
height: 380rpx;
display: block;
}
.book-text {
display: block;
font-size: 28rpx;
color: #333;
line-height: 36rpx;
width: 80%;
margin: 15rpx auto 0;
max-height: 72rpx;
overflow: hidden;
}
.book-price {
position: absolute;
font-size: 28rpx;
color: #ff4703;
left: 30rpx;
bottom: 20rpx;
}
.book-flag {
display: block;
font-size: 26rpx;
color: #999;
position: absolute;
right: 6%;
bottom: 20rpx;
}
}
}
</style>

View File

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

View File

@@ -1,163 +0,0 @@
# 登录功能说明
## 功能概述
本模块实现了从 nuttyreading-hw2 项目迁移的完整登录功能,包括:
- ✅ 验证码登录/注册
- ✅ 密码登录
- ✅ 忘记密码
- ✅ 用户协议和隐私政策
- ✅ 游客体验入口
## 技术栈
- Vue3 Composition API
- TypeScript
- Pinia (状态管理)
- WotUI (UI 组件库)
- Tailwind CSS + SCSS
- UniApp
## 文件结构
```
pages/user/
├── login.vue # 登录页面
└── forget.vue # 忘记密码页面
api/modules/
├── auth.ts # 认证相关 API
└── common.ts # 通用 API
stores/
└── user.ts # 用户状态管理
types/
└── user.ts # 用户相关类型定义
utils/
└── validator.ts # 表单验证工具
```
## API 接口
### 登录相关
1. **验证码登录/注册**
- 接口:`GET book/user/registerOrLogin`
- 参数:`{ tel: string, code: string }`
2. **密码登录**
- 接口:`POST book/user/login`
- 参数:`{ phone: string, password: string }`
3. **重置密码**
- 接口:`POST book/user/setPassword`
- 参数:`{ phone: string, code: string, password: string }`
### 通用接口
1. **发送邮箱验证码**
- 接口:`GET common/user/getMailCaptcha`
- 参数:`{ email: string }`
2. **获取协议内容**
- 接口:`GET common/agreement/detail`
- 参数:`{ id: number }` (111: 用户协议, 112: 隐私政策)
## 使用说明
### 1. 登录页面
访问路径:`/pages/user/login`
**验证码登录**
1. 输入邮箱地址
2. 点击"Get Code"获取验证码
3. 输入收到的验证码
4. 勾选用户协议
5. 点击"Go Login"登录
**密码登录**
1. 点击"Password Login"切换到密码登录
2. 输入邮箱地址和密码
3. 勾选用户协议
4. 点击"Go Login"登录
### 2. 忘记密码
访问路径:`/pages/user/forget`
1. 输入邮箱地址
2. 点击"Get Code"获取验证码
3. 输入验证码
4. 输入新密码(需满足强度要求)
5. 再次输入新密码确认
6. 点击"Submit"提交
### 3. 密码强度要求
- **强密码**8位以上包含大小写字母、数字和特殊字符
- **中等密码**8位以上包含大小写字母、数字、特殊字符中的两项
- **弱密码**8位以上
- **最低要求**6-20位必须包含字母和数字
## 状态管理
使用 Pinia 管理用户状态:
```typescript
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 登录成功后设置用户信息
userStore.setUserInfo(userInfo)
// 检查登录状态
if (userStore.isLoggedIn) {
// 已登录
}
// 登出
userStore.logout()
```
## 国际化
支持中英文切换,翻译文件位于:
- `locale/en.json` - 英文
- `locale/zh-Hans.json` - 简体中文
## 注意事项
1. **API 地址**:已配置为 `https://global.nuttyreading.com/`
2. **请求头**:自动添加 `token``appType: 'abroad'``version_code`
3. **Token 失效**:自动处理 401 错误,清除用户信息并跳转登录页
4. **验证码倒计时**60秒防止重复发送
5. **协议同意**:登录和获取验证码前必须同意用户协议和隐私政策
## 测试建议
1. 测试验证码登录流程
2. 测试密码登录流程
3. 测试忘记密码流程
4. 测试登录方式切换
5. 测试表单验证
6. 测试协议弹窗
7. 测试多平台兼容性H5、小程序、APP
## 已知问题
- 游客页面 `/pages/visitor/visitor` 需要单独实现
- 部分图标可能需要根据实际设计调整
## 更新日志
### v1.0.0 (2025-11-02)
- ✅ 完成登录功能迁移
- ✅ 实现验证码登录和密码登录
- ✅ 实现忘记密码功能
- ✅ 添加用户协议和隐私政策
- ✅ 支持中英文国际化