feat: 优化书籍详情页与封面组件,新增默认封面与布局
1. 重构BookCover组件,移除渐变 fallback 逻辑,新增默认封面图片 2. 调整书籍详情页布局,使用Grid组件重构章节列表,优化阅读体验 3. 为简介和章节区域添加滚动容器,优化长内容展示 4. 完善返回按钮样式与页面整体排版
This commit is contained in:
parent
c12cbaaabc
commit
a5dd0c446d
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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']
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue