- 添加了项目基础结构,包括路由、状态管理、API请求等 - 实现了用户登录、注册、退出功能 - 添加了首页、书籍详情、章节阅读等页面 - 实现了书籍搜索、书架管理、用户中心等功能 - 添加了全局样式和主题配置 - 实现了响应式布局,支持移动端和PC端 - 添加了图片资源,优化了页面加载速度master
@ -0,0 +1,20 @@ | |||
--- | |||
description: | |||
globs: | |||
alwaysApply: true | |||
--- | |||
## 技术栈使用 | |||
- Vue3 | |||
- element-plus | |||
- piana | |||
- Vue Router | |||
- scss | |||
## 编码习惯 | |||
写代码的之前先要看一下目录结构,查看有没有可以复用的组件,如果没用则需要编写代码按照模块拆分成组件使用,让代码可以复用 | |||
## 主色调 #0A2463 | |||
## 需要图片的时候去/src/assetsimages查看是否有合适的 | |||
@ -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. 退出登录 |
@ -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 |
@ -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 进行路由管理 | |||
## 组件复用 | |||
在编写代码前,请先查看项目目录结构,确认是否有可复用的组件。如果没有可复用的组件,请按照模块进行拆分,使代码可以被复用。 |
@ -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> |
@ -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" | |||
} | |||
} |
@ -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> |
@ -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; |
@ -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%; | |||
} |
@ -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; |
@ -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> |
@ -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 { | |||
// 模拟API调用,实际项目中这里应该调用后端API | |||
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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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'); |
@ -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; |
@ -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(); | |||
} | |||
} | |||
} | |||
} | |||
}); |
@ -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 | |||
}; |
@ -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> |
@ -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> |
@ -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(() => { | |||
// 实际应用中这里应该根据bookId从API获取书籍详情 | |||
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> |
@ -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> |
@ -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 | |||
} | |||
}); |