- 新增书架功能,包括书籍的添加、移除、清空及阅读进度管理 - 新增书籍详情页组件,包括简介、目录、书评、统计等模块 - 优化书籍卡片组件,统一样式并支持删除模式 - 新增创作者申请功能及相关弹窗组件 - 更新路由配置,支持书架及书籍详情页的路由跳转 - 更新全局样式,优化按钮及颜色变量master
| @ -0,0 +1,176 @@ | |||
| <template> | |||
| <el-dialog | |||
| :model-value="visible" | |||
| @update:model-value="$emit('update:visible', $event)" | |||
| title="申请成为创作者" | |||
| width="500px" | |||
| :show-close="true" | |||
| :close-on-click-modal="false" | |||
| center | |||
| class="author-application-modal" | |||
| > | |||
| <div class="application-form"> | |||
| <div class="form-item"> | |||
| <div class="required-label">笔名</div> | |||
| <el-input v-model="penName" placeholder="请输入..." /> | |||
| </div> | |||
| <div class="form-item"> | |||
| <div class="required-label">简介</div> | |||
| <el-input | |||
| v-model="introduction" | |||
| type="textarea" | |||
| :rows="4" | |||
| placeholder="请输入..." | |||
| /> | |||
| </div> | |||
| <el-button | |||
| type="primary" | |||
| class="submit-button" | |||
| :loading="loading" | |||
| :disabled="!isValid" | |||
| @click="handleSubmit" | |||
| > | |||
| 成为创作者 | |||
| </el-button> | |||
| </div> | |||
| </el-dialog> | |||
| </template> | |||
| <script> | |||
| import { ref, computed } from 'vue'; | |||
| import { ElMessage } from 'element-plus'; | |||
| export default { | |||
| name: 'AuthorApplicationModal', | |||
| props: { | |||
| visible: { | |||
| type: Boolean, | |||
| required: true | |||
| } | |||
| }, | |||
| emits: ['update:visible', 'application-success'], | |||
| setup(props, { emit }) { | |||
| // 表单数据 | |||
| const penName = ref(''); | |||
| const introduction = ref(''); | |||
| const loading = ref(false); | |||
| // 表单验证 | |||
| const isValid = computed(() => { | |||
| return penName.value.trim() !== '' && introduction.value.trim() !== ''; | |||
| }); | |||
| // 提交申请 | |||
| const handleSubmit = async () => { | |||
| if (!isValid.value) { | |||
| ElMessage.warning('请填写完整信息'); | |||
| return; | |||
| } | |||
| try { | |||
| loading.value = true; | |||
| // 模拟API调用,实际项目中应调用真实的后端API | |||
| await new Promise(resolve => setTimeout(resolve, 1000)); | |||
| // 发送申请成功 | |||
| ElMessage.success('申请已提交,我们将尽快审核'); | |||
| emit('application-success', { | |||
| penName: penName.value, | |||
| introduction: introduction.value | |||
| }); | |||
| // 关闭弹窗 | |||
| emit('update:visible', false); | |||
| // 重置表单 | |||
| resetForm(); | |||
| } catch (error) { | |||
| ElMessage.error('申请提交失败,请稍后重试'); | |||
| } finally { | |||
| loading.value = false; | |||
| } | |||
| }; | |||
| // 重置表单 | |||
| const resetForm = () => { | |||
| penName.value = ''; | |||
| introduction.value = ''; | |||
| }; | |||
| // 打开弹窗的方法,可供外部调用 | |||
| const openModal = () => { | |||
| emit('update:visible', true); | |||
| }; | |||
| return { | |||
| penName, | |||
| introduction, | |||
| loading, | |||
| isValid, | |||
| handleSubmit, | |||
| openModal | |||
| }; | |||
| } | |||
| }; | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| @use 'sass:color'; | |||
| .author-application-modal { | |||
| // 全局共享样式 | |||
| :deep(.el-input__wrapper), | |||
| :deep(.el-textarea__inner), | |||
| :deep(.el-button) { | |||
| box-sizing: border-box; | |||
| } | |||
| :deep(.el-input__wrapper) { | |||
| padding: 0 15px; | |||
| height: 44px !important; | |||
| line-height: 44px !important; | |||
| } | |||
| :deep(.el-input__inner), | |||
| :deep(.el-textarea__inner) { | |||
| font-size: 15px; | |||
| } | |||
| .application-form { | |||
| padding: 10px 0; | |||
| .form-item { | |||
| margin-bottom: 20px; | |||
| .required-label { | |||
| margin-bottom: 8px; | |||
| font-size: 14px; | |||
| font-weight: 500; | |||
| color: #333; | |||
| &::before { | |||
| content: '*'; | |||
| color: #f56c6c; | |||
| margin-right: 4px; | |||
| } | |||
| } | |||
| } | |||
| .submit-button { | |||
| width: 100%; | |||
| height: 44px; | |||
| margin-top: 10px; | |||
| background-color: #0A2463; | |||
| border-color: #0A2463; | |||
| &:hover { | |||
| background-color: color.adjust(#0A2463, $lightness: -10%); | |||
| border-color: color.adjust(#0A2463, $lightness: -10%); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| </style> | |||
| @ -0,0 +1,61 @@ | |||
| <template> | |||
| <div> | |||
| <slot></slot> | |||
| <author-application-modal | |||
| v-model:visible="showApplicationModal" | |||
| @application-success="handleApplicationSuccess" | |||
| /> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { ref, provide } from 'vue'; | |||
| import AuthorApplicationModal from './AuthorApplicationModal.vue'; | |||
| // 定义提供的上下文键名 | |||
| export const AUTHOR_APPLICATION_INJECTION_KEY = Symbol('author-application-context'); | |||
| export default { | |||
| name: 'AuthorApplicationProvider', | |||
| components: { | |||
| AuthorApplicationModal | |||
| }, | |||
| setup() { | |||
| const showApplicationModal = ref(false); | |||
| const applicationSuccessCallback = ref(null); | |||
| // 打开申请成为创作者弹窗 | |||
| const openApplicationModal = (callback) => { | |||
| showApplicationModal.value = true; | |||
| applicationSuccessCallback.value = callback; | |||
| }; | |||
| // 处理申请成功 | |||
| const handleApplicationSuccess = (applicationData) => { | |||
| if (typeof applicationSuccessCallback.value === 'function') { | |||
| applicationSuccessCallback.value(applicationData); | |||
| applicationSuccessCallback.value = null; | |||
| } | |||
| // 关闭弹窗 | |||
| showApplicationModal.value = false; | |||
| }; | |||
| // 创建上下文对象 | |||
| const authorApplicationContext = { | |||
| openApplicationModal | |||
| }; | |||
| // 提供上下文给子组件 | |||
| provide(AUTHOR_APPLICATION_INJECTION_KEY, authorApplicationContext); | |||
| // 设置全局访问点 | |||
| window.$authorApplicationContext = authorApplicationContext; | |||
| return { | |||
| showApplicationModal, | |||
| handleApplicationSuccess | |||
| }; | |||
| } | |||
| }; | |||
| </script> | |||
| @ -0,0 +1,268 @@ | |||
| <template> | |||
| <div class="book-catalog-container"> | |||
| <div class="section-header"> | |||
| <h3 class="section-title">目录</h3> | |||
| </div> | |||
| <transition-group name="chapter-transition" tag="div" class="chapter-grid"> | |||
| <div v-for="(chapter, index) in displayedChapters" :key="chapter.id" class="chapter-item" | |||
| @click="goToChapter(chapter.id)"> | |||
| <div class="chapter-title"> | |||
| {{ chapter.title }} | |||
| <!-- <span class="new-badge" v-if="chapter.isNew">NEW</span> --> | |||
| </div> | |||
| <div class="chapter-pay" v-if="chapter.isPaid"> | |||
| <span class="pay-badge">付费</span> | |||
| </div> | |||
| </div> | |||
| </transition-group> | |||
| <div class="catalog-footer"> | |||
| <div v-if="showAll" class="collapse-btn" @click="toggleShowAll"> | |||
| 收起 <i class="collapse-icon"></i> | |||
| </div> | |||
| <div v-else-if="!showAll && chapters.length > maxDisplayChapters" class="expand-btn" @click="toggleShowAll"> | |||
| 展开所有章节 <i class="expand-icon"></i> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { defineComponent, ref, computed } from 'vue'; | |||
| import { useRouter } from 'vue-router'; | |||
| export default defineComponent({ | |||
| name: 'BookCatalog', | |||
| props: { | |||
| bookId: { | |||
| type: String, | |||
| default: '' | |||
| }, | |||
| chapters: { | |||
| type: Array, | |||
| default: () => [ | |||
| { id: '1', title: '第一章 重回2004', isNew: false, isPaid: false }, | |||
| { id: '2', title: '第二章 旧车旧房', isNew: false, isPaid: false }, | |||
| { id: '3', title: '第三章 再相见', isNew: false, isPaid: false }, | |||
| { id: '4', title: '第四章 李东的邀请', isNew: false, isPaid: false }, | |||
| { id: '5', title: '第五章 小气的男', isNew: false, isPaid: false }, | |||
| { id: '6', title: '第六章 先送谁?', isNew: false, isPaid: false }, | |||
| { id: '7', title: '第七章 打听行情', isNew: false, isPaid: false }, | |||
| { id: '8', title: '第八章 省城探路', isNew: false, isPaid: false }, | |||
| { id: '9', title: '第九章 订货', isNew: false, isPaid: false }, | |||
| { id: '10', title: '第十章 第一桶金', isNew: false, isPaid: false }, | |||
| { id: '11', title: '第十一章 高富帅来袭', isNew: false, isPaid: false }, | |||
| { id: '12', title: '第十二章 放学后,操场见!', isNew: true, isPaid: false }, | |||
| { id: '13', title: '第十三章 开个超市吧!', isNew: false, isPaid: true }, | |||
| { id: '14', title: '第十四章 你就是个骗子!', isNew: false, isPaid: true }, | |||
| { id: '15', title: '第十五章 要不要?', isNew: false, isPaid: true }, | |||
| { id: '16', title: '第十六章 买楼', isNew: false, isPaid: true }, | |||
| { id: '17', title: '第十七章 李总回校', isNew: false, isPaid: true }, | |||
| { id: '18', title: '第十八章 学霸和学渣', isNew: false, isPaid: true }, | |||
| { id: '19', title: '第十九章 小红是谁?', isNew: false, isPaid: true }, | |||
| { id: '20', title: '第二十章 秦海出招', isNew: false, isPaid: true }, | |||
| { id: '21', title: '第二十一章 孙涛', isNew: false, isPaid: true }, | |||
| { id: '22', title: '第二十二章 老爸的私房钱', isNew: false, isPaid: true }, | |||
| { id: '23', title: '第二十三章 政策来了', isNew: false, isPaid: true }, | |||
| { id: '24', title: '第二十四章 醉酒', isNew: false, isPaid: true } | |||
| ] | |||
| }, | |||
| maxDisplayChapters: { | |||
| type: Number, | |||
| default: 12 | |||
| } | |||
| }, | |||
| setup(props) { | |||
| const router = useRouter(); | |||
| const showAll = ref(false); | |||
| const displayedChapters = computed(() => { | |||
| if (showAll.value) { | |||
| return props.chapters; | |||
| } else { | |||
| return props.chapters.slice(0, props.maxDisplayChapters); | |||
| } | |||
| }); | |||
| const toggleShowAll = () => { | |||
| showAll.value = !showAll.value; | |||
| }; | |||
| const goToChapter = (chapterId) => { | |||
| router.push(`/book/${props.bookId}/chapter/${chapterId}`); | |||
| }; | |||
| return { | |||
| showAll, | |||
| displayedChapters, | |||
| toggleShowAll, | |||
| goToChapter | |||
| }; | |||
| } | |||
| }); | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .book-catalog-container { | |||
| background-color: #fff; | |||
| border-radius: 6px; | |||
| box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); | |||
| padding: 15px; | |||
| margin-bottom: 3px; | |||
| .section-header { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| align-items: center; | |||
| margin-bottom: 16px; | |||
| padding-bottom: 12px; | |||
| border-bottom: 1px solid #eee; | |||
| .section-title { | |||
| font-size: 18px; | |||
| font-weight: bold; | |||
| color: #333; | |||
| margin: 0; | |||
| position: relative; | |||
| padding-left: 12px; | |||
| &::before { | |||
| content: ''; | |||
| position: absolute; | |||
| left: 0; | |||
| top: 4px; | |||
| height: 18px; | |||
| width: 4px; | |||
| background-color: #0A2463; | |||
| border-radius: 2px; | |||
| } | |||
| } | |||
| } | |||
| .chapter-grid { | |||
| display: grid; | |||
| grid-template-columns: repeat(4, 1fr); | |||
| gap: 10px; | |||
| margin-bottom: 20px; | |||
| @media (max-width: 768px) { | |||
| grid-template-columns: 2fr; | |||
| } | |||
| .chapter-item { | |||
| padding: 8px 12px; | |||
| border-radius: 4px; | |||
| cursor: pointer; | |||
| transition: all 0.2s; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| &:hover { | |||
| background-color: #f8f9ff; | |||
| } | |||
| .chapter-title { | |||
| font-size: 14px; | |||
| color: #333; | |||
| white-space: nowrap; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| display: flex; | |||
| align-items: center; | |||
| flex: 1; | |||
| .new-badge { | |||
| background-color: #ff5252; | |||
| color: #fff; | |||
| font-size: 12px; | |||
| padding: 1px 6px; | |||
| border-radius: 4px; | |||
| margin-left: 8px; | |||
| flex-shrink: 0; | |||
| } | |||
| } | |||
| .chapter-pay { | |||
| flex-shrink: 0; | |||
| padding-left: 10px; | |||
| .pay-badge { | |||
| background-color: #FF9E2D; | |||
| color: #fff; | |||
| font-size: 12px; | |||
| padding: 2px 10px; | |||
| border-radius: 12px; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| .catalog-footer { | |||
| display: flex; | |||
| justify-content: center; | |||
| padding-top: 10px; | |||
| border-top: 1px solid #f0f0f0; | |||
| margin-top: 10px; | |||
| .expand-btn, .collapse-btn { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| color: #0A2463; | |||
| font-size: 14px; | |||
| cursor: pointer; | |||
| padding: 8px 0; | |||
| transition: all 0.3s; | |||
| &:hover { | |||
| opacity: 0.8; | |||
| } | |||
| } | |||
| .expand-btn { | |||
| .expand-icon { | |||
| display: inline-block; | |||
| width: 18px; | |||
| height: 18px; | |||
| margin-left: 8px; | |||
| background: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%230A2463"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>') no-repeat center; | |||
| background-size: contain; | |||
| transition: transform 0.3s; | |||
| } | |||
| } | |||
| .collapse-btn { | |||
| .collapse-icon { | |||
| display: inline-block; | |||
| width: 18px; | |||
| height: 18px; | |||
| margin-left: 8px; | |||
| background: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%230A2463"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>') no-repeat center; | |||
| background-size: contain; | |||
| transition: transform 0.3s; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| // 章节过渡动画 | |||
| .chapter-transition-enter-active, | |||
| .chapter-transition-leave-active { | |||
| transition: all 0.3s ease; | |||
| } | |||
| .chapter-transition-enter-from { | |||
| opacity: 0; | |||
| transform: translateY(20px); | |||
| } | |||
| .chapter-transition-leave-to { | |||
| opacity: 0; | |||
| transform: translateY(-20px); | |||
| } | |||
| .chapter-transition-move { | |||
| transition: transform 0.3s ease; | |||
| } | |||
| </style> | |||
| @ -0,0 +1,275 @@ | |||
| <template> | |||
| <div class="book-comments-container"> | |||
| <div class="section-header"> | |||
| <h3 class="section-title">书评</h3> | |||
| <div class="section-controls"> | |||
| <el-button type="primary" size="small" @click="showCommentForm = true"> | |||
| 写书评 | |||
| </el-button> | |||
| </div> | |||
| </div> | |||
| <div class="comment-input" v-if="showCommentForm"> | |||
| <el-input v-model="commentText" type="textarea" :rows="4" placeholder="请输入您的书评..." maxlength="500" | |||
| show-word-limit /> | |||
| <div class="comment-actions"> | |||
| <el-button @click="showCommentForm = false">取消</el-button> | |||
| <el-button type="primary" @click="submitComment" :disabled="!commentText.trim()">提交</el-button> | |||
| </div> | |||
| </div> | |||
| <div class="comments-list"> | |||
| <div v-for="(comment, index) in comments" :key="index" class="comment-item"> | |||
| <div class="user-avatar"> | |||
| <img src="@/assets/images/center/headImage.png" :alt="comment.username"> | |||
| </div> | |||
| <div class="comment-content"> | |||
| <div class="comment-header"> | |||
| <span class="username">{{ comment.username }}</span> | |||
| <span class="time">{{ comment.time }}</span> | |||
| </div> | |||
| <div class="comment-text">{{ comment.content }}</div> | |||
| <div class="comment-footer"> | |||
| <div class="comment-actions"> | |||
| <span class="action-item"> | |||
| <el-icon> | |||
| <Message /> | |||
| </el-icon> 回复 | |||
| </span> | |||
| <span class="action-item"> | |||
| <el-icon> | |||
| <Star /> | |||
| </el-icon> 点赞 | |||
| </span> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="comments-footer" v-if="comments.length > 0"> | |||
| <el-pagination v-model:currentPage="currentPage" :page-size="pageSize" layout="prev, pager, next" | |||
| :total="totalComments" @current-change="handlePageChange" /> | |||
| </div> | |||
| <div class="no-comments" v-if="comments.length === 0"> | |||
| <el-empty description="暂无书评,快来发表第一条书评吧!" /> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { defineComponent, ref, computed } from 'vue'; | |||
| import { Message, Star } from '@element-plus/icons-vue'; | |||
| import { ElMessage } from 'element-plus'; | |||
| export default defineComponent({ | |||
| name: 'BookComments', | |||
| components: { | |||
| Message, | |||
| Star | |||
| }, | |||
| props: { | |||
| bookId: { | |||
| type: String, | |||
| default: '' | |||
| } | |||
| }, | |||
| setup(props) { | |||
| const defaultAvatar = ref('/src/assets/images/默认头像.png'); | |||
| const comments = ref([ | |||
| { | |||
| username: '万年捧', | |||
| avatar: 'https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTKlR5PibUEEsVjXGfH4c1eR5hXDicoH0EJUTHYwDO3EvZLXXgON8GrNTbRg8DnzaddicibYnGcfq28tYg/132', | |||
| time: '2022-07-03', | |||
| content: '这是本年内看的唯一一部完结的!看的人真幸运,发家文和风险防控写的都是一流' | |||
| }, | |||
| { | |||
| username: '残生往事', | |||
| avatar: 'https://thirdwx.qlogo.cn/mmopen/vi_32/3F4feeHnMyoGjqKfP8vGKCHwyvovMHiaO0Q1QkQMRTGibLcyJbUcUJ4LmdkkDqC5ZcqP1rvqKMviaYAyehqYb6ciaA/132', | |||
| content: '我很喜欢男主的性格,不小心眼,有格局,做事情多考虑下一步,商业和情感都处理得不错,就是那个林涵有点没必要吧?' | |||
| } | |||
| ]); | |||
| const showCommentForm = ref(false); | |||
| const commentText = ref(''); | |||
| const currentPage = ref(1); | |||
| const pageSize = ref(10); | |||
| const totalComments = ref(comments.value.length); | |||
| const submitComment = () => { | |||
| if (!commentText.value.trim()) { | |||
| ElMessage.warning('请输入评论内容'); | |||
| return; | |||
| } | |||
| // 添加新评论(实际应用中应该调用API) | |||
| comments.value.unshift({ | |||
| username: '当前用户', | |||
| avatar: defaultAvatar.value, | |||
| time: new Date().toLocaleDateString(), | |||
| content: commentText.value | |||
| }); | |||
| totalComments.value = comments.value.length; | |||
| commentText.value = ''; | |||
| showCommentForm.value = false; | |||
| ElMessage.success('评论发表成功!'); | |||
| }; | |||
| const handlePageChange = (page) => { | |||
| currentPage.value = page; | |||
| // 实际应用中这里应该加载对应页的数据 | |||
| }; | |||
| return { | |||
| comments, | |||
| defaultAvatar, | |||
| showCommentForm, | |||
| commentText, | |||
| currentPage, | |||
| pageSize, | |||
| totalComments, | |||
| submitComment, | |||
| handlePageChange | |||
| }; | |||
| } | |||
| }); | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .book-comments-container { | |||
| background-color: #fff; | |||
| border-radius: 6px; | |||
| box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); | |||
| padding: 15px; | |||
| margin-bottom: 3px; | |||
| .section-header { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| align-items: center; | |||
| margin-bottom: 16px; | |||
| padding-bottom: 12px; | |||
| border-bottom: 1px solid #eee; | |||
| .section-title { | |||
| font-size: 18px; | |||
| font-weight: bold; | |||
| color: #333; | |||
| margin: 0; | |||
| position: relative; | |||
| padding-left: 12px; | |||
| &::before { | |||
| content: ''; | |||
| position: absolute; | |||
| left: 0; | |||
| top: 4px; | |||
| height: 18px; | |||
| width: 4px; | |||
| background-color: #0A2463; | |||
| border-radius: 2px; | |||
| } | |||
| } | |||
| } | |||
| .comment-input { | |||
| margin-bottom: 24px; | |||
| .comment-actions { | |||
| display: flex; | |||
| justify-content: flex-end; | |||
| margin-top: 12px; | |||
| gap: 10px; | |||
| } | |||
| } | |||
| .comments-list { | |||
| .comment-item { | |||
| display: flex; | |||
| padding: 16px 0; | |||
| border-bottom: 1px solid #eee; | |||
| &:last-child { | |||
| border-bottom: none; | |||
| } | |||
| .user-avatar { | |||
| width: 48px; | |||
| height: 48px; | |||
| border-radius: 50%; | |||
| overflow: hidden; | |||
| margin-right: 16px; | |||
| img { | |||
| width: 100%; | |||
| height: 100%; | |||
| object-fit: cover; | |||
| } | |||
| } | |||
| .comment-content { | |||
| flex: 1; | |||
| min-width: 0; | |||
| .comment-header { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| align-items: center; | |||
| margin-bottom: 8px; | |||
| .username { | |||
| font-size: 16px; | |||
| font-weight: 500; | |||
| color: #333; | |||
| } | |||
| .time { | |||
| font-size: 14px; | |||
| color: #999; | |||
| } | |||
| } | |||
| .comment-text { | |||
| font-size: 15px; | |||
| line-height: 1.6; | |||
| color: #333; | |||
| margin-bottom: 12px; | |||
| word-break: break-word; | |||
| } | |||
| .comment-footer { | |||
| .comment-actions { | |||
| display: flex; | |||
| gap: 16px; | |||
| .action-item { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 4px; | |||
| font-size: 14px; | |||
| color: #666; | |||
| cursor: pointer; | |||
| &:hover { | |||
| color: #0A2463; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| .comments-footer { | |||
| display: flex; | |||
| justify-content: center; | |||
| padding-top: 20px; | |||
| } | |||
| .no-comments { | |||
| padding: 30px 0; | |||
| } | |||
| } | |||
| </style> | |||
| @ -0,0 +1,120 @@ | |||
| <template> | |||
| <div class="book-intro-container"> | |||
| <div class="section-header"> | |||
| <h3 class="section-title">简介</h3> | |||
| </div> | |||
| <div class="intro-content" v-html="bookIntro"></div> | |||
| <div class="author-notes"> | |||
| <p v-if="authorDescription">{{ authorDescription }}</p> | |||
| <template v-if="userGroup"> | |||
| <p class="group-notice">您是星际《{{ bookTitle }}》读者?请不要错过下面的粉丝群!</p> | |||
| <p class="group-info">书友群: {{ userGroup }}</p> | |||
| </template> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { defineComponent, computed } from 'vue'; | |||
| export default defineComponent({ | |||
| name: 'BookIntro', | |||
| props: { | |||
| bookData: { | |||
| type: Object, | |||
| default: () => ({ | |||
| title: '重生之财源滚滚', | |||
| description: `<p>当那一世——</p> | |||
| <p>贺季宁曾经是A市土豪公司的金牌经理,工资高福利好,女友漂亮车位靓,最重要的是老板信任他。但是贺季宁的对手不服,老板娘不喜欢他,不怀好意的对手们联合一起,终于把他拉下台了,他失去工作,更可悲的是,他连公司配他的贷款的房子都搭进去了,欠了几百万的债,女友也离他而去。</p> | |||
| <p>临终前,他喃喃自语:如果老天给我重来一次的机会,我一定要好好做人,赚很多钱,让那帮人看着我过得好就比他们难受!</p>`, | |||
| authorDescription: '', | |||
| userGroup: '638781087(网友交流群)' | |||
| }) | |||
| } | |||
| }, | |||
| setup(props) { | |||
| const bookTitle = computed(() => props.bookData.title); | |||
| const bookIntro = computed(() => props.bookData.description); | |||
| const authorDescription = computed(() => props.bookData.authorDescription); | |||
| const userGroup = computed(() => props.bookData.userGroup); | |||
| return { | |||
| bookTitle, | |||
| bookIntro, | |||
| authorDescription, | |||
| userGroup | |||
| }; | |||
| } | |||
| }); | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .book-intro-container { | |||
| background-color: #fff; | |||
| border-radius: 6px; | |||
| box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); | |||
| padding: 15px; | |||
| margin-bottom: 3px; | |||
| .section-header { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| align-items: center; | |||
| margin-bottom: 16px; | |||
| padding-bottom: 12px; | |||
| border-bottom: 1px solid #eee; | |||
| .section-title { | |||
| font-size: 18px; | |||
| font-weight: bold; | |||
| color: #333; | |||
| margin: 0; | |||
| position: relative; | |||
| padding-left: 12px; | |||
| &::before { | |||
| content: ''; | |||
| position: absolute; | |||
| left: 0; | |||
| top: 4px; | |||
| height: 18px; | |||
| width: 4px; | |||
| background-color: #0A2463; | |||
| border-radius: 2px; | |||
| } | |||
| } | |||
| } | |||
| .intro-content { | |||
| font-size: 15px; | |||
| line-height: 1.8; | |||
| color: #333; | |||
| margin-bottom: 20px; | |||
| ::v-deep(p) { | |||
| margin-bottom: 10px; | |||
| } | |||
| } | |||
| .author-notes { | |||
| font-size: 14px; | |||
| color: #777; | |||
| padding-top: 16px; | |||
| border-top: 1px dashed #eee; | |||
| p { | |||
| margin-bottom: 8px; | |||
| } | |||
| .group-notice { | |||
| color: #666; | |||
| font-weight: 500; | |||
| } | |||
| .group-info { | |||
| color: #0A2463; | |||
| font-weight: 500; | |||
| } | |||
| } | |||
| } | |||
| </style> | |||
| @ -0,0 +1,166 @@ | |||
| <template> | |||
| <div class="book-stats-container"> | |||
| <div class="stats-section"> | |||
| <div class="stats-card"> | |||
| <div class="stats-header"> | |||
| <div class="stats-title-block"> | |||
| <span class="stats-indicator"></span> | |||
| <span class="stats-title">作品累计推荐票</span> | |||
| </div> | |||
| </div> | |||
| <div class="stats-content"> | |||
| <div class="stats-info"> | |||
| <div class="stats-value">2814</div> | |||
| <div class="stats-label">推荐票数</div> | |||
| </div> | |||
| <div class="stats-button"> | |||
| <el-button class="recommend-btn"> | |||
| <img src="@/assets/images/book/recommend.png" alt="投推荐票" class="icon"> | |||
| <span>投推荐票</span> | |||
| </el-button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="stats-divider"></div> | |||
| <div class="stats-card"> | |||
| <div class="stats-header"> | |||
| <div class="stats-title-block"> | |||
| <span class="stats-indicator"></span> | |||
| <span class="stats-title">作者累计亲密值</span> | |||
| </div> | |||
| </div> | |||
| <div class="stats-value">2814</div> | |||
| <div class="stats-label">亲密值数</div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { defineComponent, ref } from 'vue'; | |||
| export default defineComponent({ | |||
| name: 'BookStats', | |||
| props: { | |||
| bookId: { | |||
| type: String, | |||
| default: '' | |||
| } | |||
| }, | |||
| setup(props) { | |||
| const recommendCount = ref(2814); | |||
| const intimacyValue = ref(2814); | |||
| const handleRecommend = () => { | |||
| // 投票功能实现 | |||
| console.log('给作品ID为', props.bookId, '投推荐票'); | |||
| }; | |||
| return { | |||
| recommendCount, | |||
| intimacyValue, | |||
| handleRecommend | |||
| }; | |||
| } | |||
| }); | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .book-stats-container { | |||
| background-color: #fff; | |||
| border-radius: 8px; | |||
| box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); | |||
| padding: 20px; | |||
| margin-bottom: 3px; | |||
| .stats-section { | |||
| display: flex; | |||
| position: relative; | |||
| .stats-divider { | |||
| width: 1px; | |||
| background-color: #EEEEEE; | |||
| margin: 0 40px; | |||
| } | |||
| .stats-card { | |||
| flex: 1; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: flex-start; | |||
| padding: 0 10px; | |||
| .stats-header { | |||
| width: 100%; | |||
| display: flex; | |||
| justify-content: flex-start; | |||
| margin-bottom: 15px; | |||
| .stats-title-block { | |||
| display: flex; | |||
| align-items: center; | |||
| .stats-indicator { | |||
| width: 4px; | |||
| height: 18px; | |||
| background-color: #0A2463; | |||
| border-radius: 2px; | |||
| margin-right: 8px; | |||
| } | |||
| .stats-title { | |||
| font-size: 16px; | |||
| color: #333; | |||
| font-weight: 500; | |||
| } | |||
| } | |||
| } | |||
| .stats-content { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| align-items: center; | |||
| width: 100%; | |||
| .stats-info { | |||
| display: flex; | |||
| flex-direction: column; | |||
| } | |||
| } | |||
| .stats-value { | |||
| font-size: 28px; | |||
| font-weight: bold; | |||
| color: #333; | |||
| margin-bottom: 8px; | |||
| } | |||
| .stats-label { | |||
| font-size: 14px; | |||
| color: #999; | |||
| margin-bottom: 15px; | |||
| } | |||
| .stats-button { | |||
| .recommend-btn { | |||
| color: #0A2463; | |||
| border: 1px solid #0A2463; | |||
| padding: 8px 18px; | |||
| border-radius: 4px; | |||
| font-size: 14px; | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 5px; | |||
| .icon { | |||
| width: 16px; | |||
| height: 16px; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| </style> | |||
| @ -0,0 +1,151 @@ | |||
| <template> | |||
| <div class="book-item"> | |||
| <!-- 书籍封面和信息 --> | |||
| <div class="book-card" @click="handleClick"> | |||
| <div class="book-cover"> | |||
| <img :src="book.cover" :alt="book.title"> | |||
| <span class="book-status">{{ book.status }}</span> | |||
| <span v-if="deleteMode" class="delete-icon" @click.stop="handleDelete"> | |||
| <img src="@/assets/images/移除书架.png" alt="移除"> | |||
| </span> | |||
| </div> | |||
| <div class="book-info"> | |||
| <h3 class="book-title">{{ book.title }}</h3> | |||
| <p class="book-author">作者:{{ book.author }}</p> | |||
| <p class="last-read">上次阅读:{{ book.lastReadChapter }}</p> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { defineComponent } from 'vue'; | |||
| export default defineComponent({ | |||
| name: 'BookshelfItem', | |||
| props: { | |||
| book: { | |||
| type: Object, | |||
| required: true | |||
| }, | |||
| deleteMode: { | |||
| type: Boolean, | |||
| default: false | |||
| } | |||
| }, | |||
| emits: ['click', 'delete'], | |||
| setup(props, { emit }) { | |||
| // 点击书籍卡片的处理函数 | |||
| const handleClick = () => { | |||
| if (!props.deleteMode) { | |||
| emit('click', props.book); | |||
| } | |||
| }; | |||
| // 点击删除图标的处理函数 | |||
| const handleDelete = () => { | |||
| emit('delete', props.book.id); | |||
| }; | |||
| return { | |||
| handleClick, | |||
| handleDelete | |||
| }; | |||
| } | |||
| }); | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| @use '@/assets/styles/variables.scss' as vars; | |||
| .book-item { | |||
| .book-card { | |||
| background-color: #fff; | |||
| border-radius: 8px; | |||
| box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); | |||
| overflow: hidden; | |||
| transition: all 0.3s; | |||
| cursor: pointer; | |||
| &:hover { | |||
| transform: translateY(-5px); | |||
| box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15); | |||
| } | |||
| .book-cover { | |||
| position: relative; | |||
| width: 100%; | |||
| padding-top: 140%; | |||
| overflow: hidden; | |||
| img { | |||
| position: absolute; | |||
| top: 0; | |||
| left: 0; | |||
| width: 100%; | |||
| height: 100%; | |||
| object-fit: cover; | |||
| } | |||
| .book-status { | |||
| position: absolute; | |||
| top: 10px; | |||
| right: 10px; | |||
| padding: 2px 8px; | |||
| background-color: rgba(0, 0, 0, 0.6); | |||
| color: #fff; | |||
| font-size: 12px; | |||
| border-radius: 10px; | |||
| } | |||
| .delete-icon { | |||
| position: absolute; | |||
| top: 10px; | |||
| left: 10px; | |||
| width: 30px; | |||
| height: 30px; | |||
| background-color: rgba(0, 0, 0, 0.6); | |||
| border-radius: 50%; | |||
| display: flex; | |||
| justify-content: center; | |||
| align-items: center; | |||
| z-index: 2; | |||
| img { | |||
| width: 16px; | |||
| height: 16px; | |||
| } | |||
| } | |||
| } | |||
| .book-info { | |||
| padding: 12px; | |||
| .book-title { | |||
| margin: 0 0 5px; | |||
| 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 5px; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| white-space: nowrap; | |||
| } | |||
| .last-read { | |||
| font-size: 12px; | |||
| color: #999; | |||
| margin: 0; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| </style> | |||
| @ -0,0 +1,191 @@ | |||
| <template> | |||
| <div class="book-card" @click="handleClick"> | |||
| <div class="book-cover"> | |||
| <!-- <img :src="book.cover" :alt="book.title"> --> | |||
| <img src="https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp" :alt="book.title"> | |||
| <span v-if="deleteMode" class="delete-icon" @click.stop="handleDelete"> | |||
| <img src="@/assets/images/移除书架.png" alt="移除"> | |||
| </span> | |||
| </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.lastReadChapter && book.description">{{ book.description }}</p> | |||
| <p class="last-read" v-if="book.lastReadChapter">上次阅读:{{ book.lastReadChapter }}</p> | |||
| <div class="book-status"> | |||
| <span class="status-tag">{{ book.status || '连载中' }}</span> | |||
| <span class="reading-count">大家都在读</span> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { useRouter } from 'vue-router'; | |||
| export default { | |||
| name: 'BookshelfCard', | |||
| props: { | |||
| book: { | |||
| type: Object, | |||
| required: true, | |||
| default: () => ({ | |||
| id: '', | |||
| title: '', | |||
| author: '', | |||
| description: '', | |||
| cover: '', | |||
| status: '', | |||
| readCount: 0 | |||
| }) | |||
| }, | |||
| deleteMode: { | |||
| type: Boolean, | |||
| default: false | |||
| } | |||
| }, | |||
| emits: ['click', 'delete'], | |||
| setup(props, { emit }) { | |||
| const router = useRouter(); | |||
| // 点击书籍卡片的处理函数 | |||
| const handleClick = () => { | |||
| if (!props.deleteMode) { | |||
| emit('click', props.book); | |||
| } | |||
| }; | |||
| // 点击删除图标的处理函数 | |||
| const handleDelete = () => { | |||
| emit('delete', props.book.id); | |||
| }; | |||
| return { | |||
| handleClick, | |||
| handleDelete | |||
| }; | |||
| } | |||
| }; | |||
| </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; | |||
| 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-status { | |||
| position: absolute; | |||
| top: 10px; | |||
| right: 10px; | |||
| padding: 2px 8px; | |||
| background-color: rgba(0, 0, 0, 0.6); | |||
| color: #fff; | |||
| font-size: 12px; | |||
| border-radius: 10px; | |||
| } | |||
| .delete-icon { | |||
| position: absolute; | |||
| top: 10px; | |||
| left: 10px; | |||
| width: 30px; | |||
| height: 30px; | |||
| background-color: rgba(0, 0, 0, 0.6); | |||
| border-radius: 50%; | |||
| display: flex; | |||
| justify-content: center; | |||
| align-items: center; | |||
| z-index: 2; | |||
| img { | |||
| width: 16px; | |||
| height: 16px; | |||
| } | |||
| } | |||
| } | |||
| .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; | |||
| } | |||
| .last-read { | |||
| font-size: 12px; | |||
| color: #999; | |||
| margin: 0 0 8px; | |||
| } | |||
| .book-status { | |||
| margin-top: 8px; | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 10px; | |||
| .status-tag { | |||
| background-color: #67C23A33; | |||
| color: vars.$primary-color; | |||
| padding: 2px 8px; | |||
| border-radius: 10px; | |||
| font-size: 12px; | |||
| color: #67C23A; | |||
| } | |||
| .reading-count { | |||
| font-size: 12px; | |||
| color: #999; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| </style> | |||
| @ -0,0 +1,438 @@ | |||
| <template> | |||
| <div class="intimacy-ranking-container"> | |||
| <div class="ranking-header"> | |||
| <div class="header-title"> | |||
| <img src="@/assets/images/读者榜单.png" alt="读者榜单" class="ranking-icon" /> | |||
| <span>读者亲密榜单</span> | |||
| </div> | |||
| </div> | |||
| <div class="ranking-tabs"> | |||
| <div class="tab-list"> | |||
| <div v-for="(tab, index) in tabs" :key="index" | |||
| :class="['tab-item', { active: activeTab === tab.value }]" @click="activeTab = tab.value"> | |||
| {{ tab.label }} | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="ranking-list"> | |||
| <div v-for="(item, index) in currentRankingList" :key="index" class="ranking-item"> | |||
| <div class="rank-num"> | |||
| <template v-if="index < 3"> | |||
| <div :class="['medal', `medal-${index + 1}`]">{{ index + 1 }}</div> | |||
| </template> | |||
| <template v-else> | |||
| <div class="normal-rank">{{ index + 1 }}</div> | |||
| </template> | |||
| </div> | |||
| <div class="user-avatar"> | |||
| <img :src="item.avatar" :alt="item.username"> | |||
| </div> | |||
| <div class="user-info"> | |||
| <div class="username">{{ item.username }}</div> | |||
| <div class="level-tag"> | |||
| {{ `护书者者 ${item.level}级` }} | |||
| </div> | |||
| </div> | |||
| <div class="intimacy-value"> | |||
| <div class="value">{{ item.value }}</div> | |||
| <div class="label">亲密值</div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { defineComponent, ref, computed } from 'vue'; | |||
| export default defineComponent({ | |||
| name: 'IntimacyRanking', | |||
| props: { | |||
| bookId: { | |||
| type: String, | |||
| default: '' | |||
| } | |||
| }, | |||
| setup(props) { | |||
| const tabs = [ | |||
| { label: '总榜', value: 'all' }, | |||
| { label: '周榜', value: 'week' }, | |||
| { label: '月榜', value: 'month' } | |||
| ]; | |||
| const activeTab = ref('all'); | |||
| const allRankingList = ref([ | |||
| { | |||
| username: '周游', | |||
| avatar: 'https://picsum.photos/100/100?random=1', | |||
| level: '五', | |||
| value: '8783452', | |||
| }, | |||
| { | |||
| username: '冯启明', | |||
| avatar: 'https://picsum.photos/100/100?random=2', | |||
| level: '五', | |||
| value: '890379', | |||
| }, | |||
| { | |||
| username: '风静', | |||
| avatar: 'https://picsum.photos/100/100?random=3', | |||
| level: '四', | |||
| value: '605039', | |||
| }, | |||
| { | |||
| username: '钱静', | |||
| avatar: 'https://picsum.photos/100/100?random=4', | |||
| level: '三', | |||
| value: '532524', | |||
| }, | |||
| { | |||
| username: '线编码', | |||
| avatar: 'https://picsum.photos/100/100?random=5', | |||
| level: '三', | |||
| value: '525524', | |||
| }, | |||
| { | |||
| username: '冯艾蓥', | |||
| avatar: 'https://picsum.photos/100/100?random=6', | |||
| level: '二', | |||
| value: '496064', | |||
| }, | |||
| { | |||
| username: '李书琦', | |||
| avatar: 'https://picsum.photos/100/100?random=7', | |||
| level: '一', | |||
| value: '372525', | |||
| }, | |||
| { | |||
| username: '李梅', | |||
| avatar: 'https://picsum.photos/100/100?random=8', | |||
| level: '一', | |||
| value: '267476', | |||
| }, | |||
| { | |||
| username: '郑帆', | |||
| avatar: 'https://picsum.photos/100/100?random=9', | |||
| level: '一', | |||
| value: '248480', | |||
| }, | |||
| { | |||
| username: '吴修德', | |||
| avatar: 'https://picsum.photos/100/100?random=10', | |||
| level: '一', | |||
| value: '59053', | |||
| } | |||
| ]); | |||
| const weekRankingList = ref([ | |||
| { | |||
| username: '周游', | |||
| avatar: 'https://picsum.photos/100/100?random=1', | |||
| level: '五', | |||
| value: '78452', | |||
| }, | |||
| { | |||
| username: '冯启明', | |||
| avatar: 'https://picsum.photos/100/100?random=2', | |||
| level: '五', | |||
| value: '69037', | |||
| }, | |||
| { | |||
| username: '风静', | |||
| avatar: 'https://picsum.photos/100/100?random=3', | |||
| level: '四', | |||
| value: '60509', | |||
| }, | |||
| { | |||
| username: '钱静', | |||
| avatar: 'https://picsum.photos/100/100?random=4', | |||
| level: '三', | |||
| value: '53254', | |||
| }, | |||
| { | |||
| username: '线编码', | |||
| avatar: 'https://picsum.photos/100/100?random=5', | |||
| level: '三', | |||
| value: '52554', | |||
| }, | |||
| { | |||
| username: '冯艾蓥', | |||
| avatar: 'https://picsum.photos/100/100?random=6', | |||
| level: '二', | |||
| value: '49064', | |||
| }, | |||
| { | |||
| username: '李书琦', | |||
| avatar: 'https://picsum.photos/100/100?random=7', | |||
| level: '一', | |||
| value: '37255', | |||
| }, | |||
| { | |||
| username: '李梅', | |||
| avatar: 'https://picsum.photos/100/100?random=8', | |||
| level: '一', | |||
| value: '26747', | |||
| }, | |||
| { | |||
| username: '郑帆', | |||
| avatar: 'https://picsum.photos/100/100?random=9', | |||
| level: '一', | |||
| value: '24848', | |||
| }, | |||
| { | |||
| username: '吴修德', | |||
| avatar: 'https://picsum.photos/100/100?random=10', | |||
| level: '一', | |||
| value: '5953', | |||
| } | |||
| ]); | |||
| const monthRankingList = ref([ | |||
| { | |||
| username: '周游', | |||
| avatar: 'https://picsum.photos/100/100?random=1', | |||
| level: '五', | |||
| value: '178452', | |||
| }, | |||
| { | |||
| username: '冯启明', | |||
| avatar: 'https://picsum.photos/100/100?random=2', | |||
| level: '五', | |||
| value: '169037', | |||
| }, | |||
| { | |||
| username: '风静', | |||
| avatar: 'https://picsum.photos/100/100?random=3', | |||
| level: '四', | |||
| value: '160509', | |||
| }, | |||
| { | |||
| username: '钱静', | |||
| avatar: 'https://picsum.photos/100/100?random=4', | |||
| level: '三', | |||
| value: '153254', | |||
| }, | |||
| { | |||
| username: '线编码', | |||
| avatar: 'https://picsum.photos/100/100?random=5', | |||
| level: '三', | |||
| value: '152554', | |||
| }, | |||
| { | |||
| username: '冯艾蓥', | |||
| avatar: 'https://picsum.photos/100/100?random=6', | |||
| level: '二', | |||
| value: '149064', | |||
| }, | |||
| { | |||
| username: '李书琦', | |||
| avatar: 'https://picsum.photos/100/100?random=7', | |||
| level: '一', | |||
| value: '137255', | |||
| }, | |||
| { | |||
| username: '李梅', | |||
| avatar: 'https://picsum.photos/100/100?random=8', | |||
| level: '一', | |||
| value: '126747', | |||
| }, | |||
| { | |||
| username: '郑帆', | |||
| avatar: 'https://picsum.photos/100/100?random=9', | |||
| level: '一', | |||
| value: '124848', | |||
| }, | |||
| { | |||
| username: '吴修德', | |||
| avatar: 'https://picsum.photos/100/100?random=10', | |||
| level: '一', | |||
| value: '105953', | |||
| } | |||
| ]); | |||
| const currentRankingList = computed(() => { | |||
| if (activeTab.value === 'all') { | |||
| return allRankingList.value; | |||
| } else if (activeTab.value === 'week') { | |||
| return weekRankingList.value; | |||
| } else { | |||
| return monthRankingList.value; | |||
| } | |||
| }); | |||
| return { | |||
| tabs, | |||
| activeTab, | |||
| currentRankingList | |||
| }; | |||
| } | |||
| }); | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .intimacy-ranking-container { | |||
| background-color: #fff; | |||
| border-radius: 6px; | |||
| box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); | |||
| padding: 0 0 16px 0; | |||
| overflow: hidden; | |||
| margin-left: 0; | |||
| .ranking-header { | |||
| background-color: #0A2463; | |||
| color: #fff; | |||
| padding: 15px 20px; | |||
| .header-title { | |||
| display: flex; | |||
| align-items: center; | |||
| font-size: 18px; | |||
| font-weight: bold; | |||
| .ranking-icon { | |||
| width: 28px; | |||
| height: 28px; | |||
| margin-right: 10px; | |||
| } | |||
| } | |||
| } | |||
| .ranking-tabs { | |||
| padding: 12px 15px; | |||
| border-bottom: 1px solid #eee; | |||
| .tab-list { | |||
| display: flex; | |||
| .tab-item { | |||
| padding: 6px 16px; | |||
| font-size: 15px; | |||
| color: #666; | |||
| cursor: pointer; | |||
| border-radius: 4px; | |||
| transition: all 0.2s; | |||
| &.active { | |||
| background-color: #f0f5ff; | |||
| color: #0A2463; | |||
| font-weight: 500; | |||
| } | |||
| &:hover:not(.active) { | |||
| color: #0A2463; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| .ranking-list { | |||
| padding: 0 15px; | |||
| .ranking-item { | |||
| display: flex; | |||
| align-items: center; | |||
| padding: 12px 0; | |||
| border-bottom: 1px solid #f5f5f5; | |||
| &:last-child { | |||
| border-bottom: none; | |||
| } | |||
| .rank-num { | |||
| width: 30px; | |||
| display: flex; | |||
| justify-content: center; | |||
| .medal { | |||
| width: 22px; | |||
| height: 22px; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| border-radius: 50%; | |||
| font-size: 14px; | |||
| font-weight: bold; | |||
| color: #fff; | |||
| &.medal-1 { | |||
| background: linear-gradient(to bottom, #ffda44, #ff9c39); | |||
| } | |||
| &.medal-2 { | |||
| background: linear-gradient(to bottom, #d3d3d3, #a0a0a0); | |||
| } | |||
| &.medal-3 { | |||
| background: linear-gradient(to bottom, #ffc09f, #a15c20); | |||
| } | |||
| } | |||
| .normal-rank { | |||
| font-size: 16px; | |||
| color: #999; | |||
| } | |||
| } | |||
| .user-avatar { | |||
| width: 44px; | |||
| height: 44px; | |||
| border-radius: 50%; | |||
| overflow: hidden; | |||
| margin: 0 12px; | |||
| img { | |||
| width: 100%; | |||
| height: 100%; | |||
| object-fit: cover; | |||
| } | |||
| } | |||
| .user-info { | |||
| flex: 1; | |||
| min-width: 0; | |||
| .username { | |||
| font-size: 15px; | |||
| font-weight: 500; | |||
| color: #333; | |||
| margin-bottom: 4px; | |||
| white-space: nowrap; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| } | |||
| .level-tag { | |||
| font-size: 13px; | |||
| color: #0A2463; | |||
| background-color: #f0f5ff; | |||
| display: inline-block; | |||
| padding: 2px 8px; | |||
| border-radius: 4px; | |||
| } | |||
| } | |||
| .intimacy-value { | |||
| text-align: right; | |||
| min-width: 80px; | |||
| .value { | |||
| font-size: 16px; | |||
| font-weight: bold; | |||
| color: #ff7c6a; | |||
| } | |||
| .label { | |||
| font-size: 12px; | |||
| color: #999; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| </style> | |||
| @ -0,0 +1,328 @@ | |||
| <template> | |||
| <div class="intimacy-ranking-container"> | |||
| <div class="ranking-header"> | |||
| <div class="crown-wrapper"> | |||
| <img src="@/assets/images/读者榜单.png" alt="皇冠" class="crown-icon"> | |||
| </div> | |||
| <div class="title-wrapper"> | |||
| <span class="header-title">读者亲密值榜单</span> | |||
| </div> | |||
| </div> | |||
| <div class="ranking-list"> | |||
| <div v-for="(item, index) in currentRankingList.slice(0, 10)" :key="index" class="ranking-item"> | |||
| <div class="rank-side-bg" :class="`rank-color-${index + 1}`"> | |||
| <span class="rank-num">{{ index + 1 }}</span> | |||
| </div> | |||
| <div class="item-content"> | |||
| <div class="user-avatar"> | |||
| <img src="@/assets/images/center/headImage.png" :alt="item.username"> | |||
| </div> | |||
| <div class="user-info"> | |||
| <div class="username"> | |||
| <img src="@/assets/images/center/headImage.png" alt="徽章" class="badge-img"> | |||
| <span class="user-name">{{ item.username }}</span> | |||
| </div> | |||
| <div class="badge-wrap"> | |||
| <div class="level-badge"> | |||
| <span class="level-text">护书使者 {{ item.level }}级</span> | |||
| </div> | |||
| </div> | |||
| <div class="intimacy-value"> | |||
| {{ formatNumber(item.value) }} <span class="value-label">亲密值</span> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { defineComponent, ref, computed } from 'vue'; | |||
| export default defineComponent({ | |||
| name: 'IntimacyRanking', | |||
| props: { | |||
| bookId: { | |||
| type: String, | |||
| default: '' | |||
| } | |||
| }, | |||
| setup(props) { | |||
| const activeTab = ref('all'); | |||
| const allRankingList = ref([ | |||
| { | |||
| username: '周海', | |||
| avatar: 'https://picsum.photos/100/100?random=1', | |||
| level: '五', | |||
| value: '6785452', | |||
| }, | |||
| { | |||
| username: '冯启彬', | |||
| avatar: 'https://picsum.photos/100/100?random=2', | |||
| level: '五', | |||
| value: '6303372', | |||
| }, | |||
| { | |||
| username: '周静', | |||
| avatar: 'https://picsum.photos/100/100?random=3', | |||
| level: '四', | |||
| value: '6075079', | |||
| }, | |||
| { | |||
| username: '钱萌萌', | |||
| avatar: 'https://picsum.photos/100/100?random=4', | |||
| level: '三', | |||
| value: '5325324', | |||
| }, | |||
| { | |||
| username: '冯艺萱', | |||
| avatar: 'https://picsum.photos/100/100?random=5', | |||
| level: '二', | |||
| value: '5325324', | |||
| }, | |||
| { | |||
| username: '王凡玄', | |||
| avatar: 'https://picsum.photos/100/100?random=6', | |||
| level: '一', | |||
| value: '4696874', | |||
| }, | |||
| { | |||
| username: '李书涛', | |||
| avatar: 'https://picsum.photos/100/100?random=7', | |||
| level: '一', | |||
| value: '3722523', | |||
| }, | |||
| { | |||
| username: '李梅', | |||
| avatar: 'https://picsum.photos/100/100?random=8', | |||
| level: '一', | |||
| value: '2872476', | |||
| }, | |||
| { | |||
| username: '郑晗', | |||
| avatar: 'https://picsum.photos/100/100?random=9', | |||
| level: '一', | |||
| value: '2484886', | |||
| }, | |||
| { | |||
| username: '吴修德', | |||
| avatar: 'https://picsum.photos/100/100?random=10', | |||
| level: '一', | |||
| value: '590238', | |||
| } | |||
| ]); | |||
| const currentRankingList = computed(() => { | |||
| return allRankingList.value; | |||
| }); | |||
| // 格式化数字显示 | |||
| const formatNumber = (num) => { | |||
| return num; | |||
| }; | |||
| // 根据排名获取不同的徽章图片 | |||
| const getBadgeImage = (index) => { | |||
| const badges = [ | |||
| '@/assets/images/image-1.png', // 一级徽章 | |||
| '@/assets/images/image-1.png', // 二级徽章 | |||
| '@/assets/images/image-2.png', // 三级徽章 | |||
| '@/assets/images/image-3.png', // 四级徽章 | |||
| '@/assets/images/image-4.png' // 五级徽章 | |||
| ]; | |||
| // 根据等级或排名返回不同的徽章 | |||
| if (index < 3) { | |||
| return badges[0]; | |||
| } else if (index < 5) { | |||
| return badges[1]; | |||
| } else { | |||
| return badges[2]; | |||
| } | |||
| }; | |||
| return { | |||
| activeTab, | |||
| currentRankingList, | |||
| formatNumber, | |||
| getBadgeImage | |||
| }; | |||
| } | |||
| }); | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .intimacy-ranking-container { | |||
| background-color: #fff; | |||
| border-radius: 12px; | |||
| box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); | |||
| overflow: hidden; | |||
| padding: 0 15px 15px; | |||
| .ranking-header { | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| padding: 20px 0 15px; | |||
| position: relative; | |||
| .crown-wrapper { | |||
| margin-bottom: 8px; | |||
| .crown-icon { | |||
| width: 40px; | |||
| height: 40px; | |||
| display: block; | |||
| } | |||
| } | |||
| .title-wrapper { | |||
| .header-title { | |||
| font-size: 18px; | |||
| font-weight: bold; | |||
| color: #333; | |||
| line-height: 1.4; | |||
| } | |||
| } | |||
| } | |||
| .ranking-list { | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: 20px; | |||
| .ranking-item { | |||
| position: relative; | |||
| border-radius: 10px; | |||
| overflow: hidden; | |||
| background-color: #fff; | |||
| display: flex; | |||
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); | |||
| .rank-side-bg { | |||
| width: 50px; | |||
| height: 100px; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| .rank-num { | |||
| font-size: 22px; | |||
| font-weight: 600; | |||
| color: #986514; | |||
| } | |||
| &.rank-color-1 { | |||
| background-color: #FFEECC; | |||
| } | |||
| &.rank-color-2 { | |||
| background-color: #E9ECEF; | |||
| .rank-num { | |||
| color: #777; | |||
| } | |||
| } | |||
| &.rank-color-3 { | |||
| background-color: #FFE7E0; | |||
| .rank-num { | |||
| color: #A05E2B; | |||
| } | |||
| } | |||
| &.rank-color-4, &.rank-color-5, &.rank-color-6, | |||
| &.rank-color-7, &.rank-color-8, &.rank-color-9, &.rank-color-10 { | |||
| background-color: #F8F8F8; | |||
| .rank-num { | |||
| color: #999; | |||
| } | |||
| } | |||
| } | |||
| .item-content { | |||
| flex: 1; | |||
| display: flex; | |||
| padding: 10px 15px; | |||
| align-items: center; | |||
| .user-avatar { | |||
| width: 36px; | |||
| height: 36px; | |||
| border-radius: 50%; | |||
| overflow: hidden; | |||
| margin-right: 12px; | |||
| img { | |||
| width: 100%; | |||
| height: 100%; | |||
| object-fit: cover; | |||
| border-radius: 50%; | |||
| } | |||
| } | |||
| .user-info { | |||
| flex: 1; | |||
| min-width: 0; | |||
| .username { | |||
| font-size: 15px; | |||
| color: #333; | |||
| font-weight: 500; | |||
| margin-bottom: 4px; | |||
| display: flex; | |||
| align-items: center; | |||
| img{ | |||
| width: 25px; | |||
| height: 25px; | |||
| border-radius: 50%; | |||
| } | |||
| .user-name{ | |||
| margin-left: 10px; | |||
| white-space: nowrap; | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| } | |||
| } | |||
| .badge-wrap { | |||
| margin-bottom: 3px; | |||
| .level-badge { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| background-color: #F0E6FF; | |||
| border-radius: 15px; | |||
| padding: 2px 6px 2px 4px; | |||
| .badge-img { | |||
| width: 16px; | |||
| height: 16px; | |||
| margin-right: 3px; | |||
| } | |||
| .level-text { | |||
| color: #9B72FF; | |||
| font-size: 12px; | |||
| } | |||
| } | |||
| } | |||
| .intimacy-value { | |||
| font-size: 13px; | |||
| color: #888; | |||
| .value-label { | |||
| color: #999; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| </style> | |||
| @ -0,0 +1,269 @@ | |||
| <template> | |||
| <div class="bookshelf-container"> | |||
| <div class="bookshelf-header"> | |||
| <h1 class="bookshelf-title">我的书架</h1> | |||
| <div class="bookshelf-actions"> | |||
| <el-button type="warning" class="clear-btn" @click="confirmClearBookshelf">清空书架</el-button> | |||
| <el-button type="danger" class="delete-btn" @click="toggleDeleteMode">{{ deleteMode ? '取消删除' : '删除书本' }}</el-button> | |||
| </div> | |||
| </div> | |||
| <div v-if="bookshelf.length === 0" class="empty-bookshelf"> | |||
| <div class="empty-text">您的书架还没有书籍,去书城逛逛吧~</div> | |||
| <el-button type="primary" @click="goToHome">去书城看看</el-button> | |||
| </div> | |||
| <div v-else class="bookshelf-content"> | |||
| <div class="book-grid"> | |||
| <bookshelfCard | |||
| v-for="book in bookshelf" | |||
| :key="book.id" | |||
| :book="book" | |||
| :delete-mode="deleteMode" | |||
| @click="goToReadBook" | |||
| @delete="removeBook" | |||
| /> | |||
| </div> | |||
| </div> | |||
| <!-- 确认清空书架的对话框 --> | |||
| <el-dialog | |||
| v-model="clearConfirmVisible" | |||
| title="确认清空" | |||
| width="30%" | |||
| > | |||
| <span>确定要清空书架吗?此操作不可恢复!</span> | |||
| <template #footer> | |||
| <span class="dialog-footer"> | |||
| <el-button @click="clearConfirmVisible = false">取消</el-button> | |||
| <el-button type="primary" @click="clearBookshelf">确定</el-button> | |||
| </span> | |||
| </template> | |||
| </el-dialog> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { ref, computed, onMounted } from 'vue'; | |||
| import { useRouter } from 'vue-router'; | |||
| import { useMainStore } from '@/store'; | |||
| import { ElMessage, ElMessageBox } from 'element-plus'; | |||
| import bookshelfCard from '@/components/bookshelf/bookshelfCard.vue'; | |||
| export default { | |||
| name: 'BookshelfView', | |||
| components: { | |||
| bookshelfCard | |||
| }, | |||
| setup() { | |||
| const store = useMainStore(); | |||
| const router = useRouter(); | |||
| const deleteMode = ref(false); | |||
| const clearConfirmVisible = ref(false); | |||
| // 获取书架数据,如果为空则提供模拟数据 | |||
| const bookshelf = computed(() => { | |||
| if (store.bookshelf.length > 0) { | |||
| return store.bookshelf; | |||
| } else { | |||
| // 返回模拟数据,仅用于演示 | |||
| return [ | |||
| { | |||
| id: '1', | |||
| title: '大宋好厨夫', | |||
| author: '祝家大郎', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '书友群:638781087智慧老猿经过了北宋水浒世界,变成了那个被孙二娘三拳打死的张青', | |||
| status: '已完结', | |||
| lastReadChapter: '第十二章', | |||
| lastReadTime: new Date().toISOString() | |||
| }, | |||
| { | |||
| id: '2', | |||
| title: '重生日本当厨神', | |||
| author: '千回转', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '作死一次安然,宁原吃料理到撑死的复仇,来到一个他完全陌生的食林之中', | |||
| status: '已完结', | |||
| lastReadChapter: '第十一章', | |||
| lastReadTime: new Date().toISOString() | |||
| }, | |||
| { | |||
| id: '3', | |||
| title: '罗修炎月儿武道大帝', | |||
| author: '忘情至尊', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '关于武道大帝:少年罗修出身寒微,天赋一般,却意外觅得奇遇本末逆转', | |||
| status: '已完结', | |||
| lastReadChapter: '第十章', | |||
| lastReadTime: new Date().toISOString() | |||
| }, | |||
| { | |||
| id: '4', | |||
| title: '神豪无极限', | |||
| author: '匿名', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '穿越到平行时空的陆安打了个响指,开启了全新的生活方式,他说,其实掐的', | |||
| status: '已完结', | |||
| lastReadChapter: '第一章', | |||
| lastReadTime: new Date().toISOString() | |||
| } | |||
| ]; | |||
| } | |||
| }); | |||
| // 跳转到阅读页面 | |||
| const goToReadBook = (book) => { | |||
| if (deleteMode.value) return; // 删除模式下不跳转 | |||
| router.push(`/book/${book.id}`); | |||
| }; | |||
| // 跳转到首页 | |||
| const goToHome = () => { | |||
| router.push('/'); | |||
| }; | |||
| // 切换删除模式 | |||
| const toggleDeleteMode = () => { | |||
| deleteMode.value = !deleteMode.value; | |||
| }; | |||
| // 移除书籍 | |||
| const removeBook = (bookId) => { | |||
| ElMessageBox.confirm('确定要将该书从书架中移除吗?', '提示', { | |||
| confirmButtonText: '确定', | |||
| cancelButtonText: '取消', | |||
| type: 'warning' | |||
| }).then(() => { | |||
| if (store.removeFromBookshelf(bookId)) { | |||
| ElMessage({ | |||
| type: 'success', | |||
| message: '已从书架移除' | |||
| }); | |||
| } | |||
| }).catch(() => {}); | |||
| }; | |||
| // 确认清空书架 | |||
| const confirmClearBookshelf = () => { | |||
| clearConfirmVisible.value = true; | |||
| }; | |||
| // 清空书架 | |||
| const clearBookshelf = () => { | |||
| store.clearBookshelf(); | |||
| clearConfirmVisible.value = false; | |||
| ElMessage({ | |||
| type: 'success', | |||
| message: '书架已清空' | |||
| }); | |||
| }; | |||
| onMounted(() => { | |||
| // 检查用户登录状态由路由守卫统一处理,这里不再需要单独处理 | |||
| // 如果有其他初始化逻辑可以放在这里 | |||
| }); | |||
| return { | |||
| bookshelf, | |||
| deleteMode, | |||
| clearConfirmVisible, | |||
| goToReadBook, | |||
| goToHome, | |||
| toggleDeleteMode, | |||
| removeBook, | |||
| confirmClearBookshelf, | |||
| clearBookshelf | |||
| }; | |||
| } | |||
| }; | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| @use '@/assets/styles/variables.scss' as vars; | |||
| .bookshelf-container { | |||
| width: 100%; | |||
| background-color: #fff; | |||
| min-height: calc(100vh - 60px); | |||
| padding: 20px; | |||
| } | |||
| .bookshelf-header { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| align-items: center; | |||
| margin-bottom: 30px; | |||
| padding-bottom: 15px; | |||
| border-bottom: 2px solid #f0f0f0; | |||
| .bookshelf-title { | |||
| font-size: 20px; | |||
| font-weight: bold; | |||
| color: #333; | |||
| position: relative; | |||
| padding-left: 15px; | |||
| &::before { | |||
| content: ''; | |||
| position: absolute; | |||
| left: 0; | |||
| top: 50%; | |||
| transform: translateY(-50%); | |||
| height: 20px; | |||
| width: 4px; | |||
| background-color: vars.$primary-color; | |||
| border-radius: 2px; | |||
| } | |||
| } | |||
| .bookshelf-actions { | |||
| display: flex; | |||
| gap: 10px; | |||
| .clear-btn, .delete-btn { | |||
| font-size: 14px; | |||
| } | |||
| } | |||
| } | |||
| .empty-bookshelf { | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| justify-content: center; | |||
| padding: 100px 0; | |||
| color: #999; | |||
| .empty-text { | |||
| margin-bottom: 20px; | |||
| font-size: 16px; | |||
| } | |||
| } | |||
| .bookshelf-content { | |||
| .book-grid { | |||
| display: grid; | |||
| grid-template-columns: repeat(2, 1fr); | |||
| gap: 25px; | |||
| @media screen and (max-width: 1200px) { | |||
| grid-template-columns: repeat(2, 1fr); | |||
| } | |||
| @media screen and (max-width: 768px) { | |||
| grid-template-columns: repeat(1, 1fr); | |||
| } | |||
| @media screen and (max-width: 480px) { | |||
| grid-template-columns: 1fr; | |||
| } | |||
| } | |||
| } | |||
| .pagination-container { | |||
| display: flex; | |||
| justify-content: center; | |||
| padding: 20px 0; | |||
| } | |||
| </style> | |||
| @ -0,0 +1,304 @@ | |||
| <template> | |||
| <div class="category-container"> | |||
| <!-- 分类标题区 --> | |||
| <div class="category-header"> | |||
| <div class="category-title"> | |||
| <h1>{{ categoryName }}</h1> | |||
| </div> | |||
| </div> | |||
| <div class="content-wrapper"> | |||
| <!-- 左侧分类导航 --> | |||
| <div class="category-sidebar" v-if="!hasRouteId"> | |||
| <ul class="category-nav"> | |||
| <li v-for="(name, id) in categoryMap" :key="id" :class="{ active: id === currentCategoryId }" | |||
| @click="switchCategory(id)"> | |||
| {{ name }} | |||
| </li> | |||
| </ul> | |||
| </div> | |||
| <!-- 右侧书籍列表 --> | |||
| <div class="book-content" :class="{ 'full-width': hasRouteId }"> | |||
| <!-- 书籍列表 --> | |||
| <div class="book-list-container"> | |||
| <div v-for="(book, index) in books" :key="index" class="book-item"> | |||
| <book-card :book="book" /> | |||
| </div> | |||
| </div> | |||
| <!-- 分页 --> | |||
| <div class="pagination-container"> | |||
| <el-pagination v-model:current-page="currentPage" :page-size="pageSize" :total="total" | |||
| layout="prev, pager, next" @current-change="handlePageChange" /> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { ref, reactive, onMounted, computed } from 'vue'; | |||
| import { useRoute, useRouter } from 'vue-router'; | |||
| import BookCard from '@/components/common/BookCard.vue'; | |||
| export default { | |||
| name: 'CategoryView', | |||
| components: { | |||
| BookCard | |||
| }, | |||
| setup() { | |||
| const route = useRoute(); | |||
| const router = useRouter(); | |||
| const categoryId = computed(() => route.params.id); | |||
| const currentCategoryId = ref(categoryId.value || '1'); | |||
| // 计算是否有路由ID参数 | |||
| const hasRouteId = computed(() => !!categoryId.value); | |||
| // 分页相关 | |||
| const currentPage = ref(1); | |||
| const pageSize = ref(20); | |||
| const total = ref(100); | |||
| // 分类名称映射 | |||
| const categoryMap = { | |||
| '1': '武侠', | |||
| '2': '都市', | |||
| '3': '玄幻', | |||
| '4': '历史', | |||
| '5': '浪漫青春', | |||
| '6': '短篇', | |||
| '7': '言情', | |||
| '8': '小说' | |||
| }; | |||
| // 计算当前分类名称 | |||
| const categoryName = computed(() => categoryMap[currentCategoryId.value] || '玄幻类'); | |||
| // 模拟书籍数据 | |||
| const books = reactive([ | |||
| { | |||
| id: '1', | |||
| title: '大宋好厨夫', | |||
| author: '祝家大郎', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '书友群:638781087智慧老猿经过了北宋水浒世界,变成了那个被孙二娘三拳打死的张青', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '2', | |||
| title: '重生日本当厨神', | |||
| author: '千回转', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '作死一次安然,宁原吃料理到撑死的复仇,来到一个他完全陌生的食林之中', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '3', | |||
| title: '罗修炎月儿武道大帝', | |||
| author: '忘情至尊', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '关于武道大帝:少年罗修出身寒微,天赋一般,却意外觅得奇遇本末逆转', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '4', | |||
| title: '神豪无极限', | |||
| author: '匿名', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '穿越到平行时空的陆安打了个响指,开启了全新的生活方式,他说,其实掐的', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '5', | |||
| title: '顾道长生', | |||
| author: '睡觉变白', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '本以为是写实的都市生活,结果一言不合就修仙!灵气复苏,道法重现,这', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '6', | |||
| title: '魔天记', | |||
| author: '忘语', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '一名在无数岁月中中长大的亡魂少年,一个与魂并立的时代,一个个可以仿佛', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '7', | |||
| title: '二十面骰子', | |||
| author: '赛斯', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '在整千七朝由感转化的时代,新大陆的出现成为各种族冒险争夺的乐园,延', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '8', | |||
| title: '苏莫是什么小说', | |||
| author: '半代溜王', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '苏莫少年苏莫,突破逆天武魂,却被认为是最低级的垃圾武魂,受尽屈辱…', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '9', | |||
| title: '重生大明当暴君', | |||
| author: '圆溜溜', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '你以为你请我建设好,联不如意?你以为你随商宰,联不如意?东南富庶,而', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '10', | |||
| title: '重生大明当暴君', | |||
| author: '圆溜溜', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '你以为你请我建设好,联不如意?你以为你随商宰,联不如意?东南富庶,而', | |||
| status: '已完结' | |||
| } | |||
| ]); | |||
| // 切换分类 | |||
| const switchCategory = (id) => { | |||
| currentCategoryId.value = id; | |||
| currentPage.value = 1; | |||
| // 实际应用中这里应该调用API获取对应分类的书籍 | |||
| console.log('切换到分类:', id); | |||
| }; | |||
| const handlePageChange = (page) => { | |||
| currentPage.value = page; | |||
| // 这里应该调用API获取对应页码的数据 | |||
| console.log('切换到页码:', page); | |||
| }; | |||
| onMounted(() => { | |||
| if (categoryId.value) { | |||
| currentCategoryId.value = categoryId.value; | |||
| } | |||
| // 实际应用中这里应该根据分类ID从API获取书籍列表 | |||
| console.log('加载分类ID:', currentCategoryId.value); | |||
| }); | |||
| return { | |||
| categoryName, | |||
| categoryMap, | |||
| books, | |||
| currentPage, | |||
| pageSize, | |||
| total, | |||
| handlePageChange, | |||
| currentCategoryId, | |||
| switchCategory, | |||
| hasRouteId | |||
| }; | |||
| } | |||
| }; | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| @use '@/assets/styles/variables.scss' as vars; | |||
| .category-container { | |||
| width: 100%; | |||
| background-color: #fff; | |||
| } | |||
| .category-header { | |||
| margin-bottom: 20px; | |||
| padding: 20px; | |||
| border-bottom: 2px solid vars.$primary-color; | |||
| .category-title { | |||
| padding: 10px 0; | |||
| h1 { | |||
| font-size: 20px; | |||
| font-weight: bold; | |||
| color: vars.$primary-color; | |||
| 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; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| .content-wrapper { | |||
| display: flex; | |||
| gap: 20px; | |||
| } | |||
| // 左侧分类导航 | |||
| .category-sidebar { | |||
| width: 120px; | |||
| flex-shrink: 0; | |||
| background-color: #f8f8f8; | |||
| border-radius: 4px; | |||
| .category-nav { | |||
| list-style: none; | |||
| padding: 0; | |||
| margin: 0; | |||
| li { | |||
| padding: 12px 15px; | |||
| font-size: 14px; | |||
| cursor: pointer; | |||
| border-left: 3px solid transparent; | |||
| transition: all 0.3s; | |||
| &:hover { | |||
| background-color: #f0f0f0; | |||
| color: vars.$primary-color; | |||
| } | |||
| &.active { | |||
| background-color: #e8f1ff; | |||
| color: vars.$primary-color; | |||
| border-left-color: vars.$primary-color; | |||
| font-weight: bold; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| // 右侧内容区 | |||
| .book-content { | |||
| flex: 1; | |||
| min-width: 0; | |||
| padding: 20px; | |||
| &.full-width { | |||
| width: 100%; | |||
| } | |||
| } | |||
| .book-list-container { | |||
| display: grid; | |||
| grid-template-columns: repeat(2, 1fr); | |||
| gap: 20px; | |||
| margin-bottom: 30px; | |||
| @media screen and (max-width: vars.$md) { | |||
| grid-template-columns: 1fr; | |||
| } | |||
| } | |||
| .pagination-container { | |||
| display: flex; | |||
| justify-content: center; | |||
| padding: 20px 0; | |||
| } | |||
| </style> | |||
| @ -0,0 +1,376 @@ | |||
| <template> | |||
| <div class="ranking-container"> | |||
| <div class="content-wrapper"> | |||
| <!-- 左侧榜单导航 --> | |||
| <div class="ranking-sidebar"> | |||
| <ul class="ranking-nav"> | |||
| <li v-for="(name, id) in rankingTypes" :key="id" :class="{ active: id === currentRankingType }" | |||
| @click="switchRankingType(id)"> | |||
| {{ name }} | |||
| </li> | |||
| </ul> | |||
| </div> | |||
| <!-- 右侧内容区 --> | |||
| <div class="ranking-content"> | |||
| <!-- 顶部分类导航 --> | |||
| <div class="category-tabs"> | |||
| <ul class="category-nav"> | |||
| <li v-for="(name, id) in categoryMap" :key="id" :class="{ active: id === currentCategoryId }" | |||
| @click="switchCategory(id)"> | |||
| {{ name }} | |||
| </li> | |||
| </ul> | |||
| </div> | |||
| <!-- 书籍列表 --> | |||
| <div class="book-list-container"> | |||
| <div v-for="(book, index) in books" :key="index" class="book-item"> | |||
| <div class="rank-number" :class="{ | |||
| 'rank-first': index === 0, | |||
| 'rank-second': index === 1, | |||
| 'rank-third': index === 2 | |||
| }">{{ index + 1 }}</div> | |||
| <book-card :book="book" /> | |||
| </div> | |||
| </div> | |||
| <!-- 分页 --> | |||
| <div class="pagination-container"> | |||
| <el-pagination v-model:current-page="currentPage" :page-size="pageSize" :total="total" | |||
| layout="prev, pager, next" @current-change="handlePageChange" /> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { ref, reactive, onMounted, computed } from 'vue'; | |||
| import { useRoute, useRouter } from 'vue-router'; | |||
| import BookCard from '@/components/common/BookCard.vue'; | |||
| export default { | |||
| name: 'RankingView', | |||
| components: { | |||
| BookCard | |||
| }, | |||
| setup() { | |||
| const route = useRoute(); | |||
| const router = useRouter(); | |||
| const categoryId = computed(() => route.params.id); | |||
| const currentCategoryId = ref(categoryId.value || '1'); | |||
| // 计算是否有路由ID参数 | |||
| const hasRouteId = computed(() => !!categoryId.value); | |||
| // 分页相关 | |||
| const currentPage = ref(1); | |||
| const pageSize = ref(20); | |||
| const total = ref(100); | |||
| // 分类名称映射 | |||
| const categoryMap = { | |||
| '1': '武侠', | |||
| '2': '都市', | |||
| '3': '玄幻', | |||
| '4': '历史', | |||
| '5': '浪漫青春', | |||
| '6': '短篇', | |||
| '7': '言情', | |||
| '8': '小说' | |||
| }; | |||
| // 榜单类型 | |||
| const rankingTypes = { | |||
| '1': '推荐榜', | |||
| '2': '完本榜', | |||
| '3': '阅读榜', | |||
| '4': '口碑榜', | |||
| '5': '新书榜', | |||
| '6': '高分榜' | |||
| }; | |||
| const currentRankingType = ref('1'); | |||
| // 计算当前分类名称 | |||
| const categoryName = computed(() => categoryMap[currentCategoryId.value] || '玄幻类'); | |||
| // 模拟书籍数据 | |||
| const books = reactive([ | |||
| { | |||
| id: '1', | |||
| title: '大宋好厨夫', | |||
| author: '祝家大郎', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '书友群:638781087智慧老猿经过了北宋水浒世界,变成了那个被孙二娘三拳打死的张青', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '2', | |||
| title: '重生日本当厨神', | |||
| author: '千回转', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '作死一次安然,宁原吃料理到撑死的复仇,来到一个他完全陌生的食林之中', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '3', | |||
| title: '罗修炎月儿武道大帝', | |||
| author: '忘情至尊', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '关于武道大帝:少年罗修出身寒微,天赋一般,却意外觅得奇遇本末逆转', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '4', | |||
| title: '神豪无极限', | |||
| author: '匿名', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '穿越到平行时空的陆安打了个响指,开启了全新的生活方式,他说,其实掐的', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '5', | |||
| title: '顾道长生', | |||
| author: '睡觉变白', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '本以为是写实的都市生活,结果一言不合就修仙!灵气复苏,道法重现,这', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '6', | |||
| title: '魔天记', | |||
| author: '忘语', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '一名在无数岁月中中长大的亡魂少年,一个与魂并立的时代,一个个可以仿佛', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '7', | |||
| title: '二十面骰子', | |||
| author: '赛斯', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '在整千七朝由感转化的时代,新大陆的出现成为各种族冒险争夺的乐园,延', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '8', | |||
| title: '苏莫是什么小说', | |||
| author: '半代溜王', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '苏莫少年苏莫,突破逆天武魂,却被认为是最低级的垃圾武魂,受尽屈辱…', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '9', | |||
| title: '重生大明当暴君', | |||
| author: '圆溜溜', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '你以为你请我建设好,联不如意?你以为你随商宰,联不如意?东南富庶,而', | |||
| status: '已完结' | |||
| }, | |||
| { | |||
| id: '10', | |||
| title: '重生大明当暴君', | |||
| author: '圆溜溜', | |||
| cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp', | |||
| description: '你以为你请我建设好,联不如意?你以为你随商宰,联不如意?东南富庶,而', | |||
| status: '已完结' | |||
| } | |||
| ]); | |||
| // 切换分类 | |||
| const switchCategory = (id) => { | |||
| currentCategoryId.value = id; | |||
| currentPage.value = 1; | |||
| // 实际应用中这里应该调用API获取对应分类的书籍 | |||
| console.log('切换到分类:', id); | |||
| }; | |||
| // 切换榜单类型 | |||
| const switchRankingType = (id) => { | |||
| currentRankingType.value = id; | |||
| currentPage.value = 1; | |||
| // 实际应用中这里应该调用API获取对应榜单的书籍 | |||
| console.log('切换到榜单:', id); | |||
| }; | |||
| const handlePageChange = (page) => { | |||
| currentPage.value = page; | |||
| // 这里应该调用API获取对应页码的数据 | |||
| console.log('切换到页码:', page); | |||
| }; | |||
| onMounted(() => { | |||
| if (categoryId.value) { | |||
| currentCategoryId.value = categoryId.value; | |||
| } | |||
| // 实际应用中这里应该根据分类ID从API获取书籍列表 | |||
| console.log('加载分类ID:', currentCategoryId.value); | |||
| }); | |||
| return { | |||
| categoryName, | |||
| categoryMap, | |||
| books, | |||
| currentPage, | |||
| pageSize, | |||
| total, | |||
| handlePageChange, | |||
| currentCategoryId, | |||
| switchCategory, | |||
| hasRouteId, | |||
| rankingTypes, | |||
| currentRankingType, | |||
| switchRankingType | |||
| }; | |||
| } | |||
| }; | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| @use '@/assets/styles/variables.scss' as vars; | |||
| .ranking-container { | |||
| width: 100%; | |||
| background-color: #fff; | |||
| } | |||
| .content-wrapper { | |||
| display: flex; | |||
| gap: 20px; | |||
| } | |||
| // 左侧榜单导航 | |||
| .ranking-sidebar { | |||
| width: 120px; | |||
| flex-shrink: 0; | |||
| background-color: #f8f8f8; | |||
| border-radius: 4px; | |||
| .ranking-nav { | |||
| list-style: none; | |||
| padding: 0; | |||
| margin: 0; | |||
| li { | |||
| padding: 12px 15px; | |||
| font-size: 14px; | |||
| cursor: pointer; | |||
| border-left: 3px solid transparent; | |||
| transition: all 0.3s; | |||
| &:hover { | |||
| background-color: #f0f0f0; | |||
| color: vars.$primary-color; | |||
| } | |||
| &.active { | |||
| background-color: #e8f1ff; | |||
| color: vars.$primary-color; | |||
| border-left-color: vars.$primary-color; | |||
| font-weight: bold; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| // 右侧内容区 | |||
| .ranking-content { | |||
| flex: 1; | |||
| min-width: 0; | |||
| } | |||
| // 顶部分类导航 | |||
| .category-tabs { | |||
| width: 100%; | |||
| background-color: #fff; | |||
| border-bottom: 1px solid #eee; | |||
| padding: 0 20px; | |||
| .category-nav { | |||
| list-style: none; | |||
| padding: 0; | |||
| margin: 0; | |||
| display: flex; | |||
| flex-wrap: wrap; | |||
| li { | |||
| padding: 15px 20px; | |||
| font-size: 16px; | |||
| cursor: pointer; | |||
| position: relative; | |||
| transition: all 0.3s; | |||
| &:hover { | |||
| color: vars.$primary-color; | |||
| } | |||
| &.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; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| .book-list-container { | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: 15px; | |||
| margin-bottom: 30px; | |||
| padding: 20px; | |||
| } | |||
| .book-item { | |||
| display: flex; | |||
| align-items: center; | |||
| border-bottom: 1px solid #f0f0f0; | |||
| padding-bottom: 15px; | |||
| position: relative; | |||
| } | |||
| .rank-number { | |||
| width: 24px; | |||
| height: 24px; | |||
| display: flex; | |||
| justify-content: center; | |||
| align-items: center; | |||
| background-color: #ddd; | |||
| color: #fff; | |||
| font-weight: bold; | |||
| border-radius: 4px; | |||
| margin-right: 15px; | |||
| flex-shrink: 0; | |||
| // 前三名特殊样式 | |||
| &.rank-first { | |||
| background-color: #e74c3c; | |||
| } | |||
| &.rank-second { | |||
| background-color: #f39c12; | |||
| } | |||
| &.rank-third { | |||
| background-color: #2ecc71; | |||
| } | |||
| } | |||
| .pagination-container { | |||
| display: flex; | |||
| justify-content: center; | |||
| padding: 20px 0; | |||
| } | |||
| </style> | |||