Browse Source

feat: 新增书架功能及优化书籍相关组件

- 新增书架功能,包括书籍的添加、移除、清空及阅读进度管理
- 新增书籍详情页组件,包括简介、目录、书评、统计等模块
- 优化书籍卡片组件,统一样式并支持删除模式
- 新增创作者申请功能及相关弹窗组件
- 更新路由配置,支持书架及书籍详情页的路由跳转
- 更新全局样式,优化按钮及颜色变量
master
前端-胡立永 7 months ago
parent
commit
121f9f38ee
24 changed files with 3871 additions and 571 deletions
  1. +33
    -3
      src/App.vue
  2. +0
    -0
      src/assets/images/book/level.png
  3. +0
    -0
      src/assets/images/book/recommend.png
  4. +0
    -0
      src/assets/images/center/headImage.png
  5. +5
    -4
      src/assets/styles/global.scss
  6. +26
    -3
      src/components/auth/AuthProvider.vue
  7. +176
    -0
      src/components/auth/AuthorApplicationModal.vue
  8. +61
    -0
      src/components/auth/AuthorApplicationProvider.vue
  9. +268
    -0
      src/components/book/BookCatalog.vue
  10. +275
    -0
      src/components/book/BookComments.vue
  11. +120
    -0
      src/components/book/BookIntro.vue
  12. +166
    -0
      src/components/book/BookStats.vue
  13. +151
    -0
      src/components/bookshelf/BookshelfItem.vue
  14. +191
    -0
      src/components/bookshelf/bookshelfCard.vue
  15. +6
    -7
      src/components/common/BookCard.vue
  16. +438
    -0
      src/components/ranking/IntimacyRanking copy.vue
  17. +328
    -0
      src/components/ranking/IntimacyRanking.vue
  18. +171
    -13
      src/layout/layout/Header.vue
  19. +59
    -3
      src/router/index.js
  20. +61
    -2
      src/store/index.js
  21. +387
    -536
      src/views/book/index.vue
  22. +269
    -0
      src/views/home/Bookshelf.vue
  23. +304
    -0
      src/views/home/category.vue
  24. +376
    -0
      src/views/home/ranking.vue

+ 33
- 3
src/App.vue View File

