Browse Source

feat: 实现书籍详情页和首页API集成及功能优化

1. 新增API模块统一管理所有接口,包括首页、书籍详情、书架等模块
2. 实现首页数据动态加载,包括轮播图、公告、推荐书籍和最新书籍
3. 完成书籍详情页API集成,展示书籍信息、章节目录和统计数据
4. 优化面包屑导航和路由配置,新增公告详情页路由
5. 重构书籍卡片组件,支持动态状态显示和样式优化
6. 添加加载状态处理,使用骨架屏提升用户体验
7. 实现分类菜单动态渲染,从store获取分类数据

新增API类型定义和文档说明,统一接口响应格式处理
master
前端-胡立永 6 months ago
parent
commit
9db2a5e286
25 changed files with 7177 additions and 751 deletions
  1. +241
    -0
      src/api/README.md
  2. +3817
    -0
      src/api/api.json
  3. +200
    -0
      src/api/auth.js
  4. +156
    -0
      src/api/bookshelf.js
  5. +321
    -0
      src/api/example.js
  6. +157
    -0
      src/api/home.js
  7. +142
    -7
      src/api/index.js
  8. +41
    -0
      src/api/modules.js
  9. +144
    -0
      src/api/types.ts
  10. +268
    -0
      src/api/user.js
  11. +48
    -2
      src/components/book/BookCatalog.vue
  12. +285
    -85
      src/components/book/BookComments.vue
  13. +7
    -8
      src/components/book/BookStats.vue
  14. +38
    -9
      src/components/common/BookCard.vue
  15. +163
    -106
      src/components/ranking/IntimacyRanking.vue
  16. +2
    -1
      src/layout/components/Breadcrumb.vue
  17. +22
    -4
      src/layout/layout/Header.vue
  18. +6
    -0
      src/router/index.js
  19. +33
    -3
      src/store/index.js
  20. +368
    -95
      src/views/book/chapter.vue
  21. +79
    -79
      src/views/book/index.vue
  22. +64
    -124
      src/views/home/Home.vue
  23. +330
    -0
      src/views/home/NoticeDetail.vue
  24. +120
    -107
      src/views/home/category.vue
  25. +125
    -121
      src/views/home/ranking.vue

+ 241
- 0
src/api/README.md View File

@ -0,0 +1,241 @@
# 小说网站 API 接口文档
本项目基于提供的 Swagger API 文档创建了完整的前端接口调用封装。
## 📁 文件结构
```
src/api/
├── index.js # axios 基础配置
├── auth.js # 授权登录相关接口
├── home.js # 首页相关接口
├── bookshelf.js # 书架相关接口(阅读书架、我的作品、读者成就)
├── user.js # 用户相关接口(流水、评论、任务、申请作家、礼物订阅)
├── modules.js # 统一导出所有API模块
├── example.js # 使用示例
├── types.ts # TypeScript 类型定义
├── api.json # 原始 Swagger 文档
└── README.md # 本文档
```
## 🚀 快速开始
### 1. 基础配置
项目已配置好 axios 实例,自动处理:
- ✅ X-Access-Token 认证头
- ✅ 统一的响应格式处理
- ✅ 错误处理和401重定向
- ✅ 请求超时设置
### 2. 使用方式
#### 方式一:按需导入
```javascript
import { authApi, homeApi, commentApi } from '@/api/modules.js';
// 用户登录
const loginResult = await authApi.appletLogin(loginData);
// 获取首页banner
const banner = await homeApi.getBanner();
// 发表评论
await commentApi.saveComment({ bookId: '123', content: '很棒的小说!' });
```
#### 方式二:全量导入
```javascript
import api from '@/api/modules.js';
// 使用方式
const books = await api.myBook.getMyShopPage({ pageNo: 1, pageSize: 10 });
const userInfo = await api.auth.getUserByToken();
```
### 3. 在 Vue 组件中使用
```vue
<template>
<div>
<div v-if="loading">加载中...</div>
<div v-else-if="error">{{ error }}</div>
<div v-else>
<!-- 渲染数据 -->
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { homeApi } from '@/api/modules.js';
const loading = ref(false);
const error = ref(null);
const bannerList = ref([]);
const fetchBanner = async () => {
loading.value = true;
try {
const response = await homeApi.getBanner();
bannerList.value = response.result;
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
};
onMounted(fetchBanner);
</script>
```
## 📚 API 模块说明
### 🔐 授权登录模块 (authApi)
```javascript
// 微信小程序登录
authApi.appletLogin(params)
// 绑定手机号
authApi.bindPhone(code)
// 获取用户信息
authApi.getUserByToken()
// 更新用户信息
authApi.updateUserInfo(userInfo)
// 获取平台配置
authApi.getConfig()
```
### 🏠 首页模块 (homeApi)
```javascript
// 获取banner
homeApi.getBanner()
// 获取分类列表
homeApi.getCategoryList()
// 获取书籍详情
homeApi.getBookDetail({ id: bookId })
// 获取书籍目录
homeApi.getBookCatalogList({ bookId, pageNo: 1, pageSize: 20 })
// 获取章节详情
homeApi.getBookCatalogDetail(chapterId)
// 投票
homeApi.vote({ bookId, num: '1' })
```
### 📚 书架模块
#### 阅读书架 (readBookApi)
```javascript
// 添加到阅读记录
readBookApi.addReadBook(bookData)
// 获取阅读记录
readBookApi.getReadBookPage({ pageNo: 1, pageSize: 10 })
// 移除阅读记录
readBookApi.removeReadBook(bookId)
```
#### 我的作品 (myBookApi)
```javascript
// 获取我的作品
myBookApi.getMyShopPage({ pageNo: 1, pageSize: 10 })
// 创建/更新作品
myBookApi.saveOrUpdateShop(shopData)
// 获取章节列表
myBookApi.getMyShopNovelPage({ bookId, pageNo: 1, pageSize: 20 })
// 创建/更新章节
myBookApi.saveOrUpdateShopNovel(novelData)
// 删除作品
myBookApi.deleteMyShop(bookId)
```
### 👤 用户模块
#### 评论 (commentApi)
```javascript
// 发表评论
commentApi.saveComment({ bookId, content })
// 获取评论列表
commentApi.getCommentList({ bookId, pageNo: 1, pageSize: 10 })
// 回复评论
commentApi.replyComment({ commentId, content })
// 删除评论
commentApi.deleteComment(commentId)
```
#### 任务 (taskApi)
```javascript
// 获取签到任务
taskApi.getSignTaskList()
// 执行签到
taskApi.clickSignTask(taskId)
// 获取更多任务
taskApi.getMoreTaskList()
// 获取推荐票数
taskApi.getMyRecommendTicketNum()
```
## 🔧 环境变量配置
`.env` 文件中配置API基础URL:
```env
# 开发环境
VITE_API_BASE_URL=http://127.0.0.1:8080
# 生产环境
VITE_API_BASE_URL=https://your-api-domain.com
```
## 🛠️ 错误处理
所有API调用都应该使用 try-catch 进行错误处理:
```javascript
try {
const result = await authApi.appletLogin(loginData);
// 处理成功结果
} catch (error) {
// 处理错误
console.error('登录失败:', error.message);
// 显示错误提示给用户
}
```
## 📝 注意事项
1. **认证token**: 所有需要认证的接口都会自动添加 `X-Access-Token`
2. **分页参数**: 统一使用 `pageNo``pageSize`
3. **响应格式**: 所有接口返回统一的格式 `{ code, message, result, success, timestamp }`
4. **错误处理**: 401错误会自动清除token并跳转到登录页
5. **超时设置**: 默认10秒超时,可在 `index.js` 中调整
## 🔗 相关链接
- [Axios 官方文档](https://axios-http.com/)
- [Vue 3 文档](https://vuejs.org/)
- [Element Plus 文档](https://element-plus.org/)
## 📞 技术支持
如有问题,请查看 `example.js` 中的使用示例或联系开发团队。

+ 3817
- 0
src/api/api.json
File diff suppressed because it is too large
View File


+ 200
- 0
src/api/auth.js View File

@ -0,0 +1,200 @@
import api from './index.js';
/**
* 授权登录相关接口
*/
export const authApi = {
/**
* 微信小程序授权登录
* @param {Object} params 登录参数
* @param {string} params.code 参数信息
* @param {string} params.encryptedData 解密
* @param {string} params.headimgurl 用户头像
* @param {string} params.id 标识
* @param {string} params.iv 解密标签
* @param {string} params.nickName 用户姓名
* @param {string} params.openid 用户唯一标识
* @param {string} params.session_key 会话密钥
* @param {string} params.shareId 邀请者销售标识
* @param {string} params.state 类型
* @param {string} params.vid 参数信息
*/
async appletLogin(params) {
try {
const response = await api.get('/all_login/appletLogin', { params });
// 登录成功后自动保存token和用户信息
if (response.success && response.result) {
const { token, userInfo } = response.result;
if (token) {
// 保存认证token
localStorage.setItem('X-Access-Token', token);
localStorage.setItem('token', token);
console.log('[Auth] 登录成功,token已保存');
}
if (userInfo) {
// 保存用户信息
localStorage.setItem('user', JSON.stringify(userInfo));
// 更新store状态
try {
import('../store/index.js').then(({ useMainStore }) => {
const store = useMainStore();
store.user = userInfo;
store.isLoggedIn = true;
store.token = token;
store.isAuthor = userInfo.isAuthor || false;
// 同步到localStorage
localStorage.setItem('isAuthor', userInfo.isAuthor || false);
console.log('[Auth] Store状态已更新');
});
} catch (err) {
console.warn('[Auth] 无法更新store状态:', err);
}
}
}
return response;
} catch (error) {
console.error('[Auth] 登录失败:', error);
throw error;
}
},
/**
* 绑定手机号码
* @param {string} code 授权码
*/
bindPhone(code) {
return api.get('/all_login/bindPhone', {
params: { code }
});
},
/**
* 获取平台基础配置信息
*/
getConfig() {
return api.get('/all_login/getConfig');
},
/**
* 获取用户信息
*/
async getUserByToken() {
try {
const response = await api.get('/all_login/getUserByToken');
// 更新本地用户信息
if (response.success && response.result) {
localStorage.setItem('user', JSON.stringify(response.result));
// 更新store
try {
import('../store/index.js').then(({ useMainStore }) => {
const store = useMainStore();
store.user = response.result;
store.isAuthor = response.result.isAuthor || false;
localStorage.setItem('isAuthor', response.result.isAuthor || false);
});
} catch (err) {
console.warn('[Auth] 无法更新store状态:', err);
}
}
return response;
} catch (error) {
console.error('[Auth] 获取用户信息失败:', error);
throw error;
}
},
/**
* 更新用户信息
* @param {Object} userInfo 用户信息
* @param {string} userInfo.avatarUrl 头像
* @param {string} userInfo.details 简介
* @param {string} userInfo.name 别名
* @param {string} userInfo.nickName 昵称
* @param {string} userInfo.phone 电话
*/
async updateUserInfo(userInfo) {
try {
const response = await api.post('/all_login/updateUserInfo', null, {
params: userInfo
});
// 更新成功后重新获取用户信息
if (response.success) {
await this.getUserByToken();
}
return response;
} catch (error) {
console.error('[Auth] 更新用户信息失败:', error);
throw error;
}
},
/**
* 手动登出
* 清除所有登录状态和用户数据
*/
logout() {
console.log('[Auth] 手动登出');
// 清除本地存储
localStorage.removeItem('X-Access-Token');
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('isAuthor');
localStorage.removeItem('bookshelf');
// 清除store状态
try {
import('../store/index.js').then(({ useMainStore }) => {
const store = useMainStore();
store.logout();
console.log('[Auth] Store状态已清除');
});
} catch (err) {
console.warn('[Auth] 无法清除store状态:', err);
}
// 跳转到首页
if (window.location.pathname !== '/') {
window.location.href = '/';
}
},
/**
* 检查当前登录状态
*/
checkLoginStatus() {
const token = localStorage.getItem('X-Access-Token');
const user = localStorage.getItem('user');
try {
const userInfo = user ? JSON.parse(user) : null;
return {
isLoggedIn: !!(token && userInfo),
token,
user: userInfo,
isAuthor: userInfo?.isAuthor || localStorage.getItem('isAuthor') === 'true'
};
} catch (err) {
console.warn('[Auth] 解析用户信息失败:', err);
return {
isLoggedIn: false,
token: null,
user: null,
isAuthor: false
};
}
}
};

+ 156
- 0
src/api/bookshelf.js View File

@ -0,0 +1,156 @@
import api from './index.js';
/**
* 书架-阅读书架相关接口
*/
export const readBookApi = {
/**
* 增加我的书架阅读记录
* @param {Object} bookData 书籍数据
*/
addReadBook(bookData) {
return api.post('/all_book/addReadBook', null, {
params: bookData
});
},
/**
* 批量移除我阅读过的数据根据书籍标识
* @param {string} bookIds 书籍ID列表逗号分隔
*/
batchRemoveReadBook(bookIds) {
return api.get('/all_book/batchRemoveReadBook', {
params: { bookIds }
});
},
/**
* 获取我阅读过的书籍列表带分页
* @param {Object} params 分页参数
* @param {number} params.pageNo 当前页
* @param {number} params.pageSize 显示条数
*/
getReadBookPage(params) {
return api.get('/all_book/getReadBookPage', { params });
},
/**
* 移除我阅读过的书籍根据书籍标识
* @param {string} bookId 书籍ID
*/
removeReadBook(bookId) {
return api.get('/all_book/removeReadBook', {
params: { bookId }
});
}
};
/**
* 书架-我的作品相关接口
*/
export const myBookApi = {
/**
* 删除作品章节
* @param {Object} params 删除参数
* @param {string} params.bookId 书籍ID
* @param {string} params.id 章节ID
*/
deleteMyNovel(params) {
return api.post('/my_book/deleteMyNovel', null, { params });
},
/**
* 删除我的作品
* @param {string} bookId 书籍ID
*/
deleteMyShop(bookId) {
return api.post('/my_book/deleteMyShop', null, {
params: { bookId }
});
},
/**
* 多选删除我的作品
* @param {string} bookIds 书籍ID列表逗号分隔
*/
deleteMyShopList(bookIds) {
return api.post('/my_book/deleteMyShopList', null, {
params: { bookIds }
});
},
/**
* 获取我的小说章节列表带分页
* @param {Object} params 查询参数
* @param {string} params.bookId 书籍ID
* @param {number} params.pageNo 当前页
* @param {number} params.pageSize 显示条数
* @param {number} params.reverse 是否倒序
* @param {number} params.status 状态
*/
getMyShopNovelPage(params) {
return api.post('/my_book/getMyShopNovelPage', null, { params });
},
/**
* 获取我的作品带分页
* @param {Object} params 分页参数
* @param {number} params.pageNo 当前页
* @param {number} params.pageSize 显示条数
*/
getMyShopPage(params) {
return api.get('/my_book/getMyShopPage', { params });
},
/**
* 添加作品或者修改作品
* @param {Object} shopData 作品数据
*/
saveOrUpdateShop(shopData) {
return api.post('/my_book/saveOrUpdateShop', null, {
params: shopData
});
},
/**
* 增加或修改作品章节
* @param {Object} novelData 章节数据
*/
saveOrUpdateShopNovel(novelData) {
return api.post('/my_book/saveOrUpdateShopNovel', null, {
params: novelData
});
}
};
/**
* 书架-读者成就相关接口
*/
export const achievementApi = {
/**
* 根据用户标识和书籍标识查询该用户的成就等级
* @param {string} bookId 书籍ID
*/
getAchievement(bookId) {
return api.get('/all_achievement/getAchievement', {
params: { bookId }
});
},
/**
* 获取读者成就列表
*/
getAchievementList() {
return api.get('/all_achievement/getAchievementList');
},
/**
* 设置读者成就等级名称
* @param {Object} achievementData 成就数据
*/
setAchievementName(achievementData) {
return api.post('/all_achievement/setAchievementName', null, {
params: achievementData
});
}
};

+ 321
- 0
src/api/example.js View File

@ -0,0 +1,321 @@
/**
* API使用示例
* 展示如何在Vue组件中使用这些API接口
*/
// 导入方式1:按需导入
import { authApi, homeApi, commentApi, checkAuthStatus, clearAuthStatus } from './modules.js';
// 导入方式2:全量导入
import api from './modules.js';
// 使用示例
export const apiExamples = {
// 1. 用户登录示例
async userLogin() {
try {
const loginData = {
code: 'wx_auth_code',
nickName: '用户昵称',
headimgurl: 'http://avatar.url',
openid: 'wx_openid'
};
const response = await authApi.appletLogin(loginData);
if (response.success) {
console.log('登录成功:', response.result);
// token和用户信息已自动保存,store状态已更新
return response.result;
}
} catch (error) {
console.error('登录失败:', error.message);
throw error;
}
},
// 2. 检查登录状态示例
checkUserLoginStatus() {
// 使用辅助函数检查登录状态
const authStatus = authApi.checkLoginStatus();
console.log('当前登录状态:', authStatus);
if (authStatus.isLoggedIn) {
console.log('用户已登录:', authStatus.user);
console.log('是否为作家:', authStatus.isAuthor);
} else {
console.log('用户未登录');
}
return authStatus;
},
// 3. 处理需要认证的API调用
async callProtectedApi() {
try {
// 首先检查登录状态
if (!checkAuthStatus()) {
throw new Error('用户未登录');
}
// 调用需要认证的接口
const response = await api.myBook.getMyShopPage({ pageNo: 1, pageSize: 10 });
return response.result;
} catch (error) {
if (error.message.includes('401') || error.message.includes('未授权')) {
console.log('认证失败,已自动处理登录状态');
// 401错误已经被自动处理,这里可以提示用户重新登录
return null;
}
throw error;
}
},
// 4. 获取首页数据示例
async getHomeData() {
try {
// 并行请求多个接口
const [banner, categories, newBooks] = await Promise.all([
homeApi.getBanner(),
homeApi.getCategoryList(),
homeApi.getNewList({ pageNo: 1, pageSize: 10 })
]);
return {
banner: banner.result,
categories: categories.result,
newBooks: newBooks.result
};
} catch (error) {
console.error('获取首页数据失败:', error.message);
throw error;
}
},
// 5. 获取书籍详情示例
async getBookDetail(bookId) {
try {
const response = await homeApi.getBookDetail({ id: bookId });
return response.result;
} catch (error) {
console.error('获取书籍详情失败:', error.message);
throw error;
}
},
// 6. 发表评论示例(需要登录)
async postComment(bookId, content) {
try {
// 检查登录状态
if (!checkAuthStatus()) {
throw new Error('请先登录后再发表评论');
}
const response = await commentApi.saveComment({
bookId,
content
});
if (response.success) {
console.log('评论发表成功');
return response.result;
}
} catch (error) {
console.error('发表评论失败:', error.message);
throw error;
}
},
// 7. 分页获取数据示例
async getMyBooks(page = 1, size = 10) {
try {
const response = await api.myBook.getMyShopPage({
pageNo: page,
pageSize: size
});
return {
list: response.result.records,
total: response.result.total,
current: response.result.current
};
} catch (error) {
console.error('获取我的作品失败:', error.message);
throw error;
}
},
// 8. 手动登出示例
async logout() {
try {
// 调用登出API
authApi.logout();
console.log('已登出');
} catch (error) {
console.error('登出失败:', error.message);
// 即使API调用失败,也要清除本地状态
clearAuthStatus();
}
},
// 9. 文件上传示例(如果需要)
async uploadImage(file) {
try {
// 检查登录状态
if (!checkAuthStatus()) {
throw new Error('请先登录');
}
const formData = new FormData();
formData.append('file', file);
// 注意:这里需要根据实际的上传接口调整
const response = await fetch('/api/upload', {
method: 'POST',
headers: {
'X-Access-Token': localStorage.getItem('X-Access-Token')
},
body: formData
});
if (response.status === 401) {
// 401错误会被axios拦截器处理
throw new Error('认证失败');
}
return await response.json();
} catch (error) {
console.error('文件上传失败:', error.message);
throw error;
}
}
};
// Vue 3 Composition API 使用示例
export function useApiExamples() {
const { authApi, homeApi, commentApi } = api;
// 响应式数据
const loading = ref(false);
const error = ref(null);
const authStatus = ref(authApi.checkLoginStatus());
// 封装API调用
const callApi = async (apiFunction, ...args) => {
loading.value = true;
error.value = null;
try {
const result = await apiFunction(...args);
return result;
} catch (err) {
error.value = err.message;
throw err;
} finally {
loading.value = false;
}
};
// 带认证检查的API调用
const callProtectedApi = async (apiFunction, ...args) => {
// 检查登录状态
const currentAuthStatus = authApi.checkLoginStatus();
authStatus.value = currentAuthStatus;
if (!currentAuthStatus.isLoggedIn) {
error.value = '请先登录';
throw new Error('请先登录');
}
return callApi(apiFunction, ...args);
};
// 监听认证状态变化
const refreshAuthStatus = () => {
authStatus.value = authApi.checkLoginStatus();
};
// 登录方法
const login = async (...args) => {
const result = await callApi(authApi.appletLogin, ...args);
refreshAuthStatus(); // 登录后刷新状态
return result;
};
// 登出方法
const logout = () => {
authApi.logout();
refreshAuthStatus(); // 登出后刷新状态
};
return {
loading,
error,
authStatus,
callApi,
callProtectedApi,
refreshAuthStatus,
// 具体的API方法
login,
logout,
getBanner: (...args) => callApi(homeApi.getBanner, ...args),
postComment: (...args) => callProtectedApi(commentApi.saveComment, ...args),
getMyBooks: (...args) => callProtectedApi(api.myBook.getMyShopPage, ...args)
};
}
// 错误处理最佳实践示例
export const errorHandlingExamples = {
// 标准错误处理模式
async standardErrorHandling() {
try {
const response = await homeApi.getBanner();
return response.result;
} catch (error) {
// 根据错误类型进行不同处理
if (error.message.includes('网络')) {
// 网络错误
console.error('网络连接失败,请检查网络设置');
} else if (error.message.includes('401')) {
// 认证错误(已被自动处理)
console.error('认证失败,请重新登录');
} else if (error.message.includes('403')) {
// 权限错误
console.error('没有权限访问此资源');
} else {
// 其他错误
console.error('请求失败:', error.message);
}
throw error;
}
},
// 重试机制示例
async withRetry(apiFunction, maxRetries = 3, delay = 1000) {
let lastError;
for (let i = 0; i < maxRetries; i++) {
try {
return await apiFunction();
} catch (error) {
lastError = error;
// 401错误不重试
if (error.message.includes('401')) {
throw error;
}
// 最后一次重试失败
if (i === maxRetries - 1) {
throw error;
}
// 等待后重试
await new Promise(resolve => setTimeout(resolve, delay));
console.log(`${i + 1}次重试...`);
}
}
throw lastError;
}
};

+ 157
- 0
src/api/home.js View File

@ -0,0 +1,157 @@
import api from './index.js';
/**
* 首页相关接口
*/
export const homeApi = {
/**
* 获取首页banner
*/
getBanner() {
return api.get('/all_index/getBanner');
},
/**
* 获取书城区域列表
*/
getBookAreaList() {
return api.get('/all_index/getBookAreaList');
},
/**
* 根据目录查询章节小说信息明细
* @param {string} id 目录ID
*/
getBookCatalogDetail(id) {
return api.get('/all_index/getBookCatalogDetail', {
params: { id }
});
},
/**
* 根据书本标识获取书本目录列表
* @param {Object} params 查询参数
* @param {string} params.bookId 书本ID
* @param {string} params.orderBy 排序方式
* @param {number} params.pageNo 当前页
* @param {number} params.pageSize 显示条数
*/
getBookCatalogList(params) {
return api.get('/all_index/getBookCatalogList', { params });
},
/**
* 根据书本标识获取书本详细信息
* @param {Object} params 查询参数
* @param {string} params.id 书本ID
* @param {string} params.token token
*/
getBookDetail(params) {
return api.get('/all_index/getBookDetail', { params });
},
/**
* 获取书城分类列表
*/
getCategoryList() {
return api.get('/all_index/getCategoryList');
},
/**
* 获取亲密度排行版
* @param {Object} params 查询参数
* @param {string} params.bookId 书本ID
* @param {number} params.pageNo 当前页
* @param {number} params.pageSize 显示条数
*/
getIntimacyRankList(params) {
return api.get('/all_index/getIntimacyRankList', { params });
},
/**
* 获取首页最新小说列表带分页
* @param {Object} params 分页参数
* @param {number} params.pageNo 当前页
* @param {number} params.pageSize 显示条数
*/
getNewList(params) {
return api.get('/all_index/getNewList', { params });
},
/**
* 获取首页公告
*/
getNotice() {
return api.get('/all_index/getNotice');
},
/**
* 获取公告详情
* @param {string} id 公告ID
*/
getNoticeById(id) {
return api.get('/all_index/getNoticeById', {
params: { id }
});
},
/**
* 获取公告列表带分页
* @param {Object} params 分页参数
* @param {number} params.pageNo 当前页
* @param {number} params.pageSize 显示条数
*/
getNoticePage(params) {
return api.get('/all_index/getNoticePage', { params });
},
/**
* 获取首页精品推荐小说列表带分页
*/
getRecommendList(params) {
return api.get('/all_index/getRecommendList', { params });
},
/**
* 根据书本标识进行投票
* @param {Object} params 投票参数
* @param {string} params.bookId 书本ID
* @param {string} params.num 投票数
*/
vote(params) {
return api.get('/all_index/vote', { params });
},
/**
* 获取排行榜数据 - 根据不同榜单类型和分类获取书籍列表
* @param {Object} params 查询参数
* @param {string} params.rankType 榜单类型 (1:推荐榜 2:完本榜 3:阅读榜 4:口碑榜 5:新书榜 6:高分榜)
* @param {string} params.categoryId 分类ID
* @param {number} params.pageNo 当前页
* @param {number} params.pageSize 显示条数
*/
getRankingList(params) {
const { rankType, categoryId, pageNo = 1, pageSize = 20 } = params;
// 根据不同的榜单类型调用不同的接口
switch (rankType) {
case '1': // 推荐榜
return api.get('/all_index/getRecommendList', {
params: { pageNo, pageSize, categoryId }
});
case '5': // 新书榜
return api.get('/all_index/getNewList', {
params: { pageNo, pageSize, categoryId }
});
case '3': // 阅读榜 (使用亲密度排行榜作为替代)
return api.get('/all_index/getIntimacyRankList', {
params: { pageNo, pageSize, bookId: categoryId }
});
default:
// 其他榜单暂时使用推荐列表作为占位
return api.get('/all_index/getRecommendList', {
params: { pageNo, pageSize, categoryId }
});
}
}
};

+ 142
- 7
src/api/index.js View File

@ -1,7 +1,8 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
baseURL: 'https://prod-api.budingxiaoshuo.com/novel-admin',
// baseURL: import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
@ -11,9 +12,9 @@ const api = axios.create({
// 请求拦截器
api.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
const token = localStorage.getItem('X-Access-Token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
config.headers['X-Access-Token'] = token;
}
return config;
},
@ -25,15 +26,149 @@ api.interceptors.request.use(
// 响应拦截器
api.interceptors.response.use(
response => {
return response.data;
const { data } = response;
// 根据API文档的统一返回格式处理
if (data.success === false) {
return Promise.reject(new Error(data.message || '请求失败'));
}
return data;
},
error => {
if (error.response && error.response.status === 401) {
// 处理未授权的情况
// 可以在这里触发退出登录或跳转到登录页
console.log('[API] 401 未授权,清除登录状态');
// 清除所有登录相关的本地存储
localStorage.removeItem('X-Access-Token');
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('isAuthor');
localStorage.removeItem('bookshelf');
// 尝试获取store实例并清除状态
try {
// 使用动态导入并通过Promise处理
import('../store/index.js').then(({ useMainStore }) => {
const store = useMainStore();
store.logout();
console.log('[API] Store状态已清除');
}).catch(err => {
console.warn('[API] 无法访问store:', err);
});
} catch (err) {
console.warn('[API] 导入store模块失败:', err);
}
// 获取当前路由信息
const currentPath = window.location.pathname;
const isProtectedRoute = currentPath.includes('/bookshelf') ||
currentPath.includes('/task-center') ||
currentPath.includes('/gift-box') ||
currentPath.includes('/messages') ||
currentPath.includes('/author');
// 如果当前在受保护的路由上,触发登录弹窗
if (isProtectedRoute) {
console.log('[API] 当前在受保护路由,尝试触发登录弹窗');
// 延迟执行,确保DOM已就绪
setTimeout(() => {
// 尝试调用全局的登录弹窗
const authContext = window.$authContext;
if (authContext && typeof authContext.openLogin === 'function') {
authContext.openLogin(() => {
console.log('[API] 登录成功回调');
// 登录成功后可以重新发起原来的请求
window.location.reload();
});
} else {
// 如果没有全局登录弹窗,使用路由事件
const { routerEvents } = window.$router || {};
if (routerEvents) {
routerEvents.triggerLogin = () => {
const context = window.$authContext;
if (context && typeof context.openLogin === 'function') {
context.openLogin(() => {
window.location.reload();
});
}
};
}
// 如果都不可用,跳转到首页
console.log('[API] 无法触发登录弹窗,跳转到首页');
window.location.href = '/';
}
}, 100);
} else {
// 如果在公开页面,只是清除状态,不跳转
console.log('[API] 当前在公开页面,只清除状态');
}
} else if (error.response) {
// 处理其他HTTP错误
const { status, data } = error.response;
console.error(`[API] HTTP错误 ${status}:`, data);
// 根据不同状态码提供更友好的错误信息
let errorMessage = '网络请求失败';
switch (status) {
case 400:
errorMessage = data?.message || '请求参数错误';
break;
case 403:
errorMessage = '没有权限访问';
break;
case 404:
errorMessage = '请求的资源不存在';
break;
case 500:
errorMessage = '服务器内部错误';
break;
case 502:
case 503:
case 504:
errorMessage = '服务器暂时不可用,请稍后重试';
break;
default:
errorMessage = data?.message || `请求失败 (${status})`;
}
return Promise.reject(new Error(errorMessage));
} else if (error.request) {
// 网络错误
console.error('[API] 网络错误:', error.request);
return Promise.reject(new Error('网络连接失败,请检查网络设置'));
} else {
// 其他错误
console.error('[API] 请求错误:', error.message);
return Promise.reject(error);
}
return Promise.reject(error);
}
);
// 导出一个辅助函数,用于检查当前登录状态
export const checkAuthStatus = () => {
const token = localStorage.getItem('X-Access-Token');
const user = localStorage.getItem('user');
return !!(token && user);
};
// 导出一个辅助函数,用于手动清除登录状态
export const clearAuthStatus = () => {
localStorage.removeItem('X-Access-Token');
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('isAuthor');
localStorage.removeItem('bookshelf');
// 如果可以访问store,也清除store状态
try {
import('../store/index.js').then(({ useMainStore }) => {
const store = useMainStore();
store.logout();
});
} catch (err) {
console.warn('[API] 无法清除store状态:', err);
}
};
export default api;

+ 41
- 0
src/api/modules.js View File

@ -0,0 +1,41 @@
// 统一导出所有API模块
export { authApi } from './auth.js';
export { homeApi } from './home.js';
export { readBookApi, myBookApi, achievementApi } from './bookshelf.js';
export { moneyApi, commentApi, taskApi, writerApi, orderApi } from './user.js';
// 导出辅助函数
export { checkAuthStatus, clearAuthStatus } from './index.js';
// 默认导出,方便直接使用
import { authApi } from './auth.js';
import { homeApi } from './home.js';
import { readBookApi, myBookApi, achievementApi } from './bookshelf.js';
import { moneyApi, commentApi, taskApi, writerApi, orderApi } from './user.js';
import { checkAuthStatus, clearAuthStatus } from './index.js';
export default {
// 授权登录
auth: authApi,
// 首页
home: homeApi,
// 书架相关
readBook: readBookApi,
myBook: myBookApi,
achievement: achievementApi,
// 用户相关
money: moneyApi,
comment: commentApi,
task: taskApi,
writer: writerApi,
order: orderApi,
// 辅助工具
utils: {
checkAuthStatus,
clearAuthStatus
}
};

+ 144
- 0
src/api/types.ts View File

@ -0,0 +1,144 @@
/**
* API接口类型定义
*/
// 基础响应类型
export interface ApiResponse<T = any> {
code: number;
message: string;
result: T;
success: boolean;
timestamp: number;
}
// 分页参数
export interface PaginationParams {
pageNo?: number;
pageSize?: number;
}
// 分页响应
export interface PaginationResponse<T> {
records: T[];
total: number;
current: number;
size: number;
pages: number;
}
// 用户登录参数
export interface LoginParams {
code?: string;
encryptedData?: string;
headimgurl?: string;
id?: string;
iv?: string;
nickName?: string;
openid?: string;
session_key?: string;
shareId?: string;
state?: string;
vid?: string;
}
// 用户信息
export interface UserInfo {
avatarUrl?: string;
details?: string;
name?: string;
nickName?: string;
phone?: string;
}
// 书籍信息
export interface BookInfo {
id: string;
name: string;
author: string;
image: string;
details: string;
shopClass: string;
shopCion: string;
status: number;
bookStatus: number;
tuiNum: number;
qmNum: number;
}
// 章节信息
export interface ChapterInfo {
id: string;
bookId: string;
title: string;
details: string;
num: number;
sort: number;
status: number;
isPay: string;
}
// 评论信息
export interface CommentInfo {
id: string;
bookId: string;
content: string;
userId: string;
createTime: string;
}
// 任务信息
export interface TaskInfo {
id: string;
name: string;
description: string;
reward: number;
status: number;
}
// 礼物信息
export interface GiftInfo {
id: string;
name: string;
price: number;
image: string;
description: string;
}
// 订单信息
export interface OrderInfo {
id: string;
giftId: string;
num: number;
totalPrice: number;
status: number;
createTime: string;
}
// 成就信息
export interface AchievementInfo {
id: string;
bookId: string;
userId: string;
oneName: string;
twoName: string;
threeName: string;
oneNum: number;
twoNum: number;
threeNum: number;
}
// 作家信息
export interface WriterInfo {
penName: string;
details: string;
}
// 流水记录
export interface MoneyLogInfo {
id: string;
amount: number;
type: string;
description: string;
createTime: string;
status: number;
}

+ 268
- 0
src/api/user.js View File

@ -0,0 +1,268 @@
import api from './index.js';
/**
* 我的-流水相关接口
*/
export const moneyApi = {
/**
* 获取我的流水列表带分页
* @param {Object} params 查询参数
* @param {number} params.pageNo 当前页
* @param {number} params.pageSize 显示条数
* @param {number} params.status 状态
*/
getMyMoneyLogPage(params) {
return api.get('/all_money/getMyMoneyLogPage', { params });
},
/**
* 获取我的可用积分数
*/
getMyMoneyNum() {
return api.get('/all_money/getMyMoneyNum');
}
};
/**
* 我的-评论相关接口
*/
export const commentApi = {
/**
* 删除评论信息
* @param {string} commentId 评论ID
*/
deleteComment(commentId) {
return api.get('/my_comment/deleteComment', {
params: { commentId }
});
},
/**
* 获取评论详情
* @param {string} commentId 评论ID
*/
getCommentDetail(commentId) {
return api.post('/my_comment/getCommentDetail', null, {
params: { commentId }
});
},
/**
* 根据书籍标识查询评论信息列表带分页
* @param {Object} params 查询参数
* @param {string} params.bookId 书籍ID
* @param {number} params.pageNo 当前页
* @param {number} params.pageSize 显示条数
*/
getCommentList(params) {
return api.get('/my_comment/getCommentList', { params });
},
/**
* 获取我的评论列表
* @param {Object} params 分页参数
* @param {number} params.pageNo 当前页
* @param {number} params.pageSize 显示条数
*/
getMyCommentList(params) {
return api.get('/my_comment/getMyCommentList', { params });
},
/**
* 获取我的评论数
*/
getMyCommentNum() {
return api.get('/my_comment/getMyCommentNum');
},
/**
* 回复评论信息
* @param {Object} params 回复参数
* @param {string} params.commentId 评论ID
* @param {string} params.content 回复内容
*/
replyComment(params) {
return api.post('/my_comment/replyComment', null, { params });
},
/**
* 保存评论信息
* @param {Object} params 评论参数
* @param {string} params.bookId 书籍ID
* @param {string} params.content 评论内容
*/
saveComment(params) {
return api.post('/my_comment/saveComment', null, { params });
},
/**
* 更新评论已读状态
* @param {string} commentId 评论ID
*/
updateCommentRead(commentId) {
return api.post('/my_comment/updateCommentRead', null, {
params: { commentId }
});
}
};
/**
* 我的-任务中心相关接口
*/
export const taskApi = {
/**
* 点击更多任务
* @param {string} taskId 任务ID
*/
clickMoreTask(taskId) {
return api.post('/my_task/clickMoreTask', null, {
params: { taskId }
});
},
/**
* 点击签到任务
* @param {string} taskId 任务ID
*/
clickSignTask(taskId) {
return api.get('/my_task/clickSignTask', {
params: { taskId }
});
},
/**
* 获取更多任务列表
* @param {string} token token
*/
getMoreTaskList(token) {
return api.get('/my_task/getMoreTaskList', {
params: { token }
});
},
/**
* 获取更多任务记录列表
* @param {Object} params 分页参数
* @param {number} params.pageNo 当前页
* @param {number} params.pageSize 显示条数
*/
getMoreTaskRecordPage(params) {
return api.get('/my_task/getMoreTaskRecordPage', { params });
},
/**
* 获取我的推荐票数
*/
getMyRecommendTicketNum() {
return api.get('/my_task/getMyRecommendTicketNum');
},
/**
* 获取我的推荐任务列表
* @param {string} token token
*/
getSignTaskList(token) {
return api.get('/my_task/getSignTaskList', {
params: { token }
});
},
/**
* 获取我的推荐任务记录列表
* @param {Object} params 分页参数
* @param {number} params.pageNo 当前页
* @param {number} params.pageSize 显示条数
*/
getSignTaskRecordPage(params) {
return api.get('/my_task/getSignTaskRecordPage', { params });
}
};
/**
* 我的-申请成为作家相关接口
*/
export const writerApi = {
/**
* 查询我的笔名以及简介
*/
getMyWriter() {
return api.get('/my_writer/getMyWriter');
},
/**
* 填写或修改笔名以及简介成为作家
* @param {Object} params 作家信息
* @param {string} params.details 简介
* @param {string} params.penName 笔名
*/
saveOrUpdateWriter(params) {
return api.post('/my_writer/saveOrUpdateWriter', null, { params });
}
};
/**
* 我的-礼物订阅接口
*/
export const orderApi = {
/**
* 创建订单
* @param {Object} params 订单参数
* @param {string} params.giftId 礼物ID
* @param {number} params.num 数量
* @param {string} params.token token
*/
createOrder(params) {
return api.post('/my_order/createOrder', null, { params });
},
/**
* 查询礼物详情
* @param {string} giftId 礼物ID
*/
getGiftDetail(giftId) {
return api.get('/my_order/getGiftDetail', {
params: { giftId }
});
},
/**
* 查询互动打赏礼物信息列表
* @param {Object} params 分页参数
* @param {number} params.pageNo 当前页
* @param {number} params.pageSize 显示条数
*/
getInteractionGiftList(params) {
return api.get('/my_order/getInteractionGiftList', { params });
},
/**
* 查询我的礼物包订单列表
* @param {Object} params 查询参数
* @param {number} params.pageNo 当前页
* @param {number} params.pageSize 显示条数
* @param {string} params.token token
*/
getMyGiftList(params) {
return api.get('/my_order/getMyGiftList', { params });
},
/**
* 支付订单
* @param {Object} params 支付参数
* @param {string} params.orderId 订单ID
* @param {string} params.token token
*/
payOrder(params) {
return api.post('/my_order/payOrder', null, { params });
},
/**
* 支付成功
* @param {string} orderId 订单ID
*/
paySuccess(orderId) {
return api.post('/my_order/paySuccess', null, {
params: { orderId }
});
}
};

+ 48
- 2
src/components/book/BookCatalog.vue View File

@ -4,7 +4,8 @@
<h3 class="section-title">目录</h3>
</div>
<transition-group name="chapter-transition" tag="div" class="chapter-grid">
<!-- 章节列表 -->
<transition-group v-if="chapters.length > 0" name="chapter-transition" tag="div" class="chapter-grid">
<div v-for="(chapter, index) in displayedChapters" :key="chapter.id" class="chapter-item"
@click="goToChapter(chapter.id)">
<div class="chapter-title">
@ -17,7 +18,24 @@
</div>
</transition-group>
<div class="catalog-footer">
<!-- 空状态 -->
<div v-else class="empty-state">
<div class="empty-icon">
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="8" y="8" width="48" height="48" rx="4" stroke="#E5E5E5" stroke-width="2" fill="none"/>
<rect x="16" y="16" width="32" height="2" rx="1" fill="#E5E5E5"/>
<rect x="16" y="22" width="20" height="2" rx="1" fill="#E5E5E5"/>
<rect x="16" y="28" width="24" height="2" rx="1" fill="#E5E5E5"/>
<rect x="16" y="34" width="16" height="2" rx="1" fill="#E5E5E5"/>
<rect x="16" y="40" width="28" height="2" rx="1" fill="#E5E5E5"/>
<rect x="16" y="46" width="18" height="2" rx="1" fill="#E5E5E5"/>
</svg>
</div>
<p class="empty-title">暂无章节</p>
<p class="empty-desc">该书籍暂时没有可阅读的章节</p>
</div>
<div v-if="chapters.length > 0" class="catalog-footer">
<div v-if="showAll" class="collapse-btn" @click="toggleShowAll">
收起 <i class="collapse-icon"></i>
</div>
@ -140,6 +158,34 @@ export default defineComponent({
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
.empty-icon {
margin-bottom: 16px;
opacity: 0.6;
}
.empty-title {
font-size: 16px;
font-weight: 500;
color: #666;
margin: 0 0 8px 0;
}
.empty-desc {
font-size: 14px;
color: #999;
margin: 0;
line-height: 1.5;
}
}
.chapter-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);


+ 285
- 85
src/components/book/BookComments.vue View File

@ -3,32 +3,70 @@
<div class="section-header">
<h3 class="section-title">书评</h3>
<div class="section-controls">
<el-button type="primary" size="small" @click="showCommentForm = true">
<el-button type="primary" size="small" @click="handleWriteComment">
写书评
</el-button>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-skeleton :rows="5" animated>
<template #template>
<div style="display: flex; margin-bottom: 20px;">
<el-skeleton-item variant="circle" style="width: 48px; height: 48px; margin-right: 16px;" />
<div style="flex: 1;">
<el-skeleton-item variant="text" style="width: 30%; margin-bottom: 8px;" />
<el-skeleton-item variant="text" style="width: 100%; margin-bottom: 8px;" />
<el-skeleton-item variant="text" style="width: 80%;" />
</div>
</div>
</template>
</el-skeleton>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<el-empty description="加载失败">
<el-button type="primary" @click="fetchComments">重新加载</el-button>
</el-empty>
</div>
<!-- 评论表单 -->
<div class="comment-input" v-if="showCommentForm">
<el-input v-model="commentText" type="textarea" :rows="4" placeholder="请输入您的书评..." maxlength="500"
show-word-limit />
<el-input
v-model="commentText"
type="textarea"
:rows="4"
placeholder="请输入您的书评..."
maxlength="500"
show-word-limit
/>
<div class="comment-actions">
<el-button @click="showCommentForm = false">取消</el-button>
<el-button type="primary" @click="submitComment" :disabled="!commentText.trim()">提交</el-button>
<el-button @click="cancelComment">取消</el-button>
<el-button
type="primary"
@click="submitComment"
:disabled="!commentText.trim() || submitting"
:loading="submitting"
>
提交
</el-button>
</div>
</div>
<div class="comments-list">
<div v-for="(comment, index) in comments" :key="index" class="comment-item">
<!-- 评论列表 -->
<div v-else-if="!loading" class="comments-list">
<div v-for="(comment, index) in comments" :key="comment.id" class="comment-item">
<div class="user-avatar">
<img src="@/assets/images/center/headImage.png" :alt="comment.username">
<img :src="comment.hanHaiMember.headImage || defaultAvatar" :alt="comment.hanHaiMember.nickName">
</div>
<div class="comment-content">
<div class="comment-header">
<span class="username">{{ comment.username }}</span>
<span class="time">{{ comment.time }}</span>
<span class="username">{{ comment.hanHaiMember.nickName || '匿名用户' }}</span>
<span class="time">{{ formatTime(comment.createTime) }}</span>
</div>
<div class="comment-text">{{ comment.content }}</div>
<div class="comment-text">{{ comment.comment }}</div>
<div class="comment-footer">
<div class="comment-actions">
<span class="action-item" @click="toggleReplyForm(index)">
@ -36,35 +74,51 @@
<Message />
</el-icon>
</span>
<span class="action-item">
<!-- <span class="action-item" @click="toggleLike(comment, index)">
<el-icon>
<Star />
</el-icon>
</span>
</el-icon>
{{ comment.likeCount > 0 ? comment.likeCount : '点赞' }}
</span> -->
</div>
</div>
<!-- 回复表单 -->
<div class="reply-form" v-if="activeReplyIndex === index">
<el-input v-model="replyText" type="textarea" :rows="2" placeholder="请输入回复内容..." maxlength="300" show-word-limit />
<el-input
v-model="replyText"
type="textarea"
:rows="2"
placeholder="请输入回复内容..."
maxlength="300"
show-word-limit
/>
<div class="reply-actions">
<el-button size="small" @click="cancelReply">取消</el-button>
<el-button size="small" type="primary" @click="submitReply(index)" :disabled="!replyText.trim()">回复</el-button>
<el-button
size="small"
type="primary"
@click="submitReply(comment.id, index)"
:disabled="!replyText.trim() || replySubmitting"
:loading="replySubmitting"
>
回复
</el-button>
</div>
</div>
<!-- 回复列表 -->
<div class="replies-list" v-if="comment.replies && comment.replies.length > 0">
<div v-for="(reply, replyIndex) in comment.replies" :key="replyIndex" class="reply-item">
<div class="replies-list" v-if="comment.children && comment.children.length > 0">
<div v-for="(reply, replyIndex) in comment.children" :key="reply.id || replyIndex" class="reply-item">
<div class="user-avatar">
<img src="@/assets/images/center/headImage.png" :alt="reply.username">
<img :src="reply.hanHaiMember.headImage || defaultAvatar" :alt="reply.hanHaiMember.nickName">
</div>
<div class="reply-content">
<div class="reply-header">
<span class="username">{{ reply.username }}</span>
<span class="time">{{ reply.time }}</span>
<span class="username">{{ reply.hanHaiMember.nickName || '匿名用户' }}</span>
<span class="time">{{ formatTime(reply.createTime) }}</span>
</div>
<div class="reply-text">{{ reply.content }}</div>
<div class="reply-text">{{ reply.comment }}</div>
</div>
</div>
</div>
@ -72,21 +126,29 @@
</div>
</div>
<div class="comments-footer" v-if="comments.length > 0">
<el-pagination v-model:currentPage="currentPage" :page-size="pageSize" layout="prev, pager, next"
:total="totalComments" @current-change="handlePageChange" />
<!-- 分页 -->
<div class="comments-footer" v-if="!loading && !error && totalComments > 0">
<el-pagination
v-model:currentPage="currentPage"
:page-size="pageSize"
layout="prev, pager, next"
:total="totalComments"
@current-change="handlePageChange"
/>
</div>
<div class="no-comments" v-if="comments.length === 0">
<!-- 无评论状态 -->
<div class="no-comments" v-if="!loading && !error && comments.length === 0">
<el-empty description="暂无书评,快来发表第一条书评吧!" />
</div>
</div>
</template>
<script>
import { defineComponent, ref, computed } from 'vue';
import { defineComponent, ref, computed, onMounted, watch } from 'vue';
import { Message, Star } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { commentApi } from '@/api/user.js';
export default defineComponent({
name: 'BookComments',
@ -97,65 +159,141 @@ export default defineComponent({
props: {
bookId: {
type: String,
default: ''
required: true
}
},
setup(props) {
const defaultAvatar = ref('/src/assets/images/默认头像.png');
const comments = ref([
{
username: '万年捧',
avatar: 'https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTKlR5PibUEEsVjXGfH4c1eR5hXDicoH0EJUTHYwDO3EvZLXXgON8GrNTbRg8DnzaddicibYnGcfq28tYg/132',
time: '2022-07-03',
content: '这是本年内看的唯一一部完结的!看的人真幸运,发家文和风险防控写的都是一流',
replies: []
},
{
username: '残生往事',
avatar: 'https://thirdwx.qlogo.cn/mmopen/vi_32/3F4feeHnMyoGjqKfP8vGKCHwyvovMHiaO0Q1QkQMRTGibLcyJbUcUJ4LmdkkDqC5ZcqP1rvqKMviaYAyehqYb6ciaA/132',
content: '我很喜欢男主的性格,不小心眼,有格局,做事情多考虑下一步,商业和情感都处理得不错,就是那个林涵有点没必要吧?',
replies: []
}
]);
//
const loading = ref(true);
const error = ref(false);
const comments = ref([]);
const defaultAvatar = ref('@/assets/images/center/headImage.png');
//
const showCommentForm = ref(false);
const commentText = ref('');
const submitting = ref(false);
//
const currentPage = ref(1);
const pageSize = ref(10);
const totalComments = ref(comments.value.length);
const totalComments = ref(0);
//
const activeReplyIndex = ref(null);
const replyText = ref('');
const replySubmitting = ref(false);
//
const isLoggedIn = computed(() => {
return !!localStorage.getItem('token');
});
//
const formatTime = (timeStr) => {
if (!timeStr) return '';
const date = new Date(timeStr);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
//
const fetchComments = async () => {
try {
loading.value = true;
error.value = false;
const response = await commentApi.getCommentList({
bookId: props.bookId,
pageNo: currentPage.value,
pageSize: pageSize.value
});
if (response && response.success) {
const result = response.result;
if (result && result.records) {
comments.value = result.records;
totalComments.value = result.total || 0;
} else {
comments.value = [];
totalComments.value = 0;
}
} else {
throw new Error(response?.message || '获取评论失败');
}
} catch (err) {
console.error('获取评论失败:', err);
error.value = true;
ElMessage.error(err.message || '获取评论失败');
} finally {
loading.value = false;
}
};
const submitComment = () => {
//
const handleWriteComment = () => {
if (!isLoggedIn.value) {
ElMessage.warning('请先登录后再发表评论');
return;
}
showCommentForm.value = true;
};
//
const cancelComment = () => {
showCommentForm.value = false;
commentText.value = '';
};
//
const submitComment = async () => {
if (!commentText.value.trim()) {
ElMessage.warning('请输入评论内容');
return;
}
// API
comments.value.unshift({
username: '当前用户',
avatar: defaultAvatar.value,
time: new Date().toLocaleDateString(),
content: commentText.value,
replies: []
});
if (!isLoggedIn.value) {
ElMessage.warning('请先登录后再发表评论');
return;
}
totalComments.value = comments.value.length;
commentText.value = '';
showCommentForm.value = false;
ElMessage.success('评论发表成功!');
};
try {
submitting.value = true;
const handlePageChange = (page) => {
currentPage.value = page;
//
const response = await commentApi.saveComment({
bookId: props.bookId,
content: commentText.value.trim()
});
if (response && response.success) {
ElMessage.success('评论发表成功!');
commentText.value = '';
showCommentForm.value = false;
//
currentPage.value = 1;
await fetchComments();
} else {
throw new Error(response?.message || '发表评论失败');
}
} catch (err) {
console.error('发表评论失败:', err);
ElMessage.error(err.message || '发表评论失败');
} finally {
submitting.value = false;
}
};
//
const toggleReplyForm = (index) => {
if (!isLoggedIn.value) {
ElMessage.warning('请先登录后再回复评论');
return;
}
if (activeReplyIndex.value === index) {
activeReplyIndex.value = null;
} else {
@ -163,53 +301,103 @@ export default defineComponent({
replyText.value = '';
}
};
//
const cancelReply = () => {
activeReplyIndex.value = null;
replyText.value = '';
};
//
const submitReply = (index) => {
const submitReply = async (commentId, index) => {
if (!replyText.value.trim()) {
ElMessage.warning('请输入回复内容');
return;
}
if (!comments.value[index].replies) {
comments.value[index].replies = [];
if (!isLoggedIn.value) {
ElMessage.warning('请先登录后再回复评论');
return;
}
try {
replySubmitting.value = true;
const response = await commentApi.replyComment({
commentId: commentId,
content: replyText.value.trim()
});
if (response && response.success) {
ElMessage.success('回复成功!');
//
replyText.value = '';
activeReplyIndex.value = null;
//
await fetchComments();
} else {
throw new Error(response?.message || '回复失败');
}
} catch (err) {
console.error('回复失败:', err);
ElMessage.error(err.message || '回复失败');
} finally {
replySubmitting.value = false;
}
};
// API
const toggleLike = async (comment, index) => {
if (!isLoggedIn.value) {
ElMessage.warning('请先登录后再点赞');
return;
}
//
comments.value[index].replies.push({
username: '当前用户',
avatar: defaultAvatar.value,
time: new Date().toLocaleDateString(),
content: replyText.value
});
//
replyText.value = '';
activeReplyIndex.value = null;
ElMessage.success('回复成功!');
// APIUI
ElMessage.info('点赞功能暂未开放');
};
//
const handlePageChange = async (page) => {
currentPage.value = page;
await fetchComments();
};
// bookId
watch(() => props.bookId, (newBookId) => {
if (newBookId) {
currentPage.value = 1;
fetchComments();
}
}, { immediate: true });
return {
loading,
error,
comments,
defaultAvatar,
showCommentForm,
commentText,
submitting,
currentPage,
pageSize,
totalComments,
submitComment,
handlePageChange,
activeReplyIndex,
replyText,
replySubmitting,
isLoggedIn,
formatTime,
fetchComments,
handleWriteComment,
cancelComment,
submitComment,
toggleReplyForm,
cancelReply,
submitReply
submitReply,
toggleLike,
handlePageChange
};
}
});
@ -409,5 +597,17 @@ export default defineComponent({
.no-comments {
padding: 30px 0;
}
.loading-container,
.error-container {
padding: 30px 20px;
text-align: center;
}
.loading-container {
.el-skeleton {
text-align: left;
}
}
}
</style>

+ 7
- 8
src/components/book/BookStats.vue View File

@ -10,7 +10,7 @@
</div>
<div class="stats-content">
<div class="stats-info">
<div class="stats-value">2814</div>
<div class="stats-value">{{ bookData.tuiNum }}</div>
<div class="stats-label">推荐票数</div>
</div>
<div class="stats-button">
@ -31,7 +31,7 @@
<span class="stats-title">作者累计亲密值</span>
</div>
</div>
<div class="stats-value">2814</div>
<div class="stats-value">{{ bookData.qmNum }}</div>
<div class="stats-label">亲密值数</div>
</div>
</div>
@ -47,20 +47,19 @@ export default defineComponent({
bookId: {
type: String,
default: ''
}
},
bookData: {
type: Object,
default: () => ({})
},
},
setup(props) {
const recommendCount = ref(2814);
const intimacyValue = ref(2814);
const handleRecommend = () => {
//
console.log('给作品ID为', props.bookId, '投推荐票');
};
return {
recommendCount,
intimacyValue,
handleRecommend
};
}


+ 38
- 9
src/components/common/BookCard.vue View File

@ -1,21 +1,22 @@
<template>
<div class="book-card" @click="goToDetail">
<div class="book-cover">
<img src="https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp" :alt="book.title">
<img :src="book.image" :alt="book.title">
</div>
<div class="book-info">
<h3 class="book-title">{{ book.title }}</h3>
<h3 class="book-title">{{ book.name }}</h3>
<p class="book-author">作者{{ book.author }}</p>
<p class="book-intro" v-if="book.description">{{ book.description }}</p>
<p class="book-intro" v-if="book.details">{{ book.details }}</p>
<div class="book-status">
<span class="status-tag">连载中</span>
<span class="reading-count">大家都在读</span>
<span class="status-tag" :class="statusClass">{{ statusText }}</span>
<span class="reading-count">{{ book.service }}</span>
</div>
</div>
</div>
</template>
<script>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
export default {
@ -44,8 +45,28 @@ export default {
});
};
//
const statusText = computed(() => {
const status = props.book.status;
if (status === 1 || status === '1' || status === 'completed') {
return '已完结';
}
return '连载中';
});
//
const statusClass = computed(() => {
const status = props.book.status;
if (status === 1 || status === '1' || status === 'completed') {
return 'completed';
}
return 'serializing';
});
return {
goToDetail
goToDetail,
statusText,
statusClass
};
}
};
@ -75,6 +96,7 @@ export default {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
}
}
@ -120,12 +142,19 @@ export default {
gap: 10px;
.status-tag {
background-color: #67C23A33;
color: vars.$primary-color;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
color: #67C23A;
&.serializing {
background-color: #67C23A33;
color: #67C23A;
}
&.completed {
background-color: #409EFF33;
color: #409EFF;
}
}
.reading-count {


+ 163
- 106
src/components/ranking/IntimacyRanking.vue View File

@ -8,147 +8,194 @@
<span class="header-title">读者亲密值榜单</span>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-skeleton :rows="6" animated>
<template #template>
<div v-for="i in 6" :key="i" style="display: flex; margin-bottom: 20px; align-items: center;">
<el-skeleton-item variant="rect" style="width: 50px; height: 100px; margin-right: 15px;" />
<el-skeleton-item variant="circle" style="width: 36px; height: 36px; margin-right: 12px;" />
<div style="flex: 1;">
<el-skeleton-item variant="text" style="width: 40%; margin-bottom: 8px;" />
<el-skeleton-item variant="text" style="width: 30%; margin-bottom: 6px;" />
<el-skeleton-item variant="text" style="width: 50%;" />
</div>
</div>
</template>
</el-skeleton>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<el-empty description="加载失败">
<el-button type="primary" size="small" @click="fetchRankingData">重新加载</el-button>
</el-empty>
</div>
<div class="ranking-list">
<div v-for="(item, index) in currentRankingList.slice(0, 10)" :key="index" class="ranking-item">
<!-- 排行榜列表 -->
<div v-else-if="!loading && rankingList.length > 0" class="ranking-list">
<div v-for="(item, index) in rankingList.slice(0, 10)" :key="item.id || index" class="ranking-item">
<div class="rank-side-bg" :class="`rank-color-${index + 1}`">
<span class="rank-num">{{ index + 1 }}</span>
</div>
<div class="item-content">
<div class="user-avatar">
<img src="@/assets/images/center/headImage.png" :alt="item.username">
<img :src="getBadgeImage(item)" :alt="getUserName(item)">
</div>
<div class="user-info">
<div class="username">
<img src="@/assets/images/center/headImage.png" alt="徽章" class="badge-img">
<span class="user-name">{{ item.username }}</span>
<img :src="getBadgeImage(item)" alt="徽章" class="badge-img">
<span class="user-name">{{ getUserName(item) }}</span>
</div>
<div class="badge-wrap">
<div class="level-badge">
<span class="level-text">护书使者 {{ item.level }}</span>
<span class="level-text">护书使者 {{ getUserLevel(item, index) }}</span>
</div>
</div>
<div class="intimacy-value">
{{ formatNumber(item.value) }} <span class="value-label">亲密值</span>
{{ formatNumber(getIntimacyValue(item)) }} <span class="value-label">亲密值</span>
</div>
</div>
</div>
</div>
</div>
<!-- 无数据状态 -->
<div v-else-if="!loading && !error" class="no-data">
<el-empty description="暂无排行榜数据" />
</div>
</div>
</template>
<script>
import { defineComponent, ref, computed } from 'vue';
import { defineComponent, ref, computed, watch, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import { homeApi } from '@/api/home.js';
export default defineComponent({
name: 'IntimacyRanking',
props: {
bookId: {
type: String,
default: ''
required: true
}
},
setup(props) {
const activeTab = ref('all');
//
const loading = ref(true);
const error = ref(false);
const rankingList = ref([]);
const pageNo = ref(1);
const pageSize = ref(10);
//
const defaultAvatar = ref('@/assets/images/center/headImage.png');
const allRankingList = ref([
{
username: '周海',
avatar: 'https://picsum.photos/100/100?random=1',
level: '五',
value: '6785452',
},
{
username: '冯启彬',
avatar: 'https://picsum.photos/100/100?random=2',
level: '五',
value: '6303372',
},
{
username: '周静',
avatar: 'https://picsum.photos/100/100?random=3',
level: '四',
value: '6075079',
},
{
username: '钱萌萌',
avatar: 'https://picsum.photos/100/100?random=4',
level: '三',
value: '5325324',
},
{
username: '冯艺萱',
avatar: 'https://picsum.photos/100/100?random=5',
level: '二',
value: '5325324',
},
{
username: '王凡玄',
avatar: 'https://picsum.photos/100/100?random=6',
level: '一',
value: '4696874',
},
{
username: '李书涛',
avatar: 'https://picsum.photos/100/100?random=7',
level: '一',
value: '3722523',
},
{
username: '李梅',
avatar: 'https://picsum.photos/100/100?random=8',
level: '一',
value: '2872476',
},
{
username: '郑晗',
avatar: 'https://picsum.photos/100/100?random=9',
level: '一',
value: '2484886',
},
{
username: '吴修德',
avatar: 'https://picsum.photos/100/100?random=10',
level: '一',
value: '590238',
//
const fetchRankingData = async () => {
if (!props.bookId) {
loading.value = false;
return;
}
]);
const currentRankingList = computed(() => {
return allRankingList.value;
});
try {
loading.value = true;
error.value = false;
const response = await homeApi.getIntimacyRankList({
bookId: props.bookId,
pageNo: pageNo.value,
pageSize: pageSize.value
});
if (response && response.success) {
const result = response.result;
if (result && result.records) {
rankingList.value = result.records;
} else if (Array.isArray(result)) {
rankingList.value = result;
} else {
rankingList.value = [];
}
} else {
throw new Error(response?.message || '获取排行榜数据失败');
}
} catch (err) {
console.error('获取亲密度排行榜失败:', err);
error.value = true;
ElMessage.error(err.message || '获取排行榜数据失败');
} finally {
loading.value = false;
}
};
//
const getUserAvatar = (item) => {
return item.icon || item.avatar || item.hanHaiMember?.headImage || defaultAvatar.value;
};
//
const getUserName = (item) => {
return item.nickName || item.username || item.hanHaiMember?.nickName || '匿名用户';
};
//
const getUserLevel = (item, index) => {
// API使
if (item.level) {
return item.level;
}
//
if (index === 0) return '五';
if (index === 1) return '五';
if (index === 2) return '四';
if (index < 5) return '三';
if (index < 7) return '二';
return '一';
};
//
const getIntimacyValue = (item) => {
return item.intimacy || item.value || item.intimacyValue || 0;
};
//
const formatNumber = (num) => {
return num;
if (!num) return '0';
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
//
const getBadgeImage = (index) => {
const badges = [
'@/assets/images/image-1.png', //
'@/assets/images/image-1.png', //
'@/assets/images/image-2.png', //
'@/assets/images/image-3.png', //
'@/assets/images/image-4.png' //
];
//
if (index < 3) {
return badges[0];
} else if (index < 5) {
return badges[1];
} else {
return badges[2];
}
const getBadgeImage = (item) => {
return item.icon || item.avatar || item.hanHaiMember?.headImage || defaultAvatar.value;
};
// bookId
watch(() => props.bookId, (newBookId) => {
if (newBookId) {
fetchRankingData();
}
}, { immediate: true });
onMounted(() => {
if (props.bookId) {
fetchRankingData();
}
});
return {
activeTab,
currentRankingList,
loading,
error,
rankingList,
fetchRankingData,
getUserAvatar,
getUserName,
getUserLevel,
getIntimacyValue,
formatNumber,
getBadgeImage
};
@ -191,6 +238,20 @@ export default defineComponent({
}
}
//
.loading-container,
.error-container,
.no-data {
padding: 30px 20px;
text-align: center;
}
.loading-container {
.el-skeleton {
text-align: left;
}
}
.ranking-list {
display: flex;
flex-direction: column;
@ -276,13 +337,15 @@ export default defineComponent({
margin-bottom: 4px;
display: flex;
align-items: center;
img{
width: 25px;
height: 25px;
.badge-img {
width: 20px;
height: 20px;
border-radius: 50%;
margin-right: 6px;
}
.user-name{
margin-left: 10px;
.user-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@ -297,13 +360,7 @@ export default defineComponent({
align-items: center;
background-color: #F0E6FF;
border-radius: 15px;
padding: 2px 6px 2px 4px;
.badge-img {
width: 16px;
height: 16px;
margin-right: 3px;
}
padding: 2px 8px;
.level-text {
color: #9B72FF;


+ 2
- 1
src/layout/components/Breadcrumb.vue View File

@ -25,7 +25,8 @@ export default {
'/ranking': '排行榜',
'/bookshelf': '书架',
'/user/center': '个人中心',
'/book': '书籍详情'
'/book': '书籍详情',
'/notice': '公告详情'
};
//


+ 22
- 4
src/layout/layout/Header.vue View File

@ -18,9 +18,14 @@
:default-active="activeMenu"
>
<el-menu-item index="/">精品推荐</el-menu-item>
<el-menu-item index="/category/1">武侠</el-menu-item>
<el-menu-item index="/category/2">都市</el-menu-item>
<el-menu-item index="/category/3">玄幻</el-menu-item>
<!-- 动态渲染分类菜单 -->
<el-menu-item
v-for="category in navigationCategories"
:key="category.id"
:index="'/category/' + category.id"
>
{{ category.title }}
</el-menu-item>
<el-menu-item index="/ranking">排行榜</el-menu-item>
<el-menu-item index="/category">其他</el-menu-item>
<el-menu-item index="/bookshelf" @click="goToBookshelf">书架</el-menu-item>
@ -102,7 +107,7 @@
</template>
<script>
import { ref, computed, inject, h } from 'vue';
import { ref, computed, inject, h, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useMainStore } from '@/store';
import { ArrowDown, ArrowRight, Bell, Reading, List, Star, Wallet, SwitchButton } from '@element-plus/icons-vue';
@ -153,6 +158,9 @@ export default {
const beanCount = computed(() => store.user?.beanCount || '9999');
const unreadMessageCount = computed(() => store.unreadMessageCount || 0);
//
const navigationCategories = computed(() => store.navigationCategories);
//
const activeMenu = computed(() => {
//
@ -284,6 +292,15 @@ export default {
router.push('/messages');
};
//
onMounted(async () => {
try {
await store.fetchCategories();
} catch (error) {
console.error('获取分类数据失败:', error);
}
});
return {
isLoggedIn,
userName,
@ -291,6 +308,7 @@ export default {
beanCount,
activeMenu,
searchKeyword,
navigationCategories,
handleSearch,
goToLogin,
goToUserCenter,


+ 6
- 0
src/router/index.js View File

@ -43,6 +43,12 @@ const routes = [
name: 'ranking',
component: () => import('../views/home/ranking.vue')
},
{
path: 'notice/:id',
name: 'NoticeDetail',
component: () => import('../views/home/NoticeDetail.vue'),
props: true
},
{
path: 'bookshelf',
name: 'Bookshelf',


+ 33
- 3
src/store/index.js View File

@ -1,4 +1,5 @@
import { defineStore } from 'pinia';
import { homeApi } from '@/api/modules.js';
export const useMainStore = defineStore('main', {
state: () => ({
@ -7,13 +8,19 @@ export const useMainStore = defineStore('main', {
isAuthor: false,
token: null,
bookshelf: [],
unreadMessages: 3 // 添加未读消息数量
unreadMessages: 3, // 添加未读消息数量
categories: [], // 书籍分类列表
categoriesLoaded: false // 分类是否已加载
}),
getters: {
isAuthenticated: (state) => state.isLoggedIn && state.user !== null,
bookshelfCount: (state) => state.bookshelf.length,
unreadMessageCount: (state) => state.unreadMessages // 添加获取未读消息数量的getter
unreadMessageCount: (state) => state.unreadMessages, // 添加获取未读消息数量的getter
// 获取前几个分类用于导航栏显示
navigationCategories: (state) => state.categories.slice(0, 3),
// 获取所有分类
allCategories: (state) => state.categories
},
actions: {
@ -26,7 +33,7 @@ export const useMainStore = defineStore('main', {
// 模拟登录成功响应
const userData = {
id: 'user_' + Date.now(),
name: '用户' + loginData.phone.substring(loginData.phone.length - 4),
name: '用户' + loginData.phone.substring(0, 3),
phone: loginData.phone,
avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
isAuthor: false,
@ -196,6 +203,29 @@ export const useMainStore = defineStore('main', {
// 设置未读消息数量
setUnreadMessages(count) {
this.unreadMessages = count;
},
// 获取书籍分类列表
async fetchCategories() {
if (this.categoriesLoaded) {
return this.categories;
}
try {
const response = await homeApi.getCategoryList();
if (response.success && response.result) {
this.categories = response.result
this.categoriesLoaded = true;
}
return this.categories;
} catch (error) {
}
},
// 重置分类数据(用于刷新)
resetCategories() {
this.categories = [];
this.categoriesLoaded = false;
}
}
});

+ 368
- 95
src/views/book/chapter.vue View File

@ -1,87 +1,118 @@
<template>
<div class="chapter-container">
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-skeleton :rows="10" animated>
<template #template>
<div style="max-width: 1100px; margin: 0 auto; padding: 30px 20px;">
<el-skeleton-item variant="text" style="width: 40%; margin-bottom: 30px;" />
<el-skeleton-item variant="text" style="width: 100%; margin-bottom: 20px;" />
<el-skeleton-item variant="text" style="width: 100%; margin-bottom: 20px;" />
<el-skeleton-item variant="text" style="width: 90%; margin-bottom: 20px;" />
<el-skeleton-item variant="text" style="width: 100%; margin-bottom: 20px;" />
<el-skeleton-item variant="text" style="width: 95%; margin-bottom: 20px;" />
<el-skeleton-item variant="text" style="width: 100%; margin-bottom: 20px;" />
<el-skeleton-item variant="text" style="width: 85%; margin-bottom: 20px;" />
<el-skeleton-item variant="text" style="width: 100%; margin-bottom: 20px;" />
<el-skeleton-item variant="text" style="width: 80%;" />
</div>
</template>
</el-skeleton>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<el-empty description="加载失败">
<el-button type="primary" @click="fetchChapterDetail">重新加载</el-button>
</el-empty>
</div>
<div class="chapter-content">
<div class="chapter-title-container">
<!-- 章节内容 -->
<div v-else-if="chapter" class="chapter-content">
<div class="chapter-title-container" @click="goToBookDetail">
<el-icon size="20px">
<ArrowLeft />
</el-icon>
<h1 class="chapter-title">{{ chapter.title }}</h1>
</div>
<div class="chapter-text">
<p v-for="(paragraph, index) in chapter.content" :key="index" class="paragraph">
<div
class="chapter-text"
@contextmenu.prevent
@selectstart.prevent
@dragstart.prevent
>
<!-- 如果content是字符串按换行符分割显示 -->
<div v-if="typeof chapter.details === 'string'" v-html="chapter.details" class="rich-content"></div>
<!-- 如果content是数组按段落显示 -->
<p v-else v-for="(paragraph, index) in chapter.details" :key="index" class="paragraph">
{{ paragraph }}
</p>
</div>
</div>
<div class="chapter-menu">
<div class="chapter-menu-item">
<!-- 章节菜单 -->
<div v-if="!loading && !error" class="chapter-menu">
<div
class="chapter-menu-item"
:class="{ disabled: !prevChapter }"
@click="goToPrevChapter"
>
<el-icon size="20px">
<ArrowUp />
</el-icon>
<span>上一章</span>
</div>
<div class="chapter-menu-item">
<div
class="chapter-menu-item"
:class="{ disabled: !nextChapter }"
@click="goToNextChapter"
>
<el-icon size="20px">
<ArrowDown />
</el-icon>
<span>下一章</span>
</div>
<div class="chapter-menu-item">
<div class="chapter-menu-item" @click="toggleBookshelf">
<el-icon size="20px">
<Reading />
</el-icon>
<span>加书架</span>
<span>{{ isInBookshelf ? '移出书架' : '加书架' }}</span>
</div>
<div class="chapter-menu-item">
<div class="chapter-menu-item" @click="goToChapterList">
<el-icon size="20px">
<ArrowRight />
</el-icon>
<span>目录</span>
</div>
<div class="chapter-menu-item"
@click="toggleTheme">
<div class="chapter-menu-item" @click="toggleTheme">
<el-icon size="20px">
<MoonNight />
</el-icon>
<span>夜间</span>
<span>{{ isDarkMode ? '日间' : '夜间' }}</span>
</div>
</div>
<!-- <div class="reading-settings">
<div class="settings-buttons">
<el-button @click="toggleFontSize(2)" circle plain>
<el-icon>
<ZoomIn />
</el-icon>
</el-button>
<el-button @click="toggleFontSize(-2)" circle plain>
<el-icon>
<ZoomOut />
</el-icon>
</el-button>
<el-button @click="toggleTheme" circle plain>
<el-icon>
<MoonNight />
</el-icon>
</el-button>
</div>
</div> -->
<!-- 无数据状态 -->
<div v-else-if="!loading && !error" class="no-data">
<el-empty description="章节不存在" />
</div>
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ArrowLeft, ZoomIn, ZoomOut, MoonNight, ArrowUp, ArrowDown, Reading } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { ArrowLeft, ArrowRight, ZoomIn, ZoomOut, MoonNight, ArrowUp, ArrowDown, Reading } from '@element-plus/icons-vue';
import { homeApi } from '@/api/home.js';
import { readBookApi } from '@/api/bookshelf.js';
export default {
name: 'ChapterDetail',
components: {
ArrowLeft,
ArrowRight,
ZoomIn,
ZoomOut,
MoonNight,
@ -92,63 +123,191 @@ export default {
setup() {
const route = useRoute();
const router = useRouter();
const bookId = route.params.id;
const chapterId = route.params.chapterId;
const bookTitle = ref('重生之财源滚滚');
const bookId = computed(() => route.params.id);
const chapterId = computed(() => route.params.chapterId);
//
const loading = ref(true);
const error = ref(false);
const chapter = ref(null);
const chapterList = ref([]);
const bookDetail = ref(null);
const isInBookshelf = ref(false);
const fontSize = ref(18);
const isDarkMode = ref(false);
//
const chapter = ref({
id: chapterId,
title: '第' + chapterId + '章 ' + (chapterId === '1' ? '重回2004' : '精彩内容'),
content: [
'贺季宁站在天台上,俯瞰着城市的点点灯光,感到一阵恍惚。',
'他曾经有多么意气风发,现在就有多么落魄。',
'三年前,他是A市土豪公司的金牌经理,工资高,福利好,女友漂亮,车位靓,最重要的是老板信任他。三年前,他是A市土豪公司的金牌经理,工资高,福利好,女友漂亮,车位靓,最重要的是老板信任他。',
'但是贺季宁的对手们不服,老板娘不喜欢他,不怀好意的对手们联合一起,终于把他拉下台了。',
'他失去工作,更可悲的是,他连公司配他的贷款的房子都搭进去了,欠了几百万的债,女友也离他而去。',
'这三年,他一直试图东山再起,却处处碰壁,如今债主们逼得紧,他已经走投无路。',
'"如果老天再给我一次机会,我一定要好好做人,赚很多钱,让那帮人看着我过得好,他们就会比我难受!"',
'贺季宁苦笑着喃喃自语,闭上了眼睛。',
'他感觉自己似乎在坠落,又似乎在飘浮,时间不知过了多久...',
'当他再次睁开眼睛,发现自己躺在一张熟悉又陌生的床上。',
'这是他十几年前住过的出租屋!',
'贺季宁一个鲤鱼打挺坐了起来,环顾四周,破旧的家具,泛黄的墙壁,一切都像是回到了过去。',
'他猛地抓起床头的手机——诺基亚!',
'日期显示:2004年3月15日。',
'"我...重生了?"贺季宁不敢相信自己的眼睛,他竟然回到了2004年,回到了一切开始之前!',
'一切都可以重来!所有的错误都可以避免!他知道未来的风口,知道哪些投资会成功,知道哪些陷阱要避开。',
'贺季宁握紧了拳头,眼中闪烁着坚定的光芒。',
'"这一次,我一定要扭转命运!"'
]
//
const currentChapterIndex = computed(() => {
if (!chapterList.value.length || !chapterId.value) return -1;
return chapterList.value.findIndex(ch => ch.id === chapterId.value);
});
// ID
const prevChapterId = computed(() => {
const current = parseInt(chapterId);
return current > 1 ? (current - 1).toString() : null;
const prevChapter = computed(() => {
const index = currentChapterIndex.value;
return index > 0 ? chapterList.value[index - 1] : null;
});
const nextChapterId = computed(() => {
const current = parseInt(chapterId);
return current < 50 ? (current + 1).toString() : null;
const nextChapter = computed(() => {
const index = currentChapterIndex.value;
return index >= 0 && index < chapterList.value.length - 1 ? chapterList.value[index + 1] : null;
});
//
const goToBookDetail = () => {
router.push(`/book/${bookId}`);
//
const fetchChapterDetail = async () => {
try {
loading.value = true;
error.value = false;
if (!chapterId.value) {
throw new Error('章节ID不存在');
}
//
const response = await homeApi.getBookCatalogDetail(chapterId.value);
if (response && response.success) {
chapter.value = response.result;
//
if (chapter.value.details) {
// HTML
if (/<[^>]+>/.test(chapter.value.details)) {
// 使
} else {
//
chapter.value.details = chapter.value.details
.split(/\n+/)
}
}
} else {
throw new Error(response?.message || '获取章节详情失败');
}
//
await fetchChapterList();
//
await addToReadHistory();
//
await nextTick();
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (err) {
console.error('获取章节详情失败:', err);
error.value = true;
ElMessage.error(err.message || '获取章节详情失败');
} finally {
loading.value = false;
}
};
//
const fetchChapterList = async () => {
try {
const response = await homeApi.getBookCatalogList({
bookId: bookId.value,
orderBy: 'asc',
pageNo: 1,
pageSize: 1000 //
});
if (response && response.success && response.result) {
chapterList.value = response.result.records || [];
}
} catch (err) {
console.error('获取章节列表失败:', err);
}
};
//
const addToReadHistory = async () => {
try {
const token = localStorage.getItem('token');
if (!token || !bookId.value) return;
await readBookApi.addReadBook({
bookId: bookId.value,
chapterId: chapterId.value
});
//
checkBookshelfStatus();
} catch (err) {
console.error('添加阅读记录失败:', err);
}
};
//
const goBack = () => {
router.back();
//
const checkBookshelfStatus = async () => {
try {
const token = localStorage.getItem('token');
if (!token) return;
const response = await readBookApi.getReadBookPage({
pageNo: 1,
pageSize: 1000
});
if (response && response.success && response.result) {
const readBooks = response.result.records || [];
isInBookshelf.value = readBooks.some(book => book.bookId === bookId.value);
}
} catch (err) {
console.error('检查书架状态失败:', err);
}
};
//
const goToChapter = (targetChapterId) => {
router.push(`/book/${bookId}/chapter/${targetChapterId}`);
//
const toggleBookshelf = async () => {
try {
const token = localStorage.getItem('token');
if (!token) {
ElMessage.warning('请先登录');
return;
}
if (isInBookshelf.value) {
//
await readBookApi.removeReadBook(bookId.value);
isInBookshelf.value = false;
ElMessage.success('已移出书架');
} else {
//
await readBookApi.addReadBook({
bookId: bookId.value,
chapterId: chapterId.value
});
isInBookshelf.value = true;
ElMessage.success('已添加到书架');
}
} catch (err) {
console.error('操作书架失败:', err);
ElMessage.error('操作失败,请重试');
}
};
//
const goToPrevChapter = () => {
if (prevChapter.value) {
router.push(`/book/${bookId.value}/chapter/${prevChapter.value.id}`);
}
};
//
const goToNextChapter = () => {
if (nextChapter.value) {
router.push(`/book/${bookId.value}/chapter/${nextChapter.value.id}`);
}
};
//
const goToBookDetail = () => {
router.push(`/book/${bookId.value}`);
};
//
const goToChapterList = () => {
router.push(`/book/${bookId.value}#catalog`);
};
//
@ -170,25 +329,37 @@ export default {
}
};
//
watch([bookId, chapterId], () => {
if (bookId.value && chapterId.value) {
//
window.scrollTo({ top: 0, behavior: 'instant' });
fetchChapterDetail();
}
}, { immediate: true });
onMounted(() => {
//
document.documentElement.style.setProperty('--reading-font-size', `${fontSize.value}px`);
//
window.scrollTo(0, 0);
// API
console.log(`加载章节内容,书籍ID: ${bookId},章节ID: ${chapterId}`);
});
return {
bookTitle,
loading,
error,
chapter,
prevChapterId,
nextChapterId,
prevChapter,
nextChapter,
isInBookshelf,
isDarkMode,
fetchChapterDetail,
goToPrevChapter,
goToNextChapter,
goToBookDetail,
goBack,
goToChapter,
goToChapterList,
toggleBookshelf,
toggleFontSize,
toggleTheme
};
@ -264,11 +435,16 @@ export default {
.chapter-content {
flex: 1;
max-width: 1100px;
margin: 0 auto;
width: 1100px;
padding: 30px 20px 50px;
background-color: #F3EFE6;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.05);
/* 防止内容复制 */
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
.chapter-title-container {
display: flex;
@ -285,6 +461,11 @@ export default {
font-size: 24px;
color: #333;
margin-left: 10px;
/* 禁止选中标题 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
}
@ -292,12 +473,22 @@ export default {
.chapter-text {
line-height: 1.8;
/* 禁止选中文字 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
.paragraph {
margin-bottom: 20px;
font-size: var(--reading-font-size);
color: var(--reading-text-color);
text-indent: 2em;
/* 禁止选中文字 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
}
}
@ -348,20 +539,102 @@ export default {
}
}
//
.loading-container,
.error-container,
.no-data {
padding: 60px 20px;
text-align: center;
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
//
.rich-content {
line-height: 1.8;
font-size: var(--reading-font-size);
color: var(--reading-text-color);
/* 禁止选中文字 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
:deep(p) {
margin-bottom: 20px;
text-indent: 2em;
/* 禁止选中文字 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
:deep(br) {
line-height: 1.8;
}
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
margin: 20px 0 15px 0;
color: #333;
text-indent: 0;
/* 禁止选中文字 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
:deep(img) {
max-width: 100%;
height: auto;
margin: 15px 0;
/* 禁止选中图片 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
pointer-events: none;
}
}
//
.chapter-menu-item.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
//
@media screen and (max-width: vars.$md) {
.chapter-content {
padding: 20px 15px 40px;
}
.chapter-header {
.header-content {
padding: 12px 15px;
.title {
max-width: 200px;
}
.chapter-menu {
transform: translateX(0);
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
flex-direction: row;
gap: 15px;
width: auto;
.chapter-menu-item {
width: 60px;
height: 60px;
font-size: 12px;
}
}
.loading-container,
.error-container,
.no-data {
padding: 40px 15px;
min-height: 200px;
}
}
</style>

+ 79
- 79
src/views/book/index.vue View File

@ -1,13 +1,20 @@
<template>
<div class="book-detail-container">
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-skeleton :rows="8" animated />
</div>
<!-- 详情内容 -->
<div v-else>
<!-- 小说基本信息部分 -->
<div class="book-info-wrapper">
<div class="book-info">
<div class="book-cover">
<img :src="book.cover" :alt="book.title">
<img :src="book.cover || book.image" :alt="book.title || book.name">
</div>
<div class="book-details">
<h1 class="book-title">{{ book.title }}</h1>
<h1 class="book-title">{{ book.title || book.name }}</h1>
<div class="book-meta">
<div class="meta-item">
<span class="label">作者</span>
@ -49,7 +56,8 @@
</div>
<div class="action-btn-group">
<el-button class="read-btn"
@click="goToChapter(1)"
v-if="chapters.length > 0"
@click="goToChapter(chapters[0].id)"
style="background:#0A2463;color:#fff;border:none;">点击阅读</el-button>
</div>
</div>
@ -60,24 +68,25 @@
<div class="book-main-content">
<div class="main-left">
<!-- 推荐票/亲密值统计 -->
<BookStats :book-id="book.id" />
<BookStats :book-data="book" />
<!-- 小说介绍 -->
<BookIntro :book-data="book" />
<!-- 目录 -->
<BookCatalog :book-id="book.id" />
<BookCatalog :book-id="bookId" :chapters="chapters" />
<!-- 评论 -->
<BookComments :book-id="book.id" />
<BookComments :book-id="bookId" />
</div>
<div class="main-right">
<!-- 读者亲密值榜单 -->
<IntimacyRanking :book-id="book.id" />
<IntimacyRanking :book-id="bookId" />
</div>
</div>
</div>
</div>
<!-- 互动打赏弹窗 -->
<InteractiveReward
v-model:visible="rewardDialogVisible"
:book-id="book.id"
:book-id="bookId"
@reward-success="handleRewardSuccess"
/>
</template>
@ -92,6 +101,7 @@ import BookCatalog from '@/components/book/BookCatalog.vue';
import BookComments from '@/components/book/BookComments.vue';
import IntimacyRanking from '@/components/ranking/IntimacyRanking.vue';
import InteractiveReward from '@/components/book/InteractiveReward.vue';
import { homeApi } from '@/api/modules.js';
export default {
name: 'BookDetail',
@ -109,78 +119,56 @@ export default {
const router = useRouter();
const bookId = route.params.id;
const book = reactive({
id: bookId,
title: '重生之财源滚滚',
author: '老鹰的沙',
category: '都市小说',
status: '已完结',
userGroup: '638781087(网友交流群)',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: `<p>当那一世——</p>
<p>贺季宁曾经是A市土豪公司的金牌经理工资高福利好女友漂亮车位靓最重要的是老板信任他但是贺季宁的对手不服老板娘不喜欢他不怀好意的对手们联合一起终于把他拉下台了他失去工作更可悲的是他连公司配他的贷款的房子都搭进去了欠了几百万的债女友也离他而去</p>
<p>临终前他喃喃自语如果老天给我重来一次的机会我一定要好好做人赚很多钱让那帮人看着我过得好就比他们难受</p>`,
userGroup: '638781087(网友交流群)',
chapters: [
{ id: '1', title: '第一章 重回2004', isNew: false },
{ id: '2', title: '第二章 旧车旧房', isNew: false },
{ id: '3', title: '第三章 再拼一把', isNew: false },
{ id: '4', title: '第四章 带女朋友逛街', isNew: false },
{ id: '5', title: '第五章 小刺激', isNew: false },
{ id: '6', title: '第六章 老王的门道', isNew: false },
{ id: '7', title: '第七章 正中家门', isNew: false },
{ id: '8', title: '第八章 被逼迫的交易', isNew: false },
{ id: '9', title: '第九章 意外惊喜', isNew: false },
{ id: '10', title: '第十章 生意的桥梁', isNew: true },
{ id: '11', title: '第十一章 家族分崩离析', isNew: false },
{ id: '12', title: '第十二章 老张没来', isNew: false }
],
comments: [
{
username: '万年捧',
avatar: 'https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTKlR5PibUEEsVjXGfH4c1eR5hXDicoH0EJUTHYwDO3EvZLXXgON8GrNTbRg8DnzaddicibYnGcfq28tYg/132',
time: '2022-07-03',
content: '这是本年内看的唯一一部完结的!看的人真幸运,发家文和风险防控写的都是一流'
},
{
username: '残生往事',
avatar: 'https://thirdwx.qlogo.cn/mmopen/vi_32/3F4feeHnMyoGjqKfP8vGKCHwyvovMHiaO0Q1QkQMRTGibLcyJbUcUJ4LmdkkDqC5ZcqP1rvqKMviaYAyehqYb6ciaA/132',
content: '我很喜欢男主的性格,不小心眼,有格局,做事情多考虑下一步,商业和情感都处理得不错,就是那个林涵有点没必要吧?'
//
const loading = ref(false);
const book = ref({});
const recommendedBooks = ref([]);
const chapters = ref([]);
//
const fetchBookDetail = async () => {
loading.value = true;
try {
const response = await homeApi.getBookDetail({ id: bookId });
if (response.success) {
const bookData = response.result;
//
const statusText = bookData.status === 1 ? '已完结' : '连载中';
book.value = {
...bookData,
cover: bookData.image || bookData.cover,
title: bookData.name || bookData.title,
description: bookData.details || bookData.description,
status: statusText
};
}
]
});
} catch (error) {
console.error('获取书籍详情失败:', error);
} finally {
loading.value = false;
}
};
//
const recommendedBooks = reactive([
{
id: '101',
title: '三体',
author: '刘慈欣',
cover: 'https://picsum.photos/120/160?random=1',
status: '已完结'
},
{
id: '102',
title: '活着',
author: '余华',
cover: 'https://picsum.photos/120/160?random=2',
status: '已完结'
},
{
id: '103',
title: '平凡的世界',
author: '路遥',
cover: 'https://picsum.photos/120/160?random=3',
status: '已完结'
},
{
id: '104',
title: '围城',
author: '钱钟书',
cover: 'https://picsum.photos/120/160?random=4',
status: '已完结'
//
const fetchChapters = async () => {
try {
const response = await homeApi.getBookCatalogList({
bookId: bookId,
pageNo: 1,
pageSize: 100000, //
});
if (response.success && response.result) {
//
const catalogData = response.result.records || response.result || [];
chapters.value = catalogData
}
} catch (error) {
console.error('获取章节目录失败:', error);
chapters.value = [];
}
]);
};
const activeTab = ref('intro');
const currentPage = ref(1);
@ -256,13 +244,19 @@ export default {
};
onMounted(() => {
// bookIdAPI
console.log('加载书籍详情,ID:', bookId);
//
Promise.all([
fetchBookDetail(),
fetchChapters()
]);
});
return {
loading,
book,
bookId,
recommendedBooks,
chapters,
activeTab,
currentPage,
commentText,
@ -288,6 +282,12 @@ export default {
background-color: #f5f5f5;
}
.loading-container {
padding: 40px 20px;
max-width: 1200px;
margin: 0 auto;
}
//
.book-info-wrapper {
background-color: #fff;


+ 64
- 124
src/views/home/Home.vue View File

@ -1,5 +1,12 @@
<template>
<div class="home-container">
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-skeleton :rows="5" animated />
</div>
<!-- 首页内容 -->
<div v-else>
<!-- 轮播图和公告部分 -->
<div class="banner-announce-wrapper">
<!-- 轮播图 -->
@ -7,10 +14,10 @@
<el-carousel height="280px" indicator-position="outside">
<el-carousel-item v-for="(banner, index) in banners" :key="index"
@click="goToDetail(banner.id)">
<div class="banner-item" :style="{ backgroundImage: `url(${banner.img})` }">
<div class="banner-item" :style="{ backgroundImage: `url(${banner.image})` }">
<div class="banner-content">
<h2>{{ banner.title }}</h2>
<p>{{ banner.desc }}</p>
<p>{{ banner.details }}</p>
</div>
</div>
</el-carousel-item>
@ -63,6 +70,7 @@
</div>
</div>
</div>
</div>
</div>
</template>
@ -71,6 +79,7 @@ import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ArrowRight } from '@element-plus/icons-vue';
import BookCard from '@/components/common/BookCard.vue';
import { homeApi } from '@/api/modules.js';
export default {
name: 'HomeView',
@ -81,139 +90,65 @@ export default {
setup() {
const router = useRouter();
const activeCategory = ref('1');
const loading = ref(false);
//
const banners = ref([]);
const announcements = ref([]);
const recommendBooks = ref([]);
const latestBooks = ref([]);
//
const fetchHomeData = async () => {
loading.value = true;
try {
//
const [bannerRes, noticeRes, recommendRes, newBooksRes] = await Promise.all([
homeApi.getBanner(),
homeApi.getNoticePage({ pageNo: 1, pageSize: 4 }),
homeApi.getRecommendList(),
homeApi.getNewList({ pageNo: 1, pageSize: 4 })
]);
//
if (bannerRes.success) {
banners.value = bannerRes.result || [];
}
//
const banners = reactive([
{
id: '1',
title: '最热门小说',
desc: '发现最受欢迎的小说,开启阅读之旅',
img: 'https://picsum.photos/1200/320?random=1'
},
{
id: '2',
title: '经典文学',
desc: '品味经典,感受文学的魅力',
img: 'https://picsum.photos/1200/320?random=2'
},
{
id: '3',
title: '最新上架',
desc: '第一时间阅读最新内容',
img: 'https://picsum.photos/1200/320?random=3'
}
]);
//
const announcements = reactive([
{
id: '1',
title: '《我不是药神》即将上线',
type: 'tongzhi',
typeText: '通知'
},
{
id: '2',
title: '2025年2月平台新活动征集优秀作品公示',
type: 'gonggao',
typeText: '公告'
},
{
id: '3',
title: '《我不是神》发布',
type: 'tongzhi',
typeText: '通知'
},
{
id: '4',
title: '2025年1月平台利用活动征集优秀作品公示',
type: 'gonggao',
typeText: '公告'
}
]);
//
const recommendBooks = reactive([
{
id: '1',
title: '三体',
author: '刘慈欣',
cover: 'https://picsum.photos/120/160?random=1',
description: '科幻小说代表作,讲述人类文明与三体文明的复杂关系',
tags: ['科幻', '获奖作品']
},
{
id: '2',
title: '活着',
author: '余华',
cover: 'https://picsum.photos/120/160?random=2',
description: '讲述了农村人福贵悲惨的人生遭遇',
tags: ['文学', '经典']
},
{
id: '3',
title: '平凡的世界',
author: '路遥',
cover: 'https://picsum.photos/120/160?random=3',
description: '展示了普通人在大时代中的命运变迁',
tags: ['当代文学', '长篇小说']
},
{
id: '4',
title: '围城',
author: '钱钟书',
cover: 'https://picsum.photos/120/160?random=4',
description: '描述了才子方鸿渐的情感生活和婚姻生活',
tags: ['讽刺', '文学名著']
}
]);
//
const latestBooks = reactive([
{
id: '5',
title: '人类简史',
author: '尤瓦尔·赫拉利',
cover: 'https://picsum.photos/120/160?random=5',
description: '从认知革命、农业革命和科学革命三个重大革命讲述人类历史',
tags: ['历史', '科普']
},
{
id: '6',
title: '百年孤独',
author: '加西亚·马尔克斯',
cover: 'https://picsum.photos/120/160?random=6',
description: '讲述了布恩迪亚家族七代人的故事',
tags: ['魔幻现实主义', '外国文学']
},
{
id: '7',
title: '解忧杂货店',
author: '东野圭吾',
cover: 'https://picsum.photos/120/160?random=7',
description: '一个神奇的杂货店,可以解答来信者的各种烦恼',
tags: ['治愈', '小说']
},
{
id: '8',
title: '白夜行',
author: '东野圭吾',
cover: 'https://picsum.photos/120/160?random=8',
description: '一对有着不同寻常情愫的少年少女步入歧途的故事',
tags: ['推理', '日本文学']
}
]);
//
if (noticeRes.success) {
announcements.value = (noticeRes.result?.records || []).map(item => ({
...item,
typeText: item.type === 'gonggao' ? '公告' : '通知'
}));
}
//
if (recommendRes.success) {
recommendBooks.value = recommendRes.result || [];
}
//
if (newBooksRes.success) {
latestBooks.value = (newBooksRes.result?.records || newBooksRes.result) || [];
}
} catch (error) {
console.error('获取首页数据失败:', error);
} finally {
loading.value = false;
}
};
const goToDetail = (id) => {
router.push(`/book/${id}`);
};
const viewAnnouncement = (id) => {
router.push(`/announcement/${id}`);
router.push(`/notice/${id}`);
};
onMounted(() => {
fetchHomeData();
});
return {
@ -222,6 +157,7 @@ export default {
recommendBooks,
latestBooks,
activeCategory,
loading,
goToDetail,
viewAnnouncement
};
@ -237,6 +173,10 @@ export default {
background-color: #fff;
}
.loading-container {
padding: 20px;
}
//
.banner-announce-wrapper {
padding: 20px;


+ 330
- 0
src/views/home/NoticeDetail.vue View File

@ -0,0 +1,330 @@
<template>
<div class="notice-detail">
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-skeleton :rows="8" animated>
<template #template>
<el-skeleton-item variant="text" style="width: 60%; margin-bottom: 20px;" />
<el-skeleton-item variant="text" style="width: 30%; margin-bottom: 30px;" />
<el-skeleton-item variant="text" style="width: 100%; margin-bottom: 15px;" />
<el-skeleton-item variant="text" style="width: 100%; margin-bottom: 15px;" />
<el-skeleton-item variant="text" style="width: 80%; margin-bottom: 15px;" />
<el-skeleton-item variant="text" style="width: 100%; margin-bottom: 15px;" />
<el-skeleton-item variant="text" style="width: 90%; margin-bottom: 15px;" />
<el-skeleton-item variant="text" style="width: 70%;" />
</template>
</el-skeleton>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<el-empty description="加载失败">
<el-button type="primary" @click="fetchNoticeDetail">重新加载</el-button>
</el-empty>
</div>
<!-- 公告内容 -->
<div v-else-if="noticeDetail" class="notice-content">
<!-- 头部信息 -->
<div class="notice-header">
<h1 class="notice-title">{{ noticeDetail.title }}</h1>
<div class="notice-meta" style="padding-bottom: 10px;">
<span v-if="noticeDetail.titleText" class="notice-time">
{{ noticeDetail.titleText }}
</span>
</div>
<div class="notice-meta">
<span v-if="noticeDetail.typeText" class="notice-type">
<el-tag :type="getTagType(noticeDetail.typeText)">{{ noticeDetail.typeText }}</el-tag>
</span>
<span v-if="noticeDetail.createTime" class="notice-time">
发布时间{{ formatTime(noticeDetail.createTime) }}
</span>
</div>
</div>
<!-- 内容区域 -->
<div class="notice-body">
<div v-if="noticeDetail.details" class="notice-text" v-html="noticeDetail.details"></div>
<div v-else class="no-content">
<el-empty description="暂无内容" />
</div>
</div>
<!-- 返回按钮 -->
<div class="notice-footer">
<el-button @click="goBack">返回</el-button>
</div>
</div>
<!-- 无数据状态 -->
<div v-else class="no-data">
<el-empty description="公告不存在" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { homeApi } from '@/api/home.js';
import Breadcrumb from '@/layout/components/Breadcrumb.vue';
const route = useRoute();
const router = useRouter();
//
const loading = ref(true);
const error = ref(false);
const noticeDetail = ref(null);
//
const fetchNoticeDetail = async () => {
try {
loading.value = true;
error.value = false;
const noticeId = route.params.id;
if (!noticeId) {
throw new Error('公告ID不存在');
}
const response = await homeApi.getNoticeById(noticeId);
if (response && response.success) {
noticeDetail.value = response.result;
} else {
throw new Error(response?.message || '获取公告详情失败');
}
} catch (err) {
console.error('获取公告详情失败:', err);
error.value = true;
ElMessage.error(err.message || '获取公告详情失败');
} finally {
loading.value = false;
}
};
//
const formatTime = (timeStr) => {
if (!timeStr) return '';
const date = new Date(timeStr);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
//
const getTagType = (typeText) => {
const typeMap = {
'系统公告': 'info',
'活动公告': 'success',
'维护公告': 'warning',
'紧急公告': 'danger'
};
return typeMap[typeText] || 'info';
};
//
const goBack = () => {
if (window.history.length > 1) {
router.go(-1);
} else {
router.push('/');
}
};
//
onMounted(() => {
fetchNoticeDetail();
});
</script>
<style lang="scss" scoped>
.notice-detail {
// margin: 0 auto;
margin: 20px 0;
padding: 40px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
.loading-container,
.error-container,
.no-data {
padding: 40px 20px;
text-align: center;
}
.notice-content {
.notice-header {
border-bottom: 2px solid #f5f5f5;
padding-bottom: 20px;
margin-bottom: 30px;
.notice-title {
font-size: 28px;
font-weight: 600;
color: #333;
line-height: 1.4;
margin: 0 0 15px 0;
}
.notice-meta {
display: flex;
align-items: center;
gap: 20px;
font-size: 14px;
color: #666;
.notice-type {
:deep(.el-tag) {
font-size: 12px;
}
}
.notice-time {
color: #999;
}
}
}
.notice-body {
margin-bottom: 40px;
.notice-text {
font-size: 16px;
line-height: 1.8;
color: #333;
word-wrap: break-word;
//
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4),
:deep(h5),
:deep(h6) {
margin: 20px 0 15px 0;
color: #0A2463;
}
:deep(p) {
margin: 15px 0;
line-height: 1.8;
}
:deep(img) {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 15px 0;
}
:deep(ul),
:deep(ol) {
margin: 15px 0;
padding-left: 30px;
}
:deep(li) {
margin: 8px 0;
line-height: 1.6;
}
:deep(blockquote) {
margin: 20px 0;
padding: 15px 20px;
background: #f8f9fa;
border-left: 4px solid #0A2463;
border-radius: 4px;
}
:deep(code) {
background: #f1f3f4;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
:deep(pre) {
background: #f8f9fa;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
margin: 15px 0;
}
:deep(table) {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
:deep(th),
:deep(td) {
border: 1px solid #e0e0e0;
padding: 10px;
text-align: left;
}
:deep(th) {
background: #f5f5f5;
font-weight: 600;
}
:deep(a) {
color: #0A2463;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.no-content {
padding: 60px 20px;
}
}
.notice-footer {
text-align: center;
padding-top: 20px;
border-top: 1px solid #f5f5f5;
}
}
}
//
@media (max-width: 768px) {
.notice-detail {
margin: 0;
padding: 15px;
border-radius: 0;
box-shadow: none;
.notice-content .notice-header {
.notice-title {
font-size: 24px;
}
.notice-meta {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
.notice-content .notice-body .notice-text {
font-size: 15px;
}
}
}
</style>

+ 120
- 107
src/views/home/category.vue View File

@ -20,15 +20,37 @@
<!-- 右侧书籍列表 -->
<div class="book-content" :class="{ 'full-width': hasRouteId }">
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-skeleton :rows="8" animated />
</div>
<!-- 书籍列表 -->
<div class="book-list-container">
<div v-else-if="books.length > 0" class="book-list-container">
<div v-for="(book, index) in books" :key="index" class="book-item">
<book-card :book="book" />
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-state">
<div class="empty-icon">
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="15" y="20" width="50" height="40" rx="4" stroke="#E5E5E5" stroke-width="2" fill="none"/>
<rect x="20" y="25" width="40" height="2" rx="1" fill="#E5E5E5"/>
<rect x="20" y="30" width="25" height="2" rx="1" fill="#E5E5E5"/>
<rect x="20" y="35" width="30" height="2" rx="1" fill="#E5E5E5"/>
<rect x="20" y="40" width="20" height="2" rx="1" fill="#E5E5E5"/>
<rect x="20" y="45" width="35" height="2" rx="1" fill="#E5E5E5"/>
<rect x="20" y="50" width="22" height="2" rx="1" fill="#E5E5E5"/>
</svg>
</div>
<p class="empty-title">暂无书籍</p>
<p class="empty-desc">该分类下暂时没有可阅读的书籍</p>
</div>
<!-- 分页 -->
<div class="pagination-container">
<div v-if="!loading && books.length > 0" class="pagination-container">
<el-pagination v-model:current-page="currentPage" :page-size="pageSize" :total="total"
layout="prev, pager, next" @current-change="handlePageChange" />
</div>
@ -38,8 +60,10 @@
</template>
<script>
import { ref, reactive, onMounted, computed } from 'vue';
import { ref, reactive, onMounted, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMainStore } from '@/store';
import { homeApi } from '@/api/modules.js';
import BookCard from '@/components/common/BookCard.vue';
export default {
@ -50,6 +74,8 @@ export default {
setup() {
const route = useRoute();
const router = useRouter();
const store = useMainStore();
const categoryId = computed(() => route.params.id);
const currentCategoryId = ref(categoryId.value || '1');
@ -59,133 +85,88 @@ export default {
//
const currentPage = ref(1);
const pageSize = ref(20);
const total = ref(100);
//
const categoryMap = {
'1': '武侠',
'2': '都市',
'3': '玄幻',
'4': '历史',
'5': '浪漫青春',
'6': '短篇',
'7': '言情',
'8': '小说'
};
const total = ref(0);
const loading = ref(false);
//
const books = ref([]);
//
const categoryMap = computed(() => {
const map = {};
store.allCategories.forEach(category => {
map[category.id] = category.title;
});
return map;
});
//
const categoryName = computed(() => categoryMap[currentCategoryId.value] || '玄幻类');
//
const books = reactive([
{
id: '1',
title: '大宋好厨夫',
author: '祝家大郎',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '书友群:638781087智慧老猿经过了北宋水浒世界,变成了那个被孙二娘三拳打死的张青',
status: '已完结'
},
{
id: '2',
title: '重生日本当厨神',
author: '千回转',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '作死一次安然,宁原吃料理到撑死的复仇,来到一个他完全陌生的食林之中',
status: '已完结'
},
{
id: '3',
title: '罗修炎月儿武道大帝',
author: '忘情至尊',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '关于武道大帝:少年罗修出身寒微,天赋一般,却意外觅得奇遇本末逆转',
status: '已完结'
},
{
id: '4',
title: '神豪无极限',
author: '匿名',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '穿越到平行时空的陆安打了个响指,开启了全新的生活方式,他说,其实掐的',
status: '已完结'
},
{
id: '5',
title: '顾道长生',
author: '睡觉变白',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '本以为是写实的都市生活,结果一言不合就修仙!灵气复苏,道法重现,这',
status: '已完结'
},
{
id: '6',
title: '魔天记',
author: '忘语',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '一名在无数岁月中中长大的亡魂少年,一个与魂并立的时代,一个个可以仿佛',
status: '已完结'
},
{
id: '7',
title: '二十面骰子',
author: '赛斯',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '在整千七朝由感转化的时代,新大陆的出现成为各种族冒险争夺的乐园,延',
status: '已完结'
},
{
id: '8',
title: '苏莫是什么小说',
author: '半代溜王',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '苏莫少年苏莫,突破逆天武魂,却被认为是最低级的垃圾武魂,受尽屈辱…',
status: '已完结'
},
{
id: '9',
title: '重生大明当暴君',
author: '圆溜溜',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '你以为你请我建设好,联不如意?你以为你随商宰,联不如意?东南富庶,而',
status: '已完结'
},
{
id: '10',
title: '重生大明当暴君',
author: '圆溜溜',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '你以为你请我建设好,联不如意?你以为你随商宰,联不如意?东南富庶,而',
status: '已完结'
const categoryName = computed(() => {
const category = store.allCategories.find(cat => cat.id === currentCategoryId.value);
return category?.title || '分类';
});
//
const fetchBooks = async () => {
loading.value = true;
try {
// API
// 使APIAPI
const response = await homeApi.getRecommendList();
if (response.success && response.result) {
books.value = response.result
total.value = books.value.length;
}
} catch (error) {
console.error('获取书籍列表失败:', error);
books.value = [];
} finally {
loading.value = false;
}
]);
};
//
const switchCategory = (id) => {
currentCategoryId.value = id;
currentPage.value = 1;
// API
console.log('切换到分类:', id);
fetchBooks();
};
const handlePageChange = (page) => {
currentPage.value = page;
// API
console.log('切换到页码:', page);
fetchBooks();
};
onMounted(() => {
// ID
watch(
() => route.params.id,
(newId) => {
if (newId) {
currentCategoryId.value = newId;
currentPage.value = 1;
fetchBooks();
}
},
{ immediate: false }
);
onMounted(async () => {
//
await store.fetchCategories();
if (categoryId.value) {
currentCategoryId.value = categoryId.value;
}
// IDAPI
console.log('加载分类ID:', currentCategoryId.value);
//
fetchBooks();
});
return {
categoryName,
categoryMap,
books,
loading,
currentPage,
pageSize,
total,
@ -283,6 +264,38 @@ export default {
&.full-width {
width: 100%;
}
.loading-container {
padding: 20px 0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
.empty-icon {
margin-bottom: 20px;
opacity: 0.6;
}
.empty-title {
font-size: 18px;
font-weight: 500;
color: #666;
margin: 0 0 10px 0;
}
.empty-desc {
font-size: 14px;
color: #999;
margin: 0;
line-height: 1.5;
}
}
}
.book-list-container {


+ 125
- 121
src/views/home/ranking.vue View File

@ -23,22 +23,37 @@
</ul>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-skeleton :rows="10" animated />
</div>
<!-- 书籍列表 -->
<div class="book-list-container">
<div v-for="(book, index) in books" :key="index" class="book-item">
<div v-else class="book-list-container">
<div v-if="books.length === 0" class="empty-state">
<el-empty description="暂无数据" />
</div>
<div v-else>
<div v-for="(book, index) in books" :key="book.id || index" class="book-item">
<div class="rank-number" :class="{
'rank-first': index === 0,
'rank-second': index === 1,
'rank-third': index === 2
}">{{ index + 1 }}</div>
}">{{ (currentPage - 1) * pageSize + index + 1 }}</div>
<book-card :book="book" />
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination v-model:current-page="currentPage" :page-size="pageSize" :total="total"
layout="prev, pager, next" @current-change="handlePageChange" />
<div class="pagination-container" v-if="!loading && books.length > 0">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next, jumper"
@current-change="handlePageChange"
/>
</div>
</div>
</div>
@ -46,9 +61,12 @@
</template>
<script>
import { ref, reactive, onMounted, computed } from 'vue';
import { ref, onMounted, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useMainStore } from '@/store';
import BookCard from '@/components/common/BookCard.vue';
import { homeApi } from '@/api/home.js';
export default {
name: 'RankingView',
@ -58,28 +76,31 @@ export default {
setup() {
const route = useRoute();
const router = useRouter();
const store = useMainStore();
const categoryId = computed(() => route.params.id);
const currentCategoryId = ref(categoryId.value || '1');
const currentCategoryId = ref(categoryId.value);
// ID
const hasRouteId = computed(() => !!categoryId.value);
//
const books = ref([]);
const loading = ref(false);
//
const currentPage = ref(1);
const pageSize = ref(20);
const total = ref(100);
//
const categoryMap = {
'1': '武侠',
'2': '都市',
'3': '玄幻',
'4': '历史',
'5': '浪漫青春',
'6': '短篇',
'7': '言情',
'8': '小说'
};
const total = ref(0);
// store
const categoryMap = computed(() => {
const map = {};
store.allCategories.forEach(category => {
map[category.id] = category.title;
});
if(!currentCategoryId.value && store.allCategories[0]){
currentCategoryId.value = store.allCategories[0].id;
}
return map;
});
//
const rankingTypes = {
@ -93,133 +114,107 @@ export default {
const currentRankingType = ref('1');
//
const categoryName = computed(() => categoryMap[currentCategoryId.value] || '玄幻类');
//
const books = reactive([
{
id: '1',
title: '大宋好厨夫',
author: '祝家大郎',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '书友群:638781087智慧老猿经过了北宋水浒世界,变成了那个被孙二娘三拳打死的张青',
status: '已完结'
},
{
id: '2',
title: '重生日本当厨神',
author: '千回转',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '作死一次安然,宁原吃料理到撑死的复仇,来到一个他完全陌生的食林之中',
status: '已完结'
},
{
id: '3',
title: '罗修炎月儿武道大帝',
author: '忘情至尊',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '关于武道大帝:少年罗修出身寒微,天赋一般,却意外觅得奇遇本末逆转',
status: '已完结'
},
{
id: '4',
title: '神豪无极限',
author: '匿名',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '穿越到平行时空的陆安打了个响指,开启了全新的生活方式,他说,其实掐的',
status: '已完结'
},
{
id: '5',
title: '顾道长生',
author: '睡觉变白',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '本以为是写实的都市生活,结果一言不合就修仙!灵气复苏,道法重现,这',
status: '已完结'
},
{
id: '6',
title: '魔天记',
author: '忘语',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '一名在无数岁月中中长大的亡魂少年,一个与魂并立的时代,一个个可以仿佛',
status: '已完结'
},
{
id: '7',
title: '二十面骰子',
author: '赛斯',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '在整千七朝由感转化的时代,新大陆的出现成为各种族冒险争夺的乐园,延',
status: '已完结'
},
{
id: '8',
title: '苏莫是什么小说',
author: '半代溜王',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '苏莫少年苏莫,突破逆天武魂,却被认为是最低级的垃圾武魂,受尽屈辱…',
status: '已完结'
},
{
id: '9',
title: '重生大明当暴君',
author: '圆溜溜',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '你以为你请我建设好,联不如意?你以为你随商宰,联不如意?东南富庶,而',
status: '已完结'
},
{
id: '10',
title: '重生大明当暴君',
author: '圆溜溜',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: '你以为你请我建设好,联不如意?你以为你随商宰,联不如意?东南富庶,而',
status: '已完结'
const categoryName = computed(() => {
const category = store.allCategories.find(cat => cat.id === currentCategoryId.value);
return category?.title || '玄幻类';
});
//
const fetchRankingData = async () => {
if (loading.value) return;
loading.value = true;
try {
const response = await homeApi.getRankingList({
rankType: currentRankingType.value,
categoryId: currentCategoryId.value,
pageNo: currentPage.value,
pageSize: pageSize.value
});
if (response.success && response.result) {
//
if (response.result.records) {
books.value = response.result.records;
total.value = response.result.total || 0;
} else if (Array.isArray(response.result)) {
books.value = response.result;
total.value = response.result.length;
} else {
books.value = [];
total.value = 0;
}
} else {
books.value = [];
total.value = 0;
ElMessage.warning(response.message || '获取排行榜数据失败');
}
} catch (error) {
console.error('获取排行榜数据失败:', error);
books.value = [];
total.value = 0;
ElMessage.error('网络错误,请稍后重试');
} finally {
loading.value = false;
}
]);
};
//
const switchCategory = (id) => {
if (currentCategoryId.value === id) return;
currentCategoryId.value = id;
currentPage.value = 1;
// API
console.log('切换到分类:', id);
fetchRankingData();
};
//
const switchRankingType = (id) => {
if (currentRankingType.value === id) return;
currentRankingType.value = id;
currentPage.value = 1;
// API
console.log('切换到榜单:', id);
fetchRankingData();
};
//
const handlePageChange = (page) => {
currentPage.value = page;
// API
console.log('切换到页码:', page);
fetchRankingData();
};
onMounted(() => {
//
watch(() => route.params.id, (newId) => {
if (newId && newId !== currentCategoryId.value) {
currentCategoryId.value = newId;
currentPage.value = 1;
fetchRankingData();
}
});
//
onMounted(async () => {
//
await store.fetchCategories();
//
if (categoryId.value) {
currentCategoryId.value = categoryId.value;
}
// IDAPI
console.log('加载分类ID:', currentCategoryId.value);
//
await fetchRankingData();
});
return {
categoryName,
categoryMap,
books,
loading,
currentPage,
pageSize,
total,
handlePageChange,
currentCategoryId,
switchCategory,
hasRouteId,
rankingTypes,
currentRankingType,
switchRankingType
@ -325,11 +320,17 @@ export default {
}
}
.loading-container {
padding: 20px;
}
.empty-state {
padding: 40px 20px;
text-align: center;
}
.book-list-container {
display: flex;
flex-direction: column;
gap: 15px;
margin-bottom: 30px;
min-height: 400px;
padding: 20px;
}
@ -338,6 +339,7 @@ export default {
align-items: center;
border-bottom: 1px solid #f0f0f0;
padding-bottom: 15px;
margin-bottom: 15px;
position: relative;
}
@ -372,5 +374,7 @@ export default {
display: flex;
justify-content: center;
padding: 20px 0;
border-top: 1px solid #f0f0f0;
margin-top: 20px;
}
</style>

Loading…
Cancel
Save