Browse Source

feat: 初始化小说前端项目,添加基础结构和功能

- 添加了项目基础结构,包括路由、状态管理、API请求等
- 实现了用户登录、注册、退出功能
- 添加了首页、书籍详情、章节阅读等页面
- 实现了书籍搜索、书架管理、用户中心等功能
- 添加了全局样式和主题配置
- 实现了响应式布局,支持移动端和PC端
- 添加了图片资源,优化了页面加载速度
master
前端-胡立永 1 month ago
parent
commit
3fab88d03f
74 changed files with 3399 additions and 2 deletions
  1. +20
    -0
      .cursor/rules/rules.mdc
  2. +62
    -0
      .cursor/rules/yw.mdc
  3. +15
    -0
      .gitignore
  4. +74
    -2
      README.md
  5. +16
    -0
      index.html
  6. +22
    -0
      package.json
  7. +42
    -0
      src/App.vue
  8. +39
    -0
      src/api/index.js
  9. BIN
      src/assets/images/.DS_Store
  10. BIN
      src/assets/images/1.png
  11. BIN
      src/assets/images/2.png
  12. BIN
      src/assets/images/3.png
  13. BIN
      src/assets/images/Group 2681.png
  14. BIN
      src/assets/images/Group 2878-1.png
  15. BIN
      src/assets/images/Group 2878-2.png
  16. BIN
      src/assets/images/Group 2878-3.png
  17. BIN
      src/assets/images/Group 2878.png
  18. BIN
      src/assets/images/home/announcement.png
  19. BIN
      src/assets/images/image-1.png
  20. BIN
      src/assets/images/image-2.png
  21. BIN
      src/assets/images/image-3.png
  22. BIN
      src/assets/images/image-4.png
  23. BIN
      src/assets/images/image-5.png
  24. BIN
      src/assets/images/image-6.png
  25. BIN
      src/assets/images/image-7.png
  26. BIN
      src/assets/images/image.png
  27. BIN
      src/assets/images/下.png
  28. BIN
      src/assets/images/书城-1.png
  29. BIN
      src/assets/images/书城-2.png
  30. BIN
      src/assets/images/书城.png
  31. BIN
      src/assets/images/书架.png
  32. BIN
      src/assets/images/任务中心.png
  33. BIN
      src/assets/images/修改信息.png
  34. BIN
      src/assets/images/加.png
  35. BIN
      src/assets/images/加载.png
  36. BIN
      src/assets/images/取消删除.png
  37. BIN
      src/assets/images/右.png
  38. BIN
      src/assets/images/失败.png
  39. BIN
      src/assets/images/布丁小说logo.png
  40. BIN
      src/assets/images/成功.png
  41. BIN
      src/assets/images/我的.png
  42. BIN
      src/assets/images/我的等级.png
  43. BIN
      src/assets/images/我的评论.png
  44. BIN
      src/assets/images/投推荐票.png
  45. BIN
      src/assets/images/推荐票.png
  46. BIN
      src/assets/images/申请成为创作者.png
  47. BIN
      src/assets/images/礼物盒.png
  48. BIN
      src/assets/images/移除书架.png
  49. BIN
      src/assets/images/移除全部.png
  50. BIN
      src/assets/images/联系客服.png
  51. BIN
      src/assets/images/读者榜单.png
  52. BIN
      src/assets/images/退出登录.png
  53. BIN
      src/assets/images/钱包流水.png
  54. BIN
      src/assets/images/首页.png
  55. BIN
      src/assets/images/默认头像.png
  56. +109
    -0
      src/assets/styles/global.scss
  57. +34
    -0
      src/assets/styles/variables.scss
  58. +75
    -0
      src/components/auth/AuthProvider.vue
  59. +324
    -0
      src/components/auth/LoginRegisterModal.vue
  60. +139
    -0
      src/components/common/BookCard.vue
  61. +105
    -0
      src/layout/components/BackToTop.vue
  62. +91
    -0
      src/layout/components/Breadcrumb.vue
  63. +69
    -0
      src/layout/index.vue
  64. +143
    -0
      src/layout/layout/Footer.vue
  65. +318
    -0
      src/layout/layout/Header.vue
  66. +26
    -0
      src/main.js
  67. +53
    -0
      src/router/index.js
  68. +83
    -0
      src/store/index.js
  69. +69
    -0
      src/utils/request.js
  70. +44
    -0
      src/views/NotFound.vue
  71. +352
    -0
      src/views/book/chapter.vue
  72. +576
    -0
      src/views/book/index.vue
  73. +483
    -0
      src/views/home/Home.vue
  74. +16
    -0
      vite.config.js

+ 20
- 0
.cursor/rules/rules.mdc View File

@ -0,0 +1,20 @@
---
description:
globs:
alwaysApply: true
---
## 技术栈使用
- Vue3
- element-plus
- piana
- Vue Router
- scss
## 编码习惯
写代码的之前先要看一下目录结构,查看有没有可以复用的组件,如果没用则需要编写代码按照模块拆分成组件使用,让代码可以复用
## 主色调 #0A2463
## 需要图片的时候去/src/assetsimages查看是否有合适的

+ 62
- 0
.cursor/rules/yw.mdc View File

@ -0,0 +1,62 @@
---
description:
globs:
alwaysApply: false
---
## 业务结构:了解全局业务
2.1. 用户注册&登录
2.1.1. 新用户注册流程
2.1.2. 已注册用户登录流程
2.1.3. 服务协议
2.1.4. 隐私政策
2.2. 首页-精品推荐
2.2.1. 列表页
2.2.2. 公告列表
2.2.3. 公告详情
2.2.4. 书本详情页
2.2.4.1. 功能-互动打赏
2.2.4.2. 功能-亲密值&排行
2.2.4.3. 功能-加入书架
2.2.4.4. 功能-投推荐票
2.2.4.5. 功能-查看全部章节
2.2.4.6. 功能-写书评
2.2.4.7. 功能-回复书评
2.2.5. 书本阅读页
2.2.5.1. 功能-加入书架
2.2.5.2. 功能-查看目录
2.2.5.3. 功能-目录跳转章节
2.2.6. 付费流程
2.3. 分类作品
2.4. 排行榜
2.5. 书架
2.5.1. 功能-删除书本
2.5.2. 功能-清空书架
2.6. 消息
2.7. 作家专区
2.7.1. 作品管理
2.7.2. 功能-读者管理
2.7.2.1. 未设置成就
2.7.2.2. 首次设置成就
2.7.2.3. 首次提交审核
2.7.2.4. 已设置成就
2.7.2.5. 二次修改
2.7.2.6. 二次提交审核
2.7.2.7. 多级成就
2.7.3. 功能-新建作品
2.7.4. 去写作
2.7.4.1. 已发布内容
2.7.4.1.1. 已发布内容修改
2.8. 我的模块
2.8.1. 用户主页
2.8.2. 个人信息
2.8.2.1. 豆豆-查看详情(流水记录)
2.8.2.2. 豆豆-去充值
2.8.3. 礼物盒
2.8.3.1. 礼物购买
2.8.4. 任务中心
2.8.5. 申请成为创作者
2.8.6. 钱包流水
2.8.7. 修改手机号
2.8.8. 退出登录

+ 15
- 0
.gitignore View File

@ -0,0 +1,15 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
.idea
package-lock.json
node_modules
unpackage
dist
.hbuilderx
.vite

+ 74
- 2
README.md View File

@ -1,3 +1,75 @@
# novel-front-pc
# 小说前端PC项目
小说网站前端代码仓库
基于 Vue 3 + Element Plus + Pinia + Vue Router + SCSS 的小说网站前端项目。
## 技术栈
- Vue 3 - 渐进式JavaScript框架
- Element Plus - 基于Vue 3的组件库
- Pinia - Vue的状态管理库
- Vue Router - Vue官方路由管理器
- SCSS - CSS预处理器
## 项目结构
```
novel-front-pc/
├── public/ # 静态资源
├── src/
│ ├── api/ # API请求
│ ├── assets/ # 资源文件(图片、样式等)
│ │ └── styles/ # 样式文件
│ ├── components/ # 组件
│ │ ├── common/ # 通用组件
│ │ └── layout/ # 布局组件
│ ├── router/ # 路由配置
│ ├── store/ # Pinia状态管理
│ ├── utils/ # 工具函数
│ ├── views/ # 页面视图
│ ├── App.vue # 根组件
│ └── main.js # 入口文件
├── .gitignore # Git忽略文件
├── index.html # HTML模板
├── package.json # 项目依赖
├── README.md # 项目说明
└── vite.config.js # Vite配置
```
## 开发指南
### 安装依赖
```bash
npm install
```
### 启动开发服务器
```bash
npm run dev
```
### 构建生产版本
```bash
npm run build
```
### 预览生产版本
```bash
npm run serve
```
## 开发规范
- 组件名使用 PascalCase 命名法
- 文件夹和文件名使用 kebab-case 命名法
- 使用 SCSS 编写样式
- 组件样式使用 scoped 属性限定作用域
- 使用 Pinia 进行状态管理
- 使用 Vue Router 进行路由管理
## 组件复用
在编写代码前,请先查看项目目录结构,确认是否有可复用的组件。如果没有可复用的组件,请按照模块进行拆分,使代码可以被复用。

+ 16
- 0
index.html View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>小说前端项目</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

+ 22
- 0
package.json View File

@ -0,0 +1,22 @@
{
"name": "novel-front-pc",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
},
"dependencies": {
"axios": "^1.9.0",
"element-plus": "^2.3.0",
"pinia": "^2.1.0",
"vue": "^3.3.0",
"vue-router": "^4.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",
"sass": "^1.63.0",
"vite": "^4.4.0"
}
}

+ 42
- 0
src/App.vue View File

@ -0,0 +1,42 @@
<template>
<auth-provider>
<router-view />
</auth-provider>
</template>
<script>
import AuthProvider from '@/components/auth/AuthProvider.vue';
export default {
name: 'App',
components: {
AuthProvider
}
};
</script>
<style lang="scss">
@import './assets/styles/global.scss';
html, body {
margin: 0;
padding: 0;
height: 100%;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
}
#app {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: #f5f5f5;
.main-content {
flex: 1;
padding: 20px;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
}
</style>

+ 39
- 0
src/api/index.js View File

@ -0,0 +1,39 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
// 请求拦截器
api.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => {
return Promise.reject(error);
}
);
// 响应拦截器
api.interceptors.response.use(
response => {
return response.data;
},
error => {
if (error.response && error.response.status === 401) {
// 处理未授权的情况
// 可以在这里触发退出登录或跳转到登录页
}
return Promise.reject(error);
}
);
export default api;

BIN
src/assets/images/.DS_Store View File


BIN
src/assets/images/1.png View File

Before After
Width: 355  |  Height: 354  |  Size: 130 KiB

BIN
src/assets/images/2.png View File

Before After
Width: 355  |  Height: 354  |  Size: 129 KiB

BIN
src/assets/images/3.png View File

Before After
Width: 355  |  Height: 354  |  Size: 130 KiB

BIN
src/assets/images/Group 2681.png View File

Before After
Width: 85  |  Height: 84  |  Size: 2.4 KiB

BIN
src/assets/images/Group 2878-1.png View File

Before After
Width: 78  |  Height: 78  |  Size: 1.5 KiB

BIN
src/assets/images/Group 2878-2.png View File

Before After
Width: 78  |  Height: 78  |  Size: 1.3 KiB

BIN
src/assets/images/Group 2878-3.png View File

Before After
Width: 78  |  Height: 78  |  Size: 1.3 KiB

BIN
src/assets/images/Group 2878.png View File

Before After
Width: 78  |  Height: 78  |  Size: 1.3 KiB

BIN
src/assets/images/home/announcement.png View File

Before After
Width: 85  |  Height: 84  |  Size: 8.9 KiB

BIN
src/assets/images/image-1.png View File

Before After
Width: 55  |  Height: 55  |  Size: 5.1 KiB

BIN
src/assets/images/image-2.png View File

Before After
Width: 55  |  Height: 55  |  Size: 4.8 KiB

BIN
src/assets/images/image-3.png View File

Before After
Width: 55  |  Height: 55  |  Size: 4.1 KiB

BIN
src/assets/images/image-4.png View File

Before After
Width: 55  |  Height: 55  |  Size: 4.4 KiB

BIN
src/assets/images/image-5.png View File

Before After
Width: 55  |  Height: 55  |  Size: 3.2 KiB

BIN
src/assets/images/image-6.png View File

Before After
Width: 55  |  Height: 55  |  Size: 3.6 KiB

BIN
src/assets/images/image-7.png View File

Before After
Width: 55  |  Height: 55  |  Size: 4.4 KiB

BIN
src/assets/images/image.png View File

Before After
Width: 55  |  Height: 55  |  Size: 4.9 KiB

BIN
src/assets/images/下.png View File

Before After
Width: 84  |  Height: 84  |  Size: 572 B

BIN
src/assets/images/书城-1.png View File

Before After
Width: 85  |  Height: 84  |  Size: 1.4 KiB

BIN
src/assets/images/书城-2.png View File

Before After
Width: 139  |  Height: 138  |  Size: 7.7 KiB

BIN
src/assets/images/书城.png View File

Before After
Width: 85  |  Height: 84  |  Size: 1.0 KiB

BIN
src/assets/images/书架.png View File

Before After
Width: 139  |  Height: 138  |  Size: 7.4 KiB

BIN
src/assets/images/任务中心.png View File

Before After
Width: 60  |  Height: 60  |  Size: 954 B

BIN
src/assets/images/修改信息.png View File

Before After
Width: 60  |  Height: 60  |  Size: 1.2 KiB

BIN
src/assets/images/加.png View File

Before After
Width: 96  |  Height: 96  |  Size: 536 B

BIN
src/assets/images/加载.png View File

Before After
Width: 73  |  Height: 72  |  Size: 982 B

BIN
src/assets/images/取消删除.png View File

Before After
Width: 61  |  Height: 60  |  Size: 1.2 KiB

BIN
src/assets/images/右.png View File

Before After
Width: 48  |  Height: 48  |  Size: 385 B

BIN
src/assets/images/失败.png View File

Before After
Width: 72  |  Height: 72  |  Size: 1.0 KiB

BIN
src/assets/images/布丁小说logo.png View File

Before After
Width: 373  |  Height: 372  |  Size: 50 KiB

BIN
src/assets/images/成功.png View File

Before After
Width: 72  |  Height: 72  |  Size: 1.0 KiB

BIN
src/assets/images/我的.png View File

Before After
Width: 139  |  Height: 138  |  Size: 8.5 KiB

BIN
src/assets/images/我的等级.png View File

Before After
Width: 48  |  Height: 48  |  Size: 4.6 KiB

BIN
src/assets/images/我的评论.png View File

Before After
Width: 60  |  Height: 60  |  Size: 604 B

BIN
src/assets/images/投推荐票.png View File

Before After
Width: 48  |  Height: 48  |  Size: 663 B

BIN
src/assets/images/推荐票.png View File

Before After
Width: 129  |  Height: 129  |  Size: 18 KiB

BIN
src/assets/images/申请成为创作者.png View File

Before After
Width: 60  |  Height: 60  |  Size: 1.4 KiB

BIN
src/assets/images/礼物盒.png View File

Before After
Width: 60  |  Height: 60  |  Size: 679 B

BIN
src/assets/images/移除书架.png View File

Before After
Width: 61  |  Height: 60  |  Size: 1.1 KiB

BIN
src/assets/images/移除全部.png View File

Before After
Width: 61  |  Height: 60  |  Size: 648 B

BIN
src/assets/images/联系客服.png View File

Before After
Width: 60  |  Height: 60  |  Size: 1.3 KiB

BIN
src/assets/images/读者榜单.png View File

Before After
Width: 366  |  Height: 115  |  Size: 59 KiB

BIN
src/assets/images/退出登录.png View File

Before After
Width: 60  |  Height: 60  |  Size: 672 B

BIN
src/assets/images/钱包流水.png View File

Before After
Width: 60  |  Height: 60  |  Size: 905 B

BIN
src/assets/images/首页.png View File

Before After
Width: 139  |  Height: 138  |  Size: 7.3 KiB

BIN
src/assets/images/默认头像.png View File

Before After
Width: 132  |  Height: 132  |  Size: 8.3 KiB

+ 109
- 0
src/assets/styles/global.scss View File