@ -1,22 +1,52 @@
<template>
<auth-provider>
<router-view />
<author-application-provider>
<router-view />
</author-application-provider>
</auth-provider>
</template>
<script>
import { onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { routerEvents } from '@/router';
import AuthProvider from '@/components/auth/AuthProvider.vue';
import AuthorApplicationProvider from '@/components/auth/AuthorApplicationProvider.vue';
export default {
name: 'App',
components: {
AuthProvider
AuthProvider,
AuthorApplicationProvider
},
setup() {
const route = useRoute();
// 便访
window.routerEvents = routerEvents;
//
onMounted(() => {
//
if (typeof routerEvents.triggerLogin === 'function') {
routerEvents.triggerLogin();
routerEvents.triggerLogin = null;
}
});
//
watch(route, () => {
if (typeof routerEvents.triggerLogin === 'function') {
routerEvents.triggerLogin();
routerEvents.triggerLogin = null;
}
});
}
};
</script>
<style lang="scss">
@import './assets/styles/global.scss';
@use '@/assets/styles/global.scss';
html, body {
margin: 0;


src/assets/images/我的等级.png → src/assets/images/book/level.png View File


src/assets/images/投推荐票.png → src/assets/images/book/recommend.png View File


src/assets/images/默认头像.png → src/assets/images/center/headImage.png View File


+ 5
- 4
src/assets/styles/global.scss View File

@ -1,4 +1,5 @@
@use './variables.scss' as vars;
@use "sass:color";
html {
font-size: 16px;
@ -61,8 +62,8 @@ a {
&:hover,
&:focus {
background-color: lighten(vars.$primary-color, 10%);
border-color: lighten(vars.$primary-color, 10%);
background-color: color.scale(vars.$primary-color, $lightness: 10%);
border-color: color.scale(vars.$primary-color, $lightness: 10%);
}
}
@ -73,8 +74,8 @@ a {
&:hover,
&:focus {
background-color: lighten(vars.$accent-color, 10%);
border-color: lighten(vars.$accent-color, 10%);
background-color: color.scale(vars.$accent-color, $lightness: 10%);
border-color: color.scale(vars.$accent-color, $lightness: 10%);
}
}


+ 26
- 3
src/components/auth/AuthProvider.vue View File

@ -11,7 +11,7 @@
</template>
<script>
import { ref, provide } from 'vue';
import { ref, provide, onMounted, onBeforeMount } from 'vue';
import LoginRegisterModal from './LoginRegisterModal.vue';
//
@ -48,6 +48,8 @@ export default {
loginSuccessCallback.value();
loginSuccessCallback.value = null;
}
//
showAuthModal.value = false;
};
//
@ -56,12 +58,33 @@ export default {
registerSuccessCallback.value();
registerSuccessCallback.value = null;
}
//
showAuthModal.value = false;
};
//
provide(AUTH_INJECTION_KEY, {
//
const authContext = {
openLogin,
openRegister
};
//
provide(AUTH_INJECTION_KEY, authContext);
// 访访
onBeforeMount(() => {
window.$authContext = authContext;
});
// 访
onMounted(() => {
window.$authContext = authContext;
//
if (window.routerEvents && typeof window.routerEvents.triggerLogin === 'function') {
window.routerEvents.triggerLogin();
window.routerEvents.triggerLogin = null;
}
});
return {


+ 176
- 0
src/components/auth/AuthorApplicationModal.vue View File

@ -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;
// APIAPI
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>

+ 61
- 0
src/components/auth/AuthorApplicationProvider.vue View File

@ -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>

+ 268
- 0
src/components/book/BookCatalog.vue View File

@ -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>

+ 275
- 0
src/components/book/BookComments.vue View File

@ -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>

+ 120
- 0
src/components/book/BookIntro.vue View File

@ -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>

+ 166
- 0
src/components/book/BookStats.vue View File

@ -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>

+ 151
- 0
src/components/bookshelf/BookshelfItem.vue View File

@ -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>

+ 191
- 0
src/components/bookshelf/bookshelfCard.vue View File

@ -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>

+ 6
- 7
src/components/common/BookCard.vue View File

@ -7,9 +7,9 @@
<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 class="book-status">
<span class="status-tag">连载中</span>
<span class="reading-count">大家都在读</span>
</div>
</div>
</div>
@ -61,7 +61,6 @@ export default {
transition: all 0.3s;
cursor: pointer;
background-color: #fff;
max-width: 400px;
margin-bottom: 15px;
@ -117,16 +116,16 @@ export default {
.book-status {
margin-top: 8px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
.status-tag {
background-color: #e6f7ff;
background-color: #67C23A33;
color: vars.$primary-color;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
border: 1px solid #91d5ff;
color: #67C23A;
}
.reading-count {


+ 438
- 0
src/components/ranking/IntimacyRanking copy.vue View File

@ -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>

+ 328
- 0
src/components/ranking/IntimacyRanking.vue View File

@ -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>

+ 171
- 13
src/layout/layout/Header.vue View File

@ -48,18 +48,51 @@
</div>
<el-button v-if="!isLoggedIn" type="primary" @click="goToLogin" plain>登录/注册</el-button>
<el-dropdown v-else>
<el-dropdown v-else trigger="click" placement="bottom-end">
<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 class="user-dropdown-menu">
<div class="user-info-card" @click="goToUserCenter">
<div class="user-avatar-wrapper">
<el-avatar :size="48" :src="userAvatar" />
</div>
<div class="user-info-details">
<div class="user-name">{{ userName }}</div>
<div class="user-bean">豆豆: {{ beanCount }}</div>
</div>
<div class="user-arrow">
<el-icon><arrow-right /></el-icon>
</div>
</div>
<el-dropdown-item class="menu-item">
<el-icon><GiftBox /></el-icon>
<span>礼物盒</span>
</el-dropdown-item>
<el-dropdown-item class="menu-item">
<el-icon><List /></el-icon>
<span>任务中心</span>
</el-dropdown-item>
<el-dropdown-item class="menu-item" @click="applyForAuthor">
<el-icon><Star /></el-icon>
<span>申请成为创作者</span>
</el-dropdown-item>
<el-dropdown-item class="menu-item">
<el-icon><Wallet /></el-icon>
<span>钱包流水</span>
</el-dropdown-item>
<el-dropdown-item class="menu-item" @click="logout">
<el-icon><SwitchButton /></el-icon>
<span>退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@ -69,18 +102,38 @@
</template>
<script>
import { ref, computed, inject } from 'vue';
import { ref, computed, inject, h } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useMainStore } from '@/store';
import { ArrowDown, Bell, Reading } from '@element-plus/icons-vue';
import { ArrowDown, ArrowRight, Bell, Reading, List, Star, Wallet, SwitchButton } from '@element-plus/icons-vue';
import { AUTH_INJECTION_KEY } from '@/components/auth/AuthProvider.vue';
import { AUTHOR_APPLICATION_INJECTION_KEY } from '@/components/auth/AuthorApplicationProvider.vue';
export default {
name: 'AppHeader',
components: {
ArrowDown,
ArrowRight,
Bell,
Reading
Reading,
List,
Star,
Wallet,
SwitchButton,
GiftBox: {
render() {
return h('svg', {
viewBox: '0 0 1024 1024',
width: '1em',
height: '1em'
}, [
h('path', {
fill: 'currentColor',
d: 'M832 192H192c-35.3 0-64 28.7-64 64v512c0 35.3 28.7 64 64 64h640c35.3 0 64-28.7 64-64V256c0-35.3-28.7-64-64-64zM766.7 317.3l-210.8 290c-5.7 7.9-14.8 12.6-24.5 12.6s-18.8-4.7-24.5-12.6l-210.8-290c-6.3-8.6-7.2-20.1-2.6-29.8 4.6-9.7 14.3-15.8 25-15.8h425.8c10.7 0 20.4 6.1 25 15.8 4.6 9.7 3.7 21.2-2.6 29.8z'
})
]);
}
}
},
setup() {
const router = useRouter();
@ -90,11 +143,14 @@ export default {
//
const authContext = inject(AUTH_INJECTION_KEY);
//
const authorApplicationContext = inject(AUTHOR_APPLICATION_INJECTION_KEY);
//
const isLoggedIn = computed(() => store.isAuthenticated);
const userName = computed(() => store.user?.name || '用户');
const userName = computed(() => store.user?.name || '神鹿日光');
const userAvatar = computed(() => store.user?.avatar || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png');
const beanCount = computed(() => store.user?.beanCount || '9999');
//
const activeMenu = computed(() => {
@ -146,10 +202,24 @@ export default {
router.push('/');
};
//
const applyForAuthor = () => {
//
if (!isLoggedIn.value) {
authContext.openLogin(() => {
authorApplicationContext.openApplicationModal();
});
return;
}
//
authorApplicationContext.openApplicationModal();
};
return {
isLoggedIn,
userName,
userAvatar,
beanCount,
activeMenu,
searchKeyword,
handleSearch,
@ -157,7 +227,8 @@ export default {
goToUserCenter,
goToBookshelf,
goToSettings,
logout
logout,
applyForAuthor
};
}
};
@ -269,10 +340,18 @@ export default {
align-items: center;
cursor: pointer;
color: #fff;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.3s;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.username {
margin: 0 0.3125rem; // 5px -> 0.3125rem
font-size: 0.875rem; // 14px -> 0.875rem
margin: 0 8px;
font-size: 14px;
font-weight: 500;
}
}
@ -291,6 +370,85 @@ export default {
}
}
:deep(.el-dropdown-menu.user-dropdown-menu) {
min-width: 260px !important;
padding: 0 !important;
border-radius: 6px !important;
overflow: hidden !important;
box-shadow: 0 4px 16px rgba(0,0,0,0.12) !important;
}
:deep(.user-info-card) {
display: flex !important;
align-items: center !important;
padding: 14px !important;
cursor: pointer !important;
border-bottom: 1px solid #f0f0f0 !important;
transition: background-color 0.3s !important;
&:hover {
background-color: #f9f9f9 !important;
}
.user-avatar-wrapper {
margin-right: 12px !important;
.el-avatar {
width: 42px !important;
height: 42px !important;
}
}
.user-info-details {
flex: 1 !important;
.user-name {
font-size: 15px !important;
font-weight: 500 !important;
margin-bottom: 4px !important;
color: #333 !important;
}
.user-bean {
font-size: 13px !important;
color: #EB8D00 !important;
}
}
.user-arrow {
color: #ccc !important;
margin-left: 8px !important;
.el-icon {
font-size: 16px !important;
}
}
}
:deep(.el-dropdown-menu__item.menu-item) {
display: flex !important;
align-items: center !important;
height: 48px !important;
padding: 0 16px !important;
font-size: 14px !important;
color: #333 !important;
cursor: pointer !important;
&:hover {
background-color: #f7f7f7 !important;
}
.el-icon {
font-size: 20px !important;
margin-right: 12px !important;
color: #666 !important;
}
span {
font-size: 14px !important;
}
}
/* 响应式调整 */
@media screen and (max-width: vars.$md) {
.app-header {


+ 59
- 3
src/router/index.js View File

@ -1,5 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router';
import { AUTH_INJECTION_KEY } from '../components/auth/AuthProvider.vue';
import layout from '../layout/index.vue';
@ -23,6 +23,27 @@ const routes = [
path: 'book/:id/chapter/:chapterId',
name: 'ChapterDetail',
component: () => import('../views/book/chapter.vue')
},
{
path: 'category',
name: 'Category',
component: () => import('../views/home/category.vue')
},
{
path: 'category/:id',
name: 'CategoryDetail',
component: () => import('../views/home/category.vue')
},
{
path: 'ranking',
name: 'ranking',
component: () => import('../views/home/ranking.vue')
},
{
path: 'bookshelf',
name: 'Bookshelf',
component: () => import('../views/home/Bookshelf.vue'),
meta: { requiresAuth: true }
}
]
},
@ -38,16 +59,51 @@ const router = createRouter({
routes
});
// 创建一个事件总线用于通信
export const routerEvents = {
triggerLogin: null
};
// 全局路由守卫
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 } });
// 保存当前要访问的路由信息
const targetRoute = to.fullPath;
// 立即尝试调用登录弹窗
setTimeout(() => {
const authContext = window.$authContext;
if (authContext && typeof authContext.openLogin === 'function') {
authContext.openLogin(() => {
// 登录成功后导航到原来想要去的页面
router.push(targetRoute);
});
} else {
// 如果authContext还未挂载,则设置事件供之后触发
routerEvents.triggerLogin = () => {
const context = window.$authContext;
if (context && typeof context.openLogin === 'function') {
context.openLogin(() => {
router.push(targetRoute);
});
}
};
}
}, 0);
// 如果不是首页,则跳转到首页
if (to.name !== 'Home') {
next({ path: '/' });
} else {
// 如果已经在首页则直接渲染
next();
}
} else {
next();
}
});
export default router;
export default router;

+ 61
- 2
src/store/index.js View File

@ -4,11 +4,13 @@ export const useMainStore = defineStore('main', {
state: () => ({
user: null,
isLoggedIn: false,
token: null
token: null,
bookshelf: []
}),
getters: {
isAuthenticated: (state) => state.isLoggedIn && state.user !== null
isAuthenticated: (state) => state.isLoggedIn && state.user !== null,
bookshelfCount: (state) => state.bookshelf.length
},
actions: {
@ -68,6 +70,7 @@ export const useMainStore = defineStore('main', {
initializeAuth() {
const userData = localStorage.getItem('user');
const token = localStorage.getItem('token');
const bookshelf = localStorage.getItem('bookshelf');
if (userData && token) {
try {
@ -78,6 +81,62 @@ export const useMainStore = defineStore('main', {
this.logout();
}
}
if (bookshelf) {
try {
this.bookshelf = JSON.parse(bookshelf);
} catch (e) {
this.bookshelf = [];
localStorage.removeItem('bookshelf');
}
}
},
// 添加书籍到书架
addToBookshelf(book) {
// 检查是否已在书架中,避免重复添加
const index = this.bookshelf.findIndex(item => item.id === book.id);
if (index === -1) {
this.bookshelf.push({
...book,
addTime: new Date().toISOString(),
lastReadChapter: book.lastReadChapter || '第一章',
lastReadTime: new Date().toISOString()
});
// 保存到本地存储
localStorage.setItem('bookshelf', JSON.stringify(this.bookshelf));
return true;
}
return false;
},
// 从书架移除一本书
removeFromBookshelf(bookId) {
const index = this.bookshelf.findIndex(item => item.id === bookId);
if (index !== -1) {
this.bookshelf.splice(index, 1);
// 更新本地存储
localStorage.setItem('bookshelf', JSON.stringify(this.bookshelf));
return true;
}
return false;
},
// 清空书架
clearBookshelf() {
this.bookshelf = [];
localStorage.removeItem('bookshelf');
},
// 更新阅读进度
updateReadProgress(bookId, chapter) {
const index = this.bookshelf.findIndex(item => item.id === bookId);
if (index !== -1) {
this.bookshelf[index].lastReadChapter = chapter;
this.bookshelf[index].lastReadTime = new Date().toISOString();
// 更新本地存储
localStorage.setItem('bookshelf', JSON.stringify(this.bookshelf));
}
}
}
});

+ 387
- 536
src/views/book/index.vue View File

@ -1,257 +1,230 @@
<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 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 book-status-row">
<span class="status-badge">{{ book.status }}</span>
<span class="dot">·</span>
<span class="reading-tip">大家都在读</span>
</div>
</div>
<div class="book-user-info">
<div class="user-badges">
<img class="level-icon" src="@/assets/images/book/level.png" alt="我的等级" />
<span class="badge-label">我的等级</span>
</div>
<div class="user-medal">
<img class="medal-icon" src="@/assets/images/image-1.png" alt="勋章" />
</div>
<div class="user-avatar-block">
<img class="user-avatar" src="@/assets/images/center/headImage.png" alt="用户头像" />
<span class="user-name">小巴</span>
<span class="dot">·</span>
<span class="user-title">VIP会员</span>
</div>
<span class="user-intimacy">1000 累计亲密值</span>
</div>
<div class="action-buttons">
<div class="action-btn-group">
<el-button class="reward-btn" plain>互动打赏</el-button>
</div>
<div class="action-btn-group">
<el-button
:class="['add-to-shelf-btn', isInShelf ? 'in-shelf' : '']"
:type="isInShelf ? '' : 'primary'"
@click="toggleShelf"
>{{ isInShelf ? '已加入书架' : '加入书架' }}</el-button>
</div>
<div class="action-btn-group">
<el-button class="read-btn" style="background:#0A2463;color:#fff;border:none;">点击阅读</el-button>
</div>
</div>
</div>
</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 class="book-main-content">
<div class="main-left">
<!-- 推荐票/亲密值统计 -->
<BookStats :book-id="book.id" />
<!-- 小说介绍 -->
<BookIntro :book-data="book" />
<!-- 目录 -->
<BookCatalog :book-id="book.id" />
<!-- 评论 -->
<BookComments :book-id="book.id" />
</div>
<div class="main-right">
<!-- 读者亲密值榜单 -->
<IntimacyRanking :book-id="book.id" />
</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';
import BookStats from '@/components/book/BookStats.vue';
import BookIntro from '@/components/book/BookIntro.vue';
import BookCatalog from '@/components/book/BookCatalog.vue';
import BookComments from '@/components/book/BookComments.vue';
import IntimacyRanking from '@/components/ranking/IntimacyRanking.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>
name: 'BookDetail',
components: {
BookCard,
BookStats,
BookIntro,
BookCatalog,
BookComments,
IntimacyRanking
},
setup() {
const route = useRoute();
const router = useRouter();
const bookId = route.params.id;
const book = reactive({
id: bookId,
title: '重生之财源滚滚',
author: '老鹰的沙',
category: '都市小说',
status: '已完结',
userGroup: '638781087(网友交流群)',
cover: 'https://bookcover.yuewen.com/qdbimg/349573/1041637443/150.webp',
description: `<p>当那一世——</p>
<p>贺季宁曾经是A市土豪公司的金牌经理工资高福利好女友漂亮车位靓最重要的是老板信任他但是贺季宁的对手不服老板娘不喜欢他不怀好意的对手们联合一起终于把他拉下台了他失去工作更可悲的是他连公司配他的贷款的房子都搭进去了欠了几百万的债女友也离他而去</p>
<p>临终前他喃喃自语如果老天给我重来一次的机会我一定要好好做人赚很多钱让那帮人看着我过得好就比他们难受</p>`,
userGroup: '638781087(网友交流群)',
chapters: [
{ id: '1', title: '第一章 重回2004', isNew: false },
{ id: '2', title: '第二章 旧车旧房', isNew: false },
{ id: '3', title: '第三章 再拼一把', isNew: false },
{ id: '4', title: '第四章 带女朋友逛街', isNew: false },
{ id: '5', title: '第五章 小刺激', isNew: false },
{ id: '6', title: '第六章 老王的门道', isNew: false },
{ id: '7', title: '第七章 正中家门', isNew: false },
{ id: '8', title: '第八章 被逼迫的交易', isNew: false },
{ id: '9', title: '第九章 意外惊喜', isNew: false },
{ id: '10', title: '第十章 生意的桥梁', isNew: true },
{ id: '11', title: '第十一章 家族分崩离析', isNew: false },
{ id: '12', title: '第十二章 老张没来', isNew: false }
],
comments: [
{
username: '万年捧',
avatar: 'https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTKlR5PibUEEsVjXGfH4c1eR5hXDicoH0EJUTHYwDO3EvZLXXgON8GrNTbRg8DnzaddicibYnGcfq28tYg/132',
time: '2022-07-03',
content: '这是本年内看的唯一一部完结的!看的人真幸运,发家文和风险防控写的都是一流'
},
{
username: '残生往事',
avatar: 'https://thirdwx.qlogo.cn/mmopen/vi_32/3F4feeHnMyoGjqKfP8vGKCHwyvovMHiaO0Q1QkQMRTGibLcyJbUcUJ4LmdkkDqC5ZcqP1rvqKMviaYAyehqYb6ciaA/132',
content: '我很喜欢男主的性格,不小心眼,有格局,做事情多考虑下一步,商业和情感都处理得不错,就是那个林涵有点没必要吧?'
}
]
});
//
const recommendedBooks = reactive([
{
id: '101',
title: '三体',
author: '刘慈欣',
cover: 'https://picsum.photos/120/160?random=1',
status: '已完结'
},
{
id: '102',
title: '活着',
author: '余华',
cover: 'https://picsum.photos/120/160?random=2',
status: '已完结'
},
{
id: '103',
title: '平凡的世界',
author: '路遥',
cover: 'https://picsum.photos/120/160?random=3',
status: '已完结'
},
{
id: '104',
title: '围城',
author: '钱钟书',
cover: 'https://picsum.photos/120/160?random=4',
status: '已完结'
}
]);
const activeTab = ref('intro');
const currentPage = ref(1);
const commentText = ref('');
const handlePageChange = (page) => {
currentPage.value = page;
//
};
const goToChapter = (chapterId) => {
router.push(`/book/${bookId}/chapter/${chapterId}`);
};
const submitComment = () => {
if (!commentText.value.trim()) {
ElMessage.warning('请输入评论内容');
return;
}
// API
book.comments.unshift({
username: '当前用户',
avatar: 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png',
time: new Date().toLocaleString(),
content: commentText.value
});
commentText.value = '';
};
onMounted(() => {
// bookIdAPI
console.log('加载书籍详情,ID:', bookId);
});
return {
book,
recommendedBooks,
activeTab,
currentPage,
commentText,
handlePageChange,
goToChapter,
submitComment
};
}
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 isInShelf = ref(false);
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 = '';
};
const toggleShelf = () => {
isInShelf.value = !isInShelf.value;
};
onMounted(() => {
// bookIdAPI
console.log('加载书籍详情,ID:', bookId);
});
return {
book,
recommendedBooks,
activeTab,
currentPage,
commentText,
handlePageChange,
goToChapter,
submitComment,
isInShelf,
toggleShelf
};
}
};
</script>
@ -259,318 +232,196 @@ export default {
@use '@/assets/styles/variables.scss' as vars;
.book-detail-container {
width: 100%;
background-color: #f5f5f5;
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 {
background-color: #fff;
padding: 30px 0;
border-bottom: 1px solid #eee;
.book-info {
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;
}
}
}
}
}
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
//
.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-cover {
width: 165px;
height: 220px;
margin-right: 30px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
//
.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;
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 6px;
}
}
.comment-user {
display: flex;
align-items: center;
margin-bottom: 10px;
.user-info {
margin-left: 12px;
.username {
font-weight: bold;
color: #333;
.book-details {
flex: 1;
.book-title {
font-size: 24px;
font-weight: bold;
margin: 0 0 15px 0;
color: #333;
}
.book-meta {
margin-bottom: 8px;
.book-status-row {
display: flex;
align-items: center;
font-size: 15px;
margin-top: 10px;
.status-badge {
background: #eaffea;
color: #52c41a;
border-radius: 6px;
padding: 2px 12px;
font-size: 15px;
font-weight: 500;
margin-right: 8px;
}
.dot {
color: #d1d1d1;
margin: 0 8px;
font-size: 18px;
}
.reading-tip {
color: #bdbdbd;
font-size: 15px;
}
}
}
.comment-time {
font-size: 12px;
color: #999;
margin-top: 2px;
.book-user-info {
display: flex;
align-items: center;
margin-bottom: 18px;
.user-badges {
display: flex;
align-items: center;
margin-right: 18px;
.level-icon {
width: 36px;
height: 36px;
}
.badge-label {
color: #b88a4a;
font-size: 14px;
margin-left: 4px;
}
}
.user-medal {
margin-right: 18px;
.medal-icon {
width: 44px;
height: 44px;
}
}
.user-avatar-block {
display: flex;
align-items: center;
margin-right: 18px;
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
margin-right: 8px;
}
.user-name {
color: #222;
font-size: 16px;
font-weight: 500;
margin-right: 4px;
}
.dot {
color: #d1d1d1;
margin: 0 8px;
font-size: 18px;
}
.user-title {
background: #ede6ff;
color: #a97cff;
border-radius: 8px;
padding: 2px 10px;
font-size: 15px;
margin-left: 2px;
}
}
.user-intimacy {
color: #bdbdbd;
font-size: 15px;
}
}
.action-buttons {
display: flex;
gap: 24px;
margin-top: 10px;
.action-btn-group {
display: flex;
flex-direction: column;
align-items: center;
.el-button {
width: 140px;
height: 40px;
font-size: 16px;
border-radius: 4px;
margin: 0;
font-weight: 500;
}
.diamond-icon {
width: 28px;
height: 18px;
margin-top: 4px;
}
.reward-btn {
color: #0A2463;
border: 1.5px solid #0A2463;
background: #fff;
}
.add-to-shelf-btn {
background: #FF7C6A;
color: #fff;
border: none;
}
.add-to-shelf-btn.in-shelf {
background: #fff;
color: #FF7C6A;
border: 1.5px solid #FF7C6A;
}
.read-btn {
background: #0A2463;
color: #fff;
border: none;
}
}
}
}
}
.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);
.book-main-content {
display: flex;
gap: 3px;
margin-top: 20px;
.main-left {
flex: 1.8;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
@media screen and (max-width: vars.$sm) {
grid-template-columns: 1fr;
.main-right {
flex: 1;
min-width: 220px;
max-width: 280px;
}
}
}
</style>
</style>

+ 269
- 0
src/views/home/Bookshelf.vue View File

@ -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>

+ 304
- 0
src/views/home/category.vue View File

@ -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;
}
// IDAPI
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>

+ 376
- 0
src/views/home/ranking.vue View File

@ -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;
}
// IDAPI
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>

Loading…
Cancel
Save