- 添加了项目基础结构,包括路由、状态管理、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 | |||||
| } | |||||
| }); | |||||