@ -0,0 +1,109 @@
@use './variables.scss' as vars;
html {
font-size: 16px;
@media screen and (max-width: vars.$lg) {
font-size: 15px;
}
@media screen and (max-width: vars.$md) {
font-size: 14px;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
font-family: 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: vars.$text-regular;
}
a {
text-decoration: none;
color: vars.$primary-color;
}
.page-container {
padding: vars.$content-padding;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.text-ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.clearfix:after {
content: '';
display: table;
clear: both;
}
// 全局按钮样式
.el-button--primary {
background-color: vars.$primary-color;
border-color: vars.$primary-color;
&:hover,
&:focus {
background-color: lighten(vars.$primary-color, 10%);
border-color: lighten(vars.$primary-color, 10%);
}
}
.el-button--accent {
background-color: vars.$accent-color;
border-color: vars.$accent-color;
color: #fff;
&:hover,
&:focus {
background-color: lighten(vars.$accent-color, 10%);
border-color: lighten(vars.$accent-color, 10%);
}
}
// 常用边距
.mb-10 {
margin-bottom: 0.625rem;
}
.mb-20 {
margin-bottom: 1.25rem;
}
.mt-10 {
margin-top: 0.625rem;
}
.mt-20 {
margin-top: 1.25rem;
}
.ml-10 {
margin-left: 0.625rem;
}
.mr-10 {
margin-right: 0.625rem;
}
// 常用宽度
.w-100 {
width: 100%;
}

+ 34
- 0
src/assets/styles/variables.scss View File

@ -0,0 +1,34 @@
// 颜色变量
$primary-color: #0A2463;
$success-color: #67C23A;
$warning-color: #E6A23C;
$danger-color: #F56C6C;
$info-color: #909399;
$accent-color: #ef5350;
// 文字颜色
$text-primary: #303133;
$text-regular: #606266;
$text-secondary: #909399;
$text-placeholder: #C0C4CC;
// 边框颜色
$border-color-base: #DCDFE6;
$border-color-light: #E4E7ED;
$border-color-lighter: #EBEEF5;
$border-color-extra-light: #F2F6FC;
// 背景颜色
$background-color-base: #F5F7FA;
// 布局相关
$header-height: 3.75rem; // 60px
$sidebar-width: 15rem; // 240px
$content-padding: 1.25rem; // 20px
// 响应式断点
$xs: 576px;
$sm: 768px;
$md: 992px;
$lg: 1200px;
$xl: 1920px;

+ 75
- 0
src/components/auth/AuthProvider.vue View File

@ -0,0 +1,75 @@
<template>
<div>
<slot></slot>
<login-register-modal
v-model:visible="showAuthModal"
:default-type="modalType"
@login-success="handleLoginSuccess"
@register-success="handleRegisterSuccess"
/>
</div>
</template>
<script>
import { ref, provide } from 'vue';
import LoginRegisterModal from './LoginRegisterModal.vue';
//
export const AUTH_INJECTION_KEY = Symbol('auth-context');
export default {
name: 'AuthProvider',
components: {
LoginRegisterModal
},
setup() {
const showAuthModal = ref(false);
const modalType = ref('login');
const loginSuccessCallback = ref(null);
const registerSuccessCallback = ref(null);
//
const openLogin = (callback) => {
modalType.value = 'login';
showAuthModal.value = true;
loginSuccessCallback.value = callback;
};
//
const openRegister = (callback) => {
modalType.value = 'register';
showAuthModal.value = true;
registerSuccessCallback.value = callback;
};
//
const handleLoginSuccess = () => {
if (typeof loginSuccessCallback.value === 'function') {
loginSuccessCallback.value();
loginSuccessCallback.value = null;
}
};
//
const handleRegisterSuccess = () => {
if (typeof registerSuccessCallback.value === 'function') {
registerSuccessCallback.value();
registerSuccessCallback.value = null;
}
};
//
provide(AUTH_INJECTION_KEY, {
openLogin,
openRegister
});
return {
showAuthModal,
modalType,
handleLoginSuccess,
handleRegisterSuccess
};
}
};
</script>

+ 324
- 0
src/components/auth/LoginRegisterModal.vue View File

@ -0,0 +1,324 @@
<template>
<el-dialog :model-value="visible" @update:model-value="$emit('update:visible', $event)"
:title="isLogin ? '手机号登录' : '手机号注册'" width="500px" :show-close="true" :close-on-click-modal="false" center
class="login-register-modal">
<div class="auth-form">
<div class="phone-input">
<el-select v-model="countryCode" class="country-code">
<el-option label="+86" value="+86" />
<el-option label="+852" value="+852" />
<el-option label="+853" value="+853" />
<el-option label="+886" value="+886" />
</el-select>
<el-input v-model="phone" placeholder="手机号" />
</div>
<div class="verification-input">
<el-input v-model="verificationCode" placeholder="验证码" />
<el-button type="primary" :disabled="countdownActive" @click="sendVerificationCode">
{{ countdownActive ? `${countdown}秒后重发` : '发送验证码' }}
</el-button>
</div>
<el-button type="primary" class="submit-button" @click="handleSubmit">
{{ isLogin ? '登录' : '注册' }}
</el-button>
<div class="form-options">
<span class="switch-auth" @click="switchAuthType">
{{ isLogin ? '手机号注册' : '已有账号登录' }}
</span>
</div>
<div class="agreement">
<el-checkbox v-model="agreeTerms"></el-checkbox>
<span class="agreement-text">
登录即表示您同意 <a href="javascript:void(0)">用户协议</a> <a href="javascript:void(0)">隐私政策</a>
</span>
</div>
</div>
</el-dialog>
</template>
<script>
import { ref, computed } from 'vue';
import { ElMessage } from 'element-plus';
import { useMainStore } from '@/store';
export default {
name: 'LoginRegisterModal',
props: {
visible: {
type: Boolean,
required: true
},
defaultType: {
type: String,
default: 'login',
validator: (val) => ['login', 'register'].includes(val)
}
},
emits: ['update:visible', 'login-success', 'register-success'],
setup(props, { emit }) {
const store = useMainStore();
const authType = ref(props.defaultType);
const isLogin = computed(() => authType.value === 'login');
const countryCode = ref('+86');
const phone = ref('');
const verificationCode = ref('');
const agreeTerms = ref(false);
const countdown = ref(60);
const countdownActive = ref(false);
const validateForm = () => {
if (!phone.value) {
ElMessage.error('请输入手机号');
return false;
}
if (!verificationCode.value) {
ElMessage.error('请输入验证码');
return false;
}
if (!agreeTerms.value) {
ElMessage.error('请同意用户协议和隐私政策');
return false;
}
return true;
};
const sendVerificationCode = () => {
if (!phone.value) {
ElMessage.error('请输入手机号');
return;
}
// API
ElMessage.success(`验证码已发送至 ${countryCode.value}${phone.value}`);
//
countdownActive.value = true;
countdown.value = 60;
const timer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
clearInterval(timer);
countdownActive.value = false;
}
}, 1000);
};
const handleSubmit = async () => {
if (!validateForm()) return;
try {
// APIAPI
if (isLogin.value) {
//
await store.login({
phone: phone.value,
code: verificationCode.value
});
emit('login-success');
ElMessage.success('登录成功');
} else {
//
await store.register({
phone: phone.value,
code: verificationCode.value
});
emit('register-success');
ElMessage.success('注册成功');
}
//
emit('update:visible', false);
//
resetForm();
} catch (error) {
ElMessage.error(error.message || '操作失败,请稍后重试');
}
};
const switchAuthType = () => {
authType.value = isLogin.value ? 'register' : 'login';
};
const resetForm = () => {
phone.value = '';
verificationCode.value = '';
agreeTerms.value = false;
countdownActive.value = false;
countdown.value = 60;
};
return {
isLogin,
countryCode,
phone,
verificationCode,
agreeTerms,
countdown,
countdownActive,
sendVerificationCode,
handleSubmit,
switchAuthType
};
}
};
</script>
<style lang="scss" scoped>
@use '@/assets/styles/variables.scss' as vars;
.login-register-modal {
//
:deep(.el-input__wrapper),
:deep(.el-input__inner),
:deep(.el-select .el-input__wrapper),
:deep(.el-button) {
height: 44px !important;
line-height: 44px !important;
box-sizing: border-box;
}
:deep(.el-input__wrapper) {
padding: 0 15px;
}
:deep(.el-input__inner) {
font-size: 15px;
}
//
:deep(.el-select),
:deep(.el-input) {
--el-select-input-focus-border-color: var(--el-color-primary);
}
// select使
:deep(.el-select) {
width: 100%;
.el-input {
width: 100%;
}
}
//
:deep(.el-button) {
border: none;
}
:deep(.el-dialog) {
border-radius: 8px;
overflow: hidden;
}
:deep(.el-dialog__header) {
padding: 24px 30px;
text-align: center;
border-bottom: 1px solid #f0f0f0;
.el-dialog__title {
font-size: 22px;
font-weight: bold;
color: #333;
}
}
:deep(.el-dialog__body) {
padding: 40px 50px;
}
.auth-form {
padding: 30px 30px 50px 30px;
.phone-input {
display: flex;
margin-bottom: 24px;
.country-code {
width: 100px;
margin-right: 12px;
flex-shrink: 0;
}
.el-input {
flex: 1;
}
}
.verification-input {
display: flex;
margin-bottom: 30px;
.el-input {
flex: 1;
}
.el-button {
margin-left: 12px;
width: 120px;
background-color: vars.$primary-color;
font-size: 15px;
padding: 0 15px;
color: white;
flex-shrink: 0;
}
}
.submit-button {
width: 100%;
height: 48px !important;
line-height: 48px !important;
background-color: vars.$primary-color;
margin-bottom: 20px;
font-size: 16px;
letter-spacing: 1px;
}
.form-options {
display: flex;
justify-content: center;
margin-bottom: 25px;
.switch-auth {
color: vars.$primary-color;
cursor: pointer;
font-size: 15px;
&:hover {
opacity: 0.8;
text-decoration: underline;
}
}
}
.agreement {
display: flex;
align-items: center;
font-size: 13px;
color: #666;
gap: 10px;
a {
color: vars.$primary-color;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
}
</style>

+ 139
- 0
src/components/common/BookCard.vue View File

@ -0,0 +1,139 @@
<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">
</div>
<div class="book-info">
<h3 class="book-title">{{ book.title }}</h3>
<p class="book-author">作者{{ book.author }}</p>
<p class="book-intro" v-if="book.description">{{ book.description }}</p>
<div class="book-status" v-if="book.status">
<span class="status-tag">{{ book.status }}</span>
<span class="reading-count" v-if="book.readCount">{{ book.readCount }}人在读</span>
</div>
</div>
</div>
</template>
<script>
import { useRouter } from 'vue-router';
export default {
name: 'BookCard',
props: {
book: {
type: Object,
required: true,
default: () => ({
id: '',
title: '',
author: '',
description: '',
cover: '',
status: '',
readCount: 0
})
}
},
setup(props) {
const router = useRouter();
const goToDetail = () => {
router.push({
path: `/book/${props.book.id}`
});
};
return {
goToDetail
};
}
};
</script>
<style lang="scss" scoped>
@use '@/assets/styles/variables.scss' as vars;
.book-card {
display: flex;
border-radius: 4px;
overflow: hidden;
transition: all 0.3s;
cursor: pointer;
background-color: #fff;
max-width: 400px;
margin-bottom: 15px;
.book-cover {
width: 100px;
min-width: 100px;
height: 140px;
overflow: hidden;
position: relative;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.book-info {
flex: 1;
padding: 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
.book-title {
margin: 0 0 6px;
font-size: 16px;
font-weight: bold;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.book-author {
font-size: 14px;
color: #666;
margin: 0 0 8px;
}
.book-intro {
font-size: 12px;
color: #999;
line-height: 1.5;
margin: 0 0 auto;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.book-status {
margin-top: 8px;
display: flex;
justify-content: space-between;
align-items: center;
.status-tag {
background-color: #e6f7ff;
color: vars.$primary-color;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
border: 1px solid #91d5ff;
}
.reading-count {
font-size: 12px;
color: #999;
}
}
}
}
</style>

+ 105
- 0
src/layout/components/BackToTop.vue View File

@ -0,0 +1,105 @@
<template>
<transition name="fade">
<div
class="back-to-top"
v-show="visible"
@click="backToTop"
>
<el-icon><Top /></el-icon>
</div>
</transition>
</template>
<script>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { Top } from '@element-plus/icons-vue';
export default {
name: 'BackToTop',
components: { Top },
props: {
visibilityHeight: {
type: Number,
default: 400
},
backPosition: {
type: Number,
default: 0
},
customStyle: {
type: Object,
default: () => ({})
},
transitionName: {
type: String,
default: 'fade'
}
},
setup(props) {
const visible = ref(false);
const scrollHandler = () => {
visible.value = window.pageYOffset > props.visibilityHeight;
};
const backToTop = () => {
window.scrollTo({
top: props.backPosition,
behavior: 'smooth'
});
};
onMounted(() => {
window.addEventListener('scroll', scrollHandler);
});
onBeforeUnmount(() => {
window.removeEventListener('scroll', scrollHandler);
});
return {
visible,
backToTop
};
}
};
</script>
<style lang="scss" scoped>
@use '@/assets/styles/variables.scss' as vars;
@use 'sass:color';
.back-to-top {
position: fixed;
right: 40px;
bottom: 40px;
width: 40px;
height: 40px;
background-color: vars.$primary-color;
border-radius: 50%;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
cursor: pointer;
z-index: 999;
box-shadow: 0 0 6px rgba(0, 0, 0, 0.12);
transition: all 0.3s;
&:hover {
background-color: color.adjust(#409EFF, $lightness: 10%);
}
}
//
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

+ 91
- 0
src/layout/components/Breadcrumb.vue View File

@ -0,0 +1,91 @@
<template>
<div class="breadcrumb-container" v-if="breadcrumbs.length > 1">
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="index" :to="item.path">
{{ item.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
</template>
<script>
import { ref, watch, computed } from 'vue';
import { useRoute } from 'vue-router';
export default {
name: 'Breadcrumb',
setup() {
const route = useRoute();
const breadcrumbs = ref([]);
//
const routeMap = {
'/': '首页',
'/category': '分类',
'/ranking': '排行榜',
'/bookshelf': '书架',
'/user/center': '个人中心',
'/book': '书籍详情'
};
//
const getBreadcrumbs = (path) => {
const result = [];
//
result.push({
path: '/',
title: '首页'
});
if (path === '/') return result;
const paths = path.split('/').filter(Boolean);
let currentPath = '';
paths.forEach((segment, index) => {
currentPath += '/' + segment;
//
if (segment.match(/^\d+$/)) {
// 使
if (index > 0) {
const basePath = '/' + paths[index - 1];
result.push({
path: currentPath,
title: routeMap[basePath] || segment
});
}
} else {
const exactPath = Object.keys(routeMap).find(key => key === currentPath);
const basePath = Object.keys(routeMap).find(key => currentPath.startsWith(key) && key !== '/');
result.push({
path: currentPath,
title: routeMap[exactPath] || routeMap[basePath] || segment
});
}
});
return result;
};
watch(() => route.path, (path) => {
breadcrumbs.value = getBreadcrumbs(path);
}, { immediate: true });
return {
breadcrumbs
};
}
};
</script>
<style lang="scss" scoped>
@use '@/assets/styles/variables.scss' as vars;
.breadcrumb-container {
margin-bottom: 20px;
padding: 8px 0;
}
</style>

+ 69
- 0
src/layout/index.vue View File

@ -0,0 +1,69 @@
<template>
<div class="layout-container">
<!-- 头部导航 -->
<app-header />
<!-- 主内容区 -->
<main class="layout-main">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<keep-alive>
<component :is="Component" />
</keep-alive>
</transition>
</router-view>
</main>
<!-- 页脚 -->
<app-footer />
<!-- 回到顶部 -->
<back-to-top />
</div>
</template>
<script>
import { defineComponent } from 'vue';
import AppHeader from './layout/Header.vue';
import AppFooter from './layout/Footer.vue';
import Breadcrumb from './components/Breadcrumb.vue';
import BackToTop from './components/BackToTop.vue';
export default defineComponent({
name: 'Layout',
components: {
AppHeader,
AppFooter,
Breadcrumb,
BackToTop
}
});
</script>
<style lang="scss" scoped>
@use '@/assets/styles/variables.scss' as vars;
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.layout-main {
flex: 1;
max-width: 1240px;
margin: 0 auto;
width: 100%;
}
//
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

+ 143
- 0
src/layout/layout/Footer.vue View File

@ -0,0 +1,143 @@
<template>
<footer class="app-footer">
<div class="footer-content">
<div class="footer-top">
<div class="footer-logo">
<el-icon class="logo-icon" :size="20"><Reading /></el-icon>
<span>瀚海中文网</span>
</div>
<!-- <div class="footer-links">
<router-link to="/about">关于我们</router-link>
<router-link to="/contact">联系我们</router-link>
<router-link to="/privacy">隐私政策</router-link>
<router-link to="/terms">使用条款</router-link>
<router-link to="/help">帮助中心</router-link>
</div> -->
</div>
<div class="footer-bottom">
<div class="copyright">
© {{ currentYear }} 瀚海中文网 版权所有
</div>
<div class="icp">
<a href="https://beian.miit.gov.cn/" target="_blank">ICP备案号: 浙ICP备xxxxxxxx号</a>
</div>
</div>
</div>
</footer>
</template>
<script>
import { ref, computed, onMounted } from 'vue';
import { Reading } from '@element-plus/icons-vue';
export default {
name: 'AppFooter',
components: {
Reading
},
setup() {
const currentYear = computed(() => new Date().getFullYear());
return {
currentYear
};
}
};
</script>
<style lang="scss" scoped>
@use '@/assets/styles/variables.scss' as vars;
.app-footer {
padding: 1.875rem 0;
background-color: #0A2463;
border-top: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
.footer-content {
max-width: 75rem;
margin: 0 auto;
padding: 0 1.25rem;
}
.footer-top {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
padding-bottom: 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
.footer-logo {
display: flex;
align-items: center;
font-size: 1.125rem;
font-weight: bold;
.logo-icon {
margin-right: 0.5rem;
}
}
}
.footer-links {
display: flex;
flex-wrap: wrap;
a {
margin: 0 0.9375rem;
color: rgba(255, 255, 255, 0.8);
font-size: 0.875rem;
&:hover {
color: #fff;
}
}
}
.footer-bottom {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
margin-top: 1.25rem;
}
.copyright {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.6);
}
.icp {
font-size: 0.75rem;
a {
color: rgba(255, 255, 255, 0.6);
&:hover {
color: #fff;
}
}
}
}
@media (max-width: vars.$md) {
.app-footer {
.footer-top, .footer-bottom {
flex-direction: column;
text-align: center;
.footer-logo, .footer-links {
margin-bottom: 0.9375rem;
justify-content: center;
}
}
.footer-bottom {
.copyright, .icp {
margin-bottom: 0.3125rem;
}
}
}
}
</style>

+ 318
- 0
src/layout/layout/Header.vue View File

@ -0,0 +1,318 @@
<template>
<div class="app-header">
<div class="header-content">
<div class="logo">
<router-link to="/">
<el-icon class="logo-icon" :size="24"><Reading /></el-icon>
<span>瀚海中文网</span>
</router-link>
</div>
<div class="menu">
<el-menu
mode="horizontal"
:ellipsis="false"
background-color="#0A2463"
text-color="#fff"
active-text-color="#fff"
:router="true"
: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 index="/ranking">排行榜</el-menu-item>
<el-menu-item index="/category">其他</el-menu-item>
<el-menu-item index="/bookshelf">书架</el-menu-item>
<el-menu-item index="/author">作家专区</el-menu-item>
</el-menu>
</div>
<div class="search-box">
<el-input
placeholder="请输入关键词搜索"
v-model="searchKeyword"
class="search-input"
>
<template #append>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</template>
</el-input>
</div>
<div class="user-info">
<div class="notification">
<el-badge :value="3" class="notification-badge">
<el-icon><Bell /></el-icon>
</el-badge>
</div>
<el-button v-if="!isLoggedIn" type="primary" @click="goToLogin" plain>登录/注册</el-button>
<el-dropdown v-else>
<div class="user-dropdown-link">
<el-avatar :size="32" :src="userAvatar" />
<span class="username">{{ userName }}</span>
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="goToUserCenter">个人中心</el-dropdown-item>
<el-dropdown-item @click="goToBookshelf">我的书架</el-dropdown-item>
<el-dropdown-item @click="goToSettings">账号设置</el-dropdown-item>
<el-dropdown-item divided @click="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, inject } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useMainStore } from '@/store';
import { ArrowDown, Bell, Reading } from '@element-plus/icons-vue';
import { AUTH_INJECTION_KEY } from '@/components/auth/AuthProvider.vue';
export default {
name: 'AppHeader',
components: {
ArrowDown,
Bell,
Reading
},
setup() {
const router = useRouter();
const route = useRoute();
const store = useMainStore();
const searchKeyword = ref('');
//
const authContext = inject(AUTH_INJECTION_KEY);
//
const isLoggedIn = computed(() => store.isAuthenticated);
const userName = computed(() => store.user?.name || '用户');
const userAvatar = computed(() => store.user?.avatar || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png');
//
const activeMenu = computed(() => {
//
if (route.path.includes('/book/') || route.path.includes('/user/')) {
return '/' + route.path.split('/')[1];
}
return route.path;
});
//
const handleSearch = () => {
if (searchKeyword.value.trim()) {
router.push({
path: '/search',
query: {
q: searchKeyword.value.trim()
}
});
}
};
const goToLogin = () => {
//
authContext.openLogin();
};
const goToUserCenter = () => {
router.push('/user/center');
};
const goToBookshelf = () => {
//
if (!isLoggedIn.value) {
authContext.openLogin(() => {
router.push('/bookshelf');
});
return;
}
router.push('/bookshelf');
};
const goToSettings = () => {
router.push('/user/settings');
};
const logout = () => {
store.logout();
router.push('/');
};
return {
isLoggedIn,
userName,
userAvatar,
activeMenu,
searchKeyword,
handleSearch,
goToLogin,
goToUserCenter,
goToBookshelf,
goToSettings,
logout
};
}
};
</script>
<style lang="scss" scoped>
@use '@/assets/styles/variables.scss' as vars;
@use 'sass:color';
.app-header {
background-color: #0A2463;
color: #fff;
height: 3.75rem; // 60px -> 3.75rem
box-shadow: 0 0.125rem 0.625rem rgba(0,0,0,0.3); // 2px 10px -> 0.125rem 0.625rem
.header-content {
display: flex;
align-items: center;
height: 100%;
width: 1400px;
margin: 0 auto;
padding: 0 1.25rem; // 20px -> 1.25rem
}
.logo {
font-size: 1.2rem; // 22px -> 1.375rem
font-weight: bold;
margin-right: 1.25rem; // 20px -> 1.25rem
a {
color: #fff;
text-decoration: none;
display: flex;
align-items: center;
.logo-icon {
margin-right: 0.5rem; // 8px -> 0.5rem
}
}
}
.menu {
flex: 1;
.el-menu {
border-bottom: none;
background-color: transparent;
.el-menu-item {
font-size: 1rem; // 16px -> 1rem
&.is-active {
font-weight: bold;
background-color: rgba(255, 255, 255, 0.1);
}
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
}
}
.search-box {
width: 18.75rem; // 300px -> 18.75rem
margin: 0 1.25rem; // 20px -> 1.25rem
.search-input {
:deep(.el-input__inner) {
border-radius: 0.25rem 0 0 0.25rem; // 4px -> 0.25rem
}
:deep(.el-input-group__append) {
padding: 0;
.el-button {
border-radius: 0 0.25rem 0.25rem 0; // 4px -> 0.25rem
border: none;
background-color: #ef5350;
color: white;
&:hover {
background-color: color.adjust(#ef5350, $lightness: -10%);
}
}
}
}
}
.user-info {
display: flex;
align-items: center;
margin-left: 1.25rem; // 20px -> 1.25rem
.notification {
margin-right: 1.25rem; // 20px -> 1.25rem
font-size: 1.25rem; // 20px -> 1.25rem
cursor: pointer;
.notification-badge {
:deep(.el-badge__content) {
background-color: #ef5350;
}
}
}
.user-dropdown-link {
display: flex;
align-items: center;
cursor: pointer;
color: #fff;
.username {
margin: 0 0.3125rem; // 5px -> 0.3125rem
font-size: 0.875rem; // 14px -> 0.875rem
}
}
.el-button {
&.is-plain {
color: #fff;
border-color: rgba(255, 255, 255, 0.5);
background: transparent;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: #fff;
}
}
}
}
}
/* 响应式调整 */
@media screen and (max-width: vars.$md) {
.app-header {
.menu {
.el-menu-item {
padding: 0 0.625rem; // 10px -> 0.625rem
font-size: 0.875rem; // 14px -> 0.875rem
}
}
.search-box {
width: 12.5rem; // 200px -> 12.5rem
margin: 0 0.625rem; // 10px -> 0.625rem
}
}
}
@media screen and (max-width: vars.$sm) {
.app-header {
.logo {
font-size: 1.125rem; // 18px -> 1.125rem
}
}
}
</style>

+ 26
- 0
src/main.js View File

@ -0,0 +1,26 @@
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import { useMainStore } from './store';
// 导入全局样式
import './assets/styles/global.scss';
const app = createApp(App);
const pinia = createPinia();
app.use(ElementPlus);
app.use(pinia);
app.use(router);
// 初始化身份验证状态
const store = useMainStore();
store.initializeAuth();
// 全局挂载pinia,用于路由守卫
window.$pinia = pinia;
app.mount('#app');

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

@ -0,0 +1,53 @@
import { createRouter, createWebHistory } from 'vue-router';
import layout from '../layout/index.vue';
const routes = [
{
path: '/',
name: 'layout',
component: layout,
children: [
{
path: '',
name: 'Home',
component: () => import('../views/home/Home.vue')
},
{
path: 'book/:id',
name: 'BookDetail',
component: () => import('../views/book/index.vue')
},
{
path: 'book/:id/chapter/:chapterId',
name: 'ChapterDetail',
component: () => import('../views/book/chapter.vue')
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('../views/NotFound.vue')
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
// 全局路由守卫
router.beforeEach((to, from, next) => {
const store = window.$pinia?.state.value?.main;
const requiresAuth = to.matched.some(record => record.meta.requiresAuth);
if (requiresAuth && (!store || !store.isLoggedIn)) {
next({ path: '/login', query: { redirect: to.fullPath } });
} else {
next();
}
});
export default router;

+ 83
- 0
src/store/index.js View File

@ -0,0 +1,83 @@
import { defineStore } from 'pinia';
export const useMainStore = defineStore('main', {
state: () => ({
user: null,
isLoggedIn: false,
token: null
}),
getters: {
isAuthenticated: (state) => state.isLoggedIn && state.user !== null
},
actions: {
async login(loginData) {
try {
// 模拟API调用,实际项目中这里应该调用后端API
// 这里模拟一个异步操作
await new Promise(resolve => setTimeout(resolve, 1000));
// 模拟登录成功响应
const userData = {
id: 'user_' + Date.now(),
name: '用户' + loginData.phone.substring(loginData.phone.length - 4),
phone: loginData.phone,
avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'
};
this.user = userData;
this.isLoggedIn = true;
this.token = 'mock_token_' + Date.now();
// 保存到本地存储,使登录状态持久化
localStorage.setItem('user', JSON.stringify(userData));
localStorage.setItem('token', this.token);
return userData;
} catch (error) {
console.error('登录失败:', error);
throw new Error('登录失败,请稍后重试');
}
},
async register(registerData) {
try {
// 模拟API调用,实际项目中这里应该调用后端API
await new Promise(resolve => setTimeout(resolve, 1000));
// 模拟注册成功,自动登录
return this.login(registerData);
} catch (error) {
console.error('注册失败:', error);
throw new Error('注册失败,请稍后重试');
}
},
logout() {
this.user = null;
this.isLoggedIn = false;
this.token = null;
// 清除本地存储
localStorage.removeItem('user');
localStorage.removeItem('token');
},
// 初始化,从本地存储恢复登录状态
initializeAuth() {
const userData = localStorage.getItem('user');
const token = localStorage.getItem('token');
if (userData && token) {
try {
this.user = JSON.parse(userData);
this.token = token;
this.isLoggedIn = true;
} catch (e) {
this.logout();
}
}
}
}
});

+ 69
- 0
src/utils/request.js View File

@ -0,0 +1,69 @@
import api from '@/api';
/**
* 获取通用请求配置选项
* @param {object} options - 请求配置选项
* @returns {object} - 处理后的请求配置
*/
const getRequestOptions = (options = {}) => {
return {
...options
};
};
/**
* GET请求
* @param {string} url - 请求地址
* @param {object} params - 请求参数
* @param {object} options - 请求配置选项
* @returns {Promise} - 请求Promise对象
*/
export const get = (url, params = {}, options = {}) => {
return api.get(url, {
params,
...getRequestOptions(options)
});
};
/**
* POST请求
* @param {string} url - 请求地址
* @param {object} data - 请求数据
* @param {object} options - 请求配置选项
* @returns {Promise} - 请求Promise对象
*/
export const post = (url, data = {}, options = {}) => {
return api.post(url, data, getRequestOptions(options));
};
/**
* PUT请求
* @param {string} url - 请求地址
* @param {object} data - 请求数据
* @param {object} options - 请求配置选项
* @returns {Promise} - 请求Promise对象
*/
export const put = (url, data = {}, options = {}) => {
return api.put(url, data, getRequestOptions(options));
};
/**
* DELETE请求
* @param {string} url - 请求地址
* @param {object} data - 请求数据
* @param {object} options - 请求配置选项
* @returns {Promise} - 请求Promise对象
*/
export const del = (url, data = {}, options = {}) => {
return api.delete(url, {
data,
...getRequestOptions(options)
});
};
export default {
get,
post,
put,
del
};

+ 44
- 0
src/views/NotFound.vue View File

@ -0,0 +1,44 @@
<template>
<div class="not-found">
<div class="not-found-content">
<el-result icon="error" title="404" sub-title="抱歉您访问的页面不存在">
<template #extra>
<el-button type="primary" @click="goToHome">返回首页</el-button>
</template>
</el-result>
</div>
</div>
</template>
<script>
import { useRouter } from 'vue-router';
export default {
name: 'NotFound',
setup() {
const router = useRouter();
const goToHome = () => {
router.push('/');
};
return {
goToHome
};
}
};
</script>
<style lang="scss" scoped>
.not-found {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - 60px - 100px);
.not-found-content {
max-width: 500px;
width: 100%;
}
}
</style>

+ 352
- 0
src/views/book/chapter.vue View File

@ -0,0 +1,352 @@
<template>
<div class="chapter-container">
<div class="chapter-header">
<div class="header-content">
<div class="back" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
<span>返回目录</span>
</div>
<div class="title">{{ chapter.title }}</div>
<div class="book-title">{{ bookTitle }}</div>
</div>
</div>
<div class="chapter-content">
<h1 class="chapter-title">{{ chapter.title }}</h1>
<div class="content-navigation">
<el-button v-if="prevChapterId" @click="goToChapter(prevChapterId)" plain>上一章</el-button>
<el-button @click="goToBookDetail">目录</el-button>
<el-button v-if="nextChapterId" @click="goToChapter(nextChapterId)" type="primary">下一章</el-button>
</div>
<div class="chapter-text">
<p v-for="(paragraph, index) in chapter.content" :key="index" class="paragraph">
{{ paragraph }}
</p>
</div>
<div class="content-navigation bottom">
<el-button v-if="prevChapterId" @click="goToChapter(prevChapterId)" plain>上一章</el-button>
<el-button @click="goToBookDetail">目录</el-button>
<el-button v-if="nextChapterId" @click="goToChapter(nextChapterId)" type="primary">下一章</el-button>
</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>
</template>
<script>
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ArrowLeft, ZoomIn, ZoomOut, MoonNight } from '@element-plus/icons-vue';
export default {
name: 'ChapterDetail',
components: {
ArrowLeft,
ZoomIn,
ZoomOut,
MoonNight
},
setup() {
const route = useRoute();
const router = useRouter();
const bookId = route.params.id;
const chapterId = route.params.chapterId;
const bookTitle = ref('重生之财源滚滚');
const fontSize = ref(18);
const isDarkMode = ref(false);
//
const chapter = ref({
id: chapterId,
title: '第' + chapterId + '章 ' + (chapterId === '1' ? '重回2004' : '精彩内容'),
content: [
'贺季宁站在天台上,俯瞰着城市的点点灯光,感到一阵恍惚。',
'他曾经有多么意气风发,现在就有多么落魄。',
'三年前,他是A市土豪公司的金牌经理,工资高,福利好,女友漂亮,车位靓,最重要的是老板信任他。',
'但是贺季宁的对手们不服,老板娘不喜欢他,不怀好意的对手们联合一起,终于把他拉下台了。',
'他失去工作,更可悲的是,他连公司配他的贷款的房子都搭进去了,欠了几百万的债,女友也离他而去。',
'这三年,他一直试图东山再起,却处处碰壁,如今债主们逼得紧,他已经走投无路。',
'"如果老天再给我一次机会,我一定要好好做人,赚很多钱,让那帮人看着我过得好,他们就会比我难受!"',
'贺季宁苦笑着喃喃自语,闭上了眼睛。',
'他感觉自己似乎在坠落,又似乎在飘浮,时间不知过了多久...',
'当他再次睁开眼睛,发现自己躺在一张熟悉又陌生的床上。',
'这是他十几年前住过的出租屋!',
'贺季宁一个鲤鱼打挺坐了起来,环顾四周,破旧的家具,泛黄的墙壁,一切都像是回到了过去。',
'他猛地抓起床头的手机——诺基亚!',
'日期显示:2004年3月15日。',
'"我...重生了?"贺季宁不敢相信自己的眼睛,他竟然回到了2004年,回到了一切开始之前!',
'一切都可以重来!所有的错误都可以避免!他知道未来的风口,知道哪些投资会成功,知道哪些陷阱要避开。',
'贺季宁握紧了拳头,眼中闪烁着坚定的光芒。',
'"这一次,我一定要扭转命运!"'
]
});
// ID
const prevChapterId = computed(() => {
const current = parseInt(chapterId);
return current > 1 ? (current - 1).toString() : null;
});
const nextChapterId = computed(() => {
const current = parseInt(chapterId);
return current < 50 ? (current + 1).toString() : null;
});
//
const goToBookDetail = () => {
router.push(`/book/${bookId}`);
};
//
const goBack = () => {
router.back();
};
//
const goToChapter = (targetChapterId) => {
router.push(`/book/${bookId}/chapter/${targetChapterId}`);
};
//
const toggleFontSize = (change) => {
const newSize = fontSize.value + change;
if (newSize >= 14 && newSize <= 24) {
fontSize.value = newSize;
document.documentElement.style.setProperty('--reading-font-size', `${fontSize.value}px`);
}
};
//
const toggleTheme = () => {
isDarkMode.value = !isDarkMode.value;
if (isDarkMode.value) {
document.documentElement.classList.add('dark-theme');
} else {
document.documentElement.classList.remove('dark-theme');
}
};
onMounted(() => {
//
document.documentElement.style.setProperty('--reading-font-size', `${fontSize.value}px`);
//
window.scrollTo(0, 0);
// API
console.log(`加载章节内容,书籍ID: ${bookId},章节ID: ${chapterId}`);
});
return {
bookTitle,
chapter,
prevChapterId,
nextChapterId,
goToBookDetail,
goBack,
goToChapter,
toggleFontSize,
toggleTheme
};
}
};
</script>
<style lang="scss">
//
:root {
--reading-font-size: 18px;
--reading-background: #f8f8f8;
--reading-text-color: #333;
--reading-border-color: #eee;
}
.dark-theme {
--reading-background: #252525;
--reading-text-color: #ccc;
--reading-border-color: #333;
.chapter-container {
background-color: var(--reading-background) !important;
.chapter-header {
background-color: #202020 !important;
color: #ddd !important;
border-bottom-color: #333 !important;
}
.chapter-content {
background-color: var(--reading-background) !important;
color: var(--reading-text-color) !important;
.chapter-title {
color: #eee !important;
}
.paragraph {
color: var(--reading-text-color) !important;
}
}
.el-button {
background-color: #333 !important;
border-color: #555 !important;
color: #ddd !important;
&.is-plain {
background: transparent !important;
}
&.el-button--primary {
background-color: var(--el-color-primary) !important;
border-color: var(--el-color-primary) !important;
color: #fff !important;
}
}
}
}
</style>
<style lang="scss" scoped>
@use '@/assets/styles/variables.scss' as vars;
.chapter-container {
min-height: 100vh;
background-color: var(--reading-background);
display: flex;
flex-direction: column;
}
.chapter-header {
position: sticky;
top: 0;
z-index: 10;
background-color: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
border-bottom: 1px solid var(--reading-border-color);
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1000px;
margin: 0 auto;
padding: 15px 20px;
.back {
display: flex;
align-items: center;
cursor: pointer;
color: vars.$primary-color;
.el-icon {
margin-right: 5px;
}
}
.title {
font-weight: bold;
font-size: 16px;
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.book-title {
font-size: 14px;
color: #666;
}
}
}
.chapter-content {
flex: 1;
max-width: 800px;
margin: 0 auto;
padding: 30px 20px 50px;
background-color: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.05);
.chapter-title {
text-align: center;
font-size: 24px;
margin-bottom: 30px;
color: #333;
}
.content-navigation {
display: flex;
justify-content: center;
gap: 15px;
margin-bottom: 30px;
&.bottom {
margin-top: 50px;
margin-bottom: 0;
}
}
.chapter-text {
line-height: 1.8;
.paragraph {
margin-bottom: 20px;
font-size: var(--reading-font-size);
color: var(--reading-text-color);
text-indent: 2em;
}
}
}
.reading-settings {
position: fixed;
bottom: 30px;
right: 30px;
z-index: 10;
.settings-buttons {
display: flex;
flex-direction: column;
gap: 10px;
.el-button {
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
}
}
//
@media screen and (max-width: vars.$md) {
.chapter-content {
padding: 20px 15px 40px;
}
.chapter-header {
.header-content {
padding: 12px 15px;
.title {
max-width: 200px;
}
}
}
}
</style>

+ 576
- 0
src/views/book/index.vue View File

@ -0,0 +1,576 @@
<template>
<div class="book-detail-container">
<!-- 小说基本信息部分 -->
<div class="book-info-wrapper">
<div class="book-info">
<div class="book-cover">
<img :src="book.cover" :alt="book.title">
</div>
<div class="book-details">
<h1 class="book-title">{{ book.title }}</h1>
<div class="book-meta">
<div class="meta-item">
<span class="label">作者</span>
<span class="value">{{ book.author }}</span>
</div>
<div class="meta-item">
<span class="label">分类</span>
<span class="value">{{ book.category }}</span>
</div>
<div class="meta-item">
<span class="label">状态</span>
<span class="value" :class="book.status === '已完结' ? 'finished' : 'updating'">{{ book.status }}</span>
</div>
<div class="meta-item">
<span class="label">书友群</span>
<span class="value">{{ book.userGroup }}</span>
</div>
</div>
<div class="action-buttons">
<el-button type="primary" class="add-to-shelf">加入书架</el-button>
<el-button class="start-reading">立即阅读</el-button>
</div>
</div>
</div>
</div>
<!-- 内容导航 -->
<div class="book-navbar">
<div class="nav-container">
<ul class="nav-list">
<li class="nav-item active" @click="activeTab = 'intro'">简介</li>
<li class="nav-item" @click="activeTab = 'directory'">目录</li>
<li class="nav-item" @click="activeTab = 'comments'">书评</li>
</ul>
</div>
</div>
<!-- 内容部分 -->
<div class="book-content">
<!-- 简介 -->
<div v-if="activeTab === 'intro'" class="book-intro">
<div class="intro-text">
<div class="paragraph" v-html="book.description"></div>
</div>
</div>
<!-- 目录 -->
<div v-if="activeTab === 'directory'" class="book-directory">
<div class="chapter-list">
<div v-for="(chapter, index) in book.chapters" :key="index" class="chapter-item" @click="goToChapter(chapter.id)">
<div class="chapter-title">{{ chapter.title }}</div>
<div class="chapter-tag" v-if="chapter.isNew"></div>
</div>
</div>
<div class="pagination">
<el-pagination
background
layout="prev, pager, next"
:total="100"
:current-page="currentPage"
@current-change="handlePageChange"
/>
</div>
</div>
<!-- 书评 -->
<div v-if="activeTab === 'comments'" class="book-comments">
<div class="comment-list">
<div v-for="(comment, index) in book.comments" :key="index" class="comment-item">
<div class="comment-user">
<el-avatar :src="comment.avatar" :size="40"></el-avatar>
<div class="user-info">
<div class="username">{{ comment.username }}</div>
<div class="comment-time">{{ comment.time }}</div>
</div>
</div>
<div class="comment-content">{{ comment.content }}</div>
</div>
</div>
<!-- 评论输入框 -->
<div class="comment-form">
<h3 class="form-title">发表书评</h3>
<el-input
v-model="commentText"
type="textarea"
:rows="4"
placeholder="写下您的想法..."
/>
<div class="form-footer">
<el-button type="primary" @click="submitComment">发布</el-button>
</div>
</div>
</div>
</div>
<!-- 推荐书籍 -->
<div class="recommended-books">
<h2 class="section-title">猜你喜欢</h2>
<div class="book-list">
<div v-for="(book, index) in recommendedBooks" :key="index" class="book-item">
<book-card :book="book" />
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, reactive, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import BookCard from '@/components/common/BookCard.vue';
export default {
name: 'BookDetail',
components: {
BookCard
},
setup() {
const route = useRoute();
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 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 activeTab = ref('intro');
const currentPage = ref(1);
const commentText = ref('');
const handlePageChange = (page) => {
currentPage.value = page;
//
};
const goToChapter = (chapterId) => {
router.push(`/book/${bookId}/chapter/${chapterId}`);
};
const submitComment = () => {
if (!commentText.value.trim()) {
ElMessage.warning('请输入评论内容');
return;
}
// API
book.comments.unshift({
username: '当前用户',
avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
time: new Date().toLocaleString(),
content: commentText.value
});
commentText.value = '';
};
onMounted(() => {
// bookIdAPI
console.log('加载书籍详情,ID:', bookId);
});
return {
book,
recommendedBooks,
activeTab,
currentPage,
commentText,
handlePageChange,
goToChapter,
submitComment
};
}
};
</script>
<style lang="scss" scoped>
@use '@/assets/styles/variables.scss' as vars;
.book-detail-container {
width: 100%;
background-color: #f5f5f5;
}
//
.book-info-wrapper {
background-color: #fff;
padding: 30px 0;
border-bottom: 1px solid #eee;
.book-info {
display: flex;
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
.book-cover {
width: 150px;
height: 200px;
margin-right: 30px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.book-details {
flex: 1;
.book-title {
font-size: 24px;
font-weight: bold;
margin: 0 0 15px 0;
color: #333;
}
.book-meta {
margin-bottom: 20px;
.meta-item {
margin-bottom: 10px;
font-size: 14px;
color: #666;
.label {
display: inline-block;
width: 60px;
color: #999;
}
.value {
color: #333;
&.finished {
color: #52c41a;
}
&.updating {
color: #1890ff;
}
}
}
}
.action-buttons {
display: flex;
margin-top: 30px;
.el-button {
padding: 12px 20px;
font-size: 16px;
margin-right: 15px;
}
.add-to-shelf {
background-color: vars.$primary-color;
}
.start-reading {
border-color: vars.$primary-color;
color: vars.$primary-color;
}
}
}
}
}
//
.book-navbar {
background-color: #fff;
border-bottom: 1px solid #eee;
margin-top: 1px;
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
.nav-list {
display: flex;
list-style: none;
padding: 0;
margin: 0;
.nav-item {
padding: 15px 20px;
font-size: 16px;
cursor: pointer;
position: relative;
color: #666;
&.active {
color: vars.$primary-color;
font-weight: bold;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 30px;
height: 3px;
background-color: vars.$primary-color;
}
}
&:hover {
color: vars.$primary-color;
}
}
}
}
}
//
.book-content {
max-width: 1200px;
margin: 0 auto;
padding: 30px 20px;
background-color: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
//
.book-intro {
.intro-text {
line-height: 1.8;
color: #555;
font-size: 15px;
.paragraph {
margin-bottom: 15px;
}
}
}
//
.book-directory {
.chapter-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 30px;
@media screen and (max-width: vars.$md) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (max-width: vars.$sm) {
grid-template-columns: 1fr;
}
.chapter-item {
padding: 15px;
border: 1px solid #eee;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: all 0.3s;
&:hover {
background-color: #f9f9f9;
border-color: #ddd;
}
.chapter-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #333;
}
.chapter-tag {
background-color: #ff4d4f;
color: #fff;
font-size: 12px;
padding: 2px 6px;
border-radius: 2px;
}
}
}
.pagination {
display: flex;
justify-content: center;
}
}
//
.book-comments {
.comment-list {
margin-bottom: 40px;
.comment-item {
padding: 20px 0;
border-bottom: 1px solid #eee;
&:last-child {
border-bottom: none;
}
.comment-user {
display: flex;
align-items: center;
margin-bottom: 10px;
.user-info {
margin-left: 12px;
.username {
font-weight: bold;
color: #333;
}
.comment-time {
font-size: 12px;
color: #999;
margin-top: 2px;
}
}
}
.comment-content {
color: #555;
line-height: 1.6;
}
}
}
.comment-form {
.form-title {
font-size: 18px;
margin-bottom: 15px;
color: #333;
}
.form-footer {
margin-top: 15px;
display: flex;
justify-content: flex-end;
}
}
}
}
//
.recommended-books {
max-width: 1200px;
margin: 30px auto;
padding: 0 20px;
.section-title {
font-size: 20px;
font-weight: bold;
margin-bottom: 20px;
position: relative;
padding-left: 15px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 18px;
background-color: vars.$primary-color;
border-radius: 2px;
}
}
.book-list {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
@media screen and (max-width: vars.$lg) {
grid-template-columns: repeat(3, 1fr);
}
@media screen and (max-width: vars.$md) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (max-width: vars.$sm) {
grid-template-columns: 1fr;
}
}
}
</style>

+ 483
- 0
src/views/home/Home.vue View File

@ -0,0 +1,483 @@
<template>
<div class="home-container">
<!-- 轮播图和公告部分 -->
<div class="banner-announce-wrapper">
<!-- 轮播图 -->
<div class="home-banner">
<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-content">
<h2>{{ banner.title }}</h2>
<p>{{ banner.desc }}</p>
</div>
</div>
</el-carousel-item>
</el-carousel>
</div>
<!-- 公告栏 -->
<div class="announcement-section">
<div class="announcement-header">
<div class="announcement-icon">
<img src="@/assets/images/home/announcement.png" alt="公告" class="announce-img" />
<span>公告</span>
<el-icon class="arrow-icon"><ArrowRight /></el-icon>
</div>
</div>
<div class="announcement-list">
<div v-for="(notice, index) in announcements" :key="index" class="announcement-item">
<div class="announcement-tag gonggao">{{ notice.typeText }}</div>
<div class="announcement-content">
<el-link class="announcement-title" @click="viewAnnouncement(notice.id)">{{ notice.title }}</el-link>
<el-icon class="right-icon"><ArrowRight /></el-icon>
</div>
</div>
</div>
</div>
</div>
<!-- 推荐书籍 -->
<div class="recommend-section">
<div class="section-header">
<h2 class="section-title">热门推荐</h2>
<el-link type="primary">查看更多</el-link>
</div>
<div class="book-list">
<div v-for="(book, index) in recommendBooks" :key="index" class="book-item">
<book-card :book="book" />
</div>
</div>
</div>
<!-- 最新更新 -->
<div class="latest-section">
<div class="section-header">
<h2 class="section-title">最新更新</h2>
<el-link type="primary">查看更多</el-link>
</div>
<div class="book-list">
<div v-for="(book, index) in latestBooks" :key="index" class="book-item">
<book-card :book="book" />
</div>
</div>
</div>
</div>
</template>
<script>
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';
export default {
name: 'HomeView',
components: {
BookCard,
ArrowRight
},
setup() {
const router = useRouter();
const activeCategory = ref('1');
//
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: ['推理', '日本文学']
}
]);
const goToDetail = (id) => {
router.push(`/book/${id}`);
};
const viewAnnouncement = (id) => {
router.push(`/announcement/${id}`);
};
onMounted(() => {
});
return {
banners,
announcements,
recommendBooks,
latestBooks,
activeCategory,
goToDetail,
viewAnnouncement
};
}
};
</script>
<style lang="scss" scoped>
@use '@/assets/styles/variables.scss' as vars;
.home-container {
width: 100%;
background-color: #fff;
}
//
.banner-announce-wrapper {
padding: 20px;
border-top: 20px solid #eee;
display: flex;
gap: 20px;
@media screen and (max-width: vars.$md) {
flex-direction: column;
}
}
.home-banner {
flex: 1;
min-width: 0;
border-radius: 8px;
overflow: hidden;
:deep(.el-carousel__container) {
border-radius: 8px;
overflow: hidden;
}
.banner-item {
height: 100%;
background-size: cover;
background-position: center;
display: flex;
align-items: center;
color: #fff;
position: relative;
border-radius: 8px;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
border-radius: 8px;
}
.banner-content {
position: absolute;
z-index: 1;
padding: 0 50px;
bottom: 0;
max-width: 600px;
h2 {
font-size: 28px;
margin-bottom: 10px;
}
p {
font-size: 16px;
margin-bottom: 20px;
opacity: 0.9;
}
}
}
}
//
.announcement-section {
width: 400px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
overflow: hidden;
display: flex;
flex-direction: column;
padding: 15px;
height: 280px;
@media screen and (max-width: vars.$md) {
width: 100%;
}
.announcement-header {
display: flex;
justify-content: flex-start;
align-items: center;
margin-bottom: 15px;
.announcement-icon {
display: flex;
align-items: center;
.announce-img {
width: 20px;
height: 20px;
margin-right: 8px;
}
span {
font-size: 16px;
font-weight: bold;
color: #303133;
}
.arrow-icon {
margin-left: 5px;
font-size: 14px;
color: #909399;
}
}
}
.announcement-list {
flex: 1;
overflow-y: auto;
.announcement-item {
display: flex;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.announcement-tag {
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
margin-right: 10px;
display: inline-block;
text-align: center;
min-width: 50px;
border: 1px solid;
&.tongzhi {
color: #409EFF;
border-color: #409EFF;
background-color: rgba(64, 158, 255, 0.1);
}
&.gonggao {
color: #67C23A;
border-color: #67C23A;
background-color: rgba(103, 194, 58, 0.1);
}
}
.announcement-content {
flex: 1;
display: flex;
text-align: left;
.announcement-title {
flex: 1;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #606266;
}
.right-icon {
font-size: 14px;
color: #C0C4CC;
margin-left: 5px;
}
}
}
}
}
.recommend-section,
.latest-section {
margin-bottom: 30px;
padding: 30px 20px;
border-top: 10px solid #eee;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.section-title {
font-size: 20px;
font-weight: bold;
position: relative;
padding-left: 15px;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 20px;
background-color: vars.$primary-color;
border-radius: 2px;
}
}
}
.book-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
@media screen and (max-width: vars.$lg) {
grid-template-columns: repeat(3, 1fr);
}
@media screen and (max-width: vars.$md) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (max-width: vars.$sm) {
grid-template-columns: 1fr;
}
}
.recommend-section{
.book-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
@media screen and (max-width: vars.$lg) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (max-width: vars.$md) {
grid-template-columns: repeat(1, 1fr);
}
@media screen and (max-width: vars.$sm) {
grid-template-columns: 1fr;
}
}
}
</style>

+ 16
- 0
vite.config.js View File

@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'path';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
server: {
port: 3000,
open: true
}
});

Loading…
Cancel
Save