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