feat: 优化书籍详情页与封面组件,新增默认封面与布局

1.  重构BookCover组件,移除渐变 fallback 逻辑,新增默认封面图片
2.  调整书籍详情页布局,使用Grid组件重构章节列表,优化阅读体验
3.  为简介和章节区域添加滚动容器,优化长内容展示
4.  完善返回按钮样式与页面整体排版
This commit is contained in:
Yuhang Wu 2026-08-20 17:22:18 +08:00
parent c12cbaaabc
commit a5dd0c446d
4 changed files with 84 additions and 107 deletions

BIN
src/assets/cover.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -1,19 +1,13 @@
<template> <template>
<div class="book-cover"> <div class="book-cover">
<img v-if="resolvedSrc" :src="resolvedSrc" :alt="title" /> <img :src="resolvedSrc" :alt="title" />
<div v-else class="cover-fallback" :style="{ background: coverGradient(title) }">
<span class="cover-spine"></span>
<div class="cover-meta">
<p class="cover-title">{{ title }}</p>
<span class="cover-author">{{ author || "佚名" }}</span>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue" import { computed } from "vue"
import { convertFileSrc } from "@tauri-apps/api/core" import { convertFileSrc } from "@tauri-apps/api/core"
import defaultCover from "@/assets/cover.jpg"
const props = defineProps<{ const props = defineProps<{
title: string title: string
@ -21,30 +15,13 @@ const props = defineProps<{
cover?: string cover?: string
}>() }>()
const coverGradients = [
"linear-gradient(135deg, #667eea, #764ba2)",
"linear-gradient(135deg, #f093fb, #f5576c)",
"linear-gradient(135deg, #4facfe, #00f2fe)",
"linear-gradient(135deg, #43e97b, #38f9d7)",
"linear-gradient(135deg, #fa709a, #fee140)",
"linear-gradient(135deg, #30cfd0, #330867)",
"linear-gradient(135deg, #ff9a9e, #fecfef)",
"linear-gradient(135deg, #a18cd1, #fbc2eb)",
]
const coverGradient = (title: string) => {
let hash = 0
for (let i = 0; i < title.length; i++) {
hash = (hash * 31 + title.charCodeAt(i)) >>> 0
}
return coverGradients[hash % coverGradients.length]
}
const resolvedSrc = computed(() => { const resolvedSrc = computed(() => {
const cover = props.cover const cover = props.cover
if (!cover) return "" if (cover) {
if (/^(https?:|data:|blob:|asset:)/.test(cover)) return cover if (/^(https?:|data:|blob:|asset:)/.test(cover)) return cover
return convertFileSrc(cover) return convertFileSrc(cover)
}
return defaultCover
}) })
</script> </script>
@ -63,52 +40,4 @@ const resolvedSrc = computed(() => {
display: block; display: block;
} }
} }
.cover-fallback {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 16px 12px;
color: #fff;
text-align: center;
}
.cover-spine {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 8px;
background: rgba(0, 0, 0, 0.18);
}
.cover-meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.cover-title {
font-size: 14px;
font-weight: 700;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
.cover-author {
font-size: 12px;
opacity: 0.85;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style> </style>

View File

@ -24,6 +24,8 @@ declare module 'vue' {
NEmpty: typeof import('naive-ui')['NEmpty'] NEmpty: typeof import('naive-ui')['NEmpty']
NForm: typeof import('naive-ui')['NForm'] NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem'] NFormItem: typeof import('naive-ui')['NFormItem']
NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid']
NIcon: typeof import('naive-ui')['NIcon'] NIcon: typeof import('naive-ui')['NIcon']
NInput: typeof import('naive-ui')['NInput'] NInput: typeof import('naive-ui')['NInput']
NLayout: typeof import('naive-ui')['NLayout'] NLayout: typeof import('naive-ui')['NLayout']

View File

@ -1,14 +1,13 @@
<template> <template>
<div class="book-detail"> <div class="book-detail">
<section class="detail-header"> <n-button class="back-btn" quaternary circle aria-label="返回" @click="goBack">
<div class="header-toolbar"> <template #icon>
<n-button quaternary circle aria-label="返回" @click="goBack"> <n-icon :component="ArrowLeftOutlined" />
<template #icon> </template>
<n-icon :component="ArrowLeftOutlined" /> </n-button>
</template>
</n-button>
</div>
<n-scrollbar class="page-scrollbar">
<section class="detail-header">
<div class="book-summary"> <div class="book-summary">
<div class="summary-cover"> <div class="summary-cover">
<BookCover :title="book?.title ?? ''" :author="book?.author" :cover="book?.cover" /> <BookCover :title="book?.title ?? ''" :author="book?.author" :cover="book?.cover" />
@ -25,7 +24,9 @@
<n-descriptions-item label="上次阅读">{{ formatTime(book?.last_read_time) }}</n-descriptions-item> <n-descriptions-item label="上次阅读">{{ formatTime(book?.last_read_time) }}</n-descriptions-item>
<n-descriptions-item label="阅读进度">{{ readProgress }}</n-descriptions-item> <n-descriptions-item label="阅读进度">{{ readProgress }}</n-descriptions-item>
<n-descriptions-item label="简介" :span="2"> <n-descriptions-item label="简介" :span="2">
<div class="intro-content">{{ book?.introduction || "暂无简介" }}</div> <n-scrollbar class="intro-scrollbar">
<div class="intro-content">{{ book?.introduction || "暂无简介" }}</div>
</n-scrollbar>
</n-descriptions-item> </n-descriptions-item>
</n-descriptions> </n-descriptions>
</div> </div>
@ -42,15 +43,22 @@
<n-scrollbar class="chapter-scrollbar"> <n-scrollbar class="chapter-scrollbar">
<n-spin :show="loadingChapters"> <n-spin :show="loadingChapters">
<n-list hoverable class="chapter-list"> <n-grid
<n-list-item v-for="ch in chapterList" :key="ch.id"> v-if="chapterList.length > 0"
class="chapter-grid"
:cols="2"
:x-gap="16"
:y-gap="6"
responsive="screen"
>
<n-gi v-for="ch in chapterList" :key="ch.id">
<div class="chapter-item"> <div class="chapter-item">
<span class="chapter-index">{{ ch.number }}</span> <span class="chapter-index">{{ ch.number }}</span>
<span class="chapter-title">{{ ch.title }}</span> <span class="chapter-title" :title="ch.title">{{ ch.title }}</span>
<span class="chapter-chars">{{ ch.total_chars }} </span> <span class="chapter-chars">{{ ch.total_chars }} </span>
</div> </div>
</n-list-item> </n-gi>
</n-list> </n-grid>
<n-empty <n-empty
v-if="!loadingChapters && chapterList.length === 0" v-if="!loadingChapters && chapterList.length === 0"
class="chapter-empty" class="chapter-empty"
@ -72,6 +80,7 @@
/> />
</div> </div>
</section> </section>
</n-scrollbar>
</div> </div>
</template> </template>
@ -164,25 +173,47 @@ onMounted(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.book-detail { .book-detail {
height: 90%; position: relative;
height: 93%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
padding: 20px 24px; padding: 20px 24px;
} }
.back-btn {
position: absolute;
top: 8px;
left: 24px;
z-index: 10;
background-color: var(--color-surface);
box-shadow: var(--shadow-card);
transition: box-shadow 0.2s ease, background-color 0.2s ease;
&:hover {
box-shadow: var(--shadow-card-hover);
}
}
.page-scrollbar {
flex: 1;
min-height: 0;
:deep(.n-scrollbar-container) {
height: 100%;
}
:deep(.n-scrollbar-content) {
min-height: 100%;
}
}
.detail-header { .detail-header {
flex-shrink: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
.header-toolbar {
display: flex;
align-items: center;
}
.book-summary { .book-summary {
display: flex; display: flex;
gap: 20px; gap: 20px;
@ -216,9 +247,19 @@ onMounted(() => {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.intro-scrollbar {
height: 120px;
:deep(.n-scrollbar-container) {
height: 100%;
}
:deep(.n-scrollbar-content) {
padding-right: 8px;
}
}
.intro-content { .intro-content {
max-height: 120px;
overflow-y: auto;
line-height: 1.6; line-height: 1.6;
font-size: 13px; font-size: 13px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
@ -231,10 +272,9 @@ onMounted(() => {
} }
.chapter-section { .chapter-section {
flex: 1;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
} }
.chapter-header { .chapter-header {
@ -257,8 +297,7 @@ onMounted(() => {
} }
.chapter-scrollbar { .chapter-scrollbar {
flex: 1; max-height: 420px;
min-height: 0;
:deep(.n-scrollbar-container) { :deep(.n-scrollbar-container) {
height: 100%; height: 100%;
@ -272,13 +311,20 @@ onMounted(() => {
.chapter-item { .chapter-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
width: 100%; width: 100%;
padding: 8px 10px;
border-radius: var(--radius-sm);
transition: background-color 0.2s ease;
&:hover {
background-color: var(--color-surface-hover);
}
} }
.chapter-index { .chapter-index {
flex-shrink: 0; flex-shrink: 0;
min-width: 40px; min-width: 32px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 13px; font-size: 13px;
} }