feat(book-detail): 添加继续阅读按钮和界面预览图

1. 重构书籍详情页布局,新增继续阅读快速入口
2. 调整书籍简介展示逻辑,优化页面排版
3. 更新package.json版本号到0.2.0
4. 新增界面预览图片并添加到README文档
5. 优化阅读器章节标题显示逻辑
This commit is contained in:
Yuhang Wu 2026-08-24 15:46:08 +08:00
parent 3b966c5655
commit 8f8f4691c4
10 changed files with 442 additions and 66 deletions

View File

@ -38,6 +38,16 @@
- 窗口支持透明背景、置顶显示、圆角毛玻璃外观 - 窗口支持透明背景、置顶显示、圆角毛玻璃外观
- 设置通过 Pinia + 持久化插件跨会话保存 - 设置通过 Pinia + 持久化插件跨会话保存
## 界面预览
| 书架管理 | 书籍详情 |
| --- | --- |
| ![书架管理](images/1.png) | ![书籍章节详情](images/3.png) |
| 幽灵阅读模式 | 阅读设置 |
| --- | --- |
| ![幽灵阅读模式](images/4.png) | ![阅读设置](images/2.png) |
## 技术栈 ## 技术栈
| 层 | 技术 | | 层 | 技术 |

BIN
images/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
images/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
images/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
images/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

View File

@ -1,7 +1,7 @@
{ {
"name": "stealthreader", "name": "stealthreader",
"private": true, "private": true,
"version": "0.1.0", "version": "0.3.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "stealthreader", "productName": "StealthReader",
"version": "0.2.0", "version": "../package.json",
"identifier": "StealthReader", "identifier": "StealthReader",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",

View File

@ -20,10 +20,24 @@
<n-descriptions-item label="总章节">{{ book?.total_chapters ?? 0 }}</n-descriptions-item> <n-descriptions-item label="总章节">{{ book?.total_chapters ?? 0 }}</n-descriptions-item>
<n-descriptions-item label="总字数">{{ formatChars(book?.total_chars) }}</n-descriptions-item> <n-descriptions-item label="总字数">{{ formatChars(book?.total_chars) }}</n-descriptions-item>
<n-descriptions-item label="导入时间">{{ formatTime(book?.create_time) }}</n-descriptions-item> <n-descriptions-item label="导入时间">{{ formatTime(book?.create_time) }}</n-descriptions-item>
<n-descriptions-item label="上次阅读">{{ formatTime(book?.last_read_time) }}</n-descriptions-item> </n-descriptions>
<n-descriptions-item label="阅读进度">{{ readProgress }}</n-descriptions-item>
<n-descriptions-item label="简介" :span="2"> <div class="progress-row">
<n-button v-if="continueInfo" class="continue-btn" @click="continueRead">
<template #default>
<div class="continue-content">
<div class="continue-action">点击继续阅读</div>
<div class="continue-main">
<span class="continue-prefix">阅读到</span>
<span class="continue-title" :title="continueInfo.title">{{ continueTitle }}</span>
<span class="continue-percent">{{ continuePercent }}%</span>
</div>
<div class="continue-time">上次阅读 {{ formatTime(book?.last_read_time) }}</div>
</div>
</template>
</n-button>
<n-popover <n-popover
class="intro-popover-wrap"
trigger="hover" trigger="hover"
placement="bottom-start" placement="bottom-start"
:show-arrow="true" :show-arrow="true"
@ -43,8 +57,7 @@
<div class="intro-popover">{{ book?.introduction || "暂无简介" }}</div> <div class="intro-popover">{{ book?.introduction || "暂无简介" }}</div>
</n-scrollbar> </n-scrollbar>
</n-popover> </n-popover>
</n-descriptions-item> </div>
</n-descriptions>
</div> </div>
</div> </div>
</section> </section>
@ -137,6 +150,7 @@ const page = ref(1)
const limit = ref(50) const limit = ref(50)
const loadingChapters = ref(false) const loadingChapters = ref(false)
const keyword = ref("") const keyword = ref("")
const lastReadChapter = ref<Chapters | null>(null)
const goBack = () => router.back() const goBack = () => router.back()
@ -144,11 +158,30 @@ const readChapter = (ch: Chapters) => {
router.push({ name: "Reader", query: { id: ch.id } }) router.push({ name: "Reader", query: { id: ch.id } })
} }
const readProgress = computed(() => { const continueRead = () => {
const ch = lastReadChapter.value
if (ch) {
router.push({ name: "Reader", query: { id: ch.id } })
}
}
const continueInfo = computed(() => {
const b = book.value const b = book.value
if (!b || b.total_chapters <= 0) return "—" if (!b || b.last_read_chapter_id <= 0) return null
const percent = Math.round((b.last_read_chapter_id / b.total_chapters) * 100) return lastReadChapter.value
return `${b.last_read_chapter_id} 章 · ${percent}%` })
const continueTitle = computed(() => {
const t = lastReadChapter.value?.title
if (!t) return ""
return t.length > 5 ? `${t.slice(0, 5)}` : t
})
const continuePercent = computed(() => {
const b = book.value
const ch = lastReadChapter.value
if (!b || b.total_chapters <= 0 || !ch) return 0
return Math.round((ch.number / b.total_chapters) * 100)
}) })
const formatChars = (chars?: number) => { const formatChars = (chars?: number) => {
@ -168,11 +201,25 @@ const loadBook = async () => {
const res = await withLoading(() => BookApi.detail(bookId.value), "加载中...") const res = await withLoading(() => BookApi.detail(bookId.value), "加载中...")
if (res.code === 0) { if (res.code === 0) {
book.value = res.data book.value = res.data
if (res.data.last_read_chapter_id > 0) {
await loadLastReadChapter(res.data.last_read_chapter_id)
}
} else { } else {
message.error(res.msg) message.error(res.msg)
} }
} }
const loadLastReadChapter = async (chapterId: number) => {
try {
const res = await BookApi.chapterDetail(chapterId)
if (res.code === 0) {
lastReadChapter.value = res.data
}
} catch {
lastReadChapter.value = null
}
}
const loadChapters = async () => { const loadChapters = async () => {
if (!bookId.value) return if (!bookId.value) return
loadingChapters.value = true loadingChapters.value = true
@ -277,17 +324,99 @@ onMounted(() => {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.intro-popover-wrap {
flex: 1;
min-width: 0;
}
.intro-preview { .intro-preview {
box-sizing: border-box;
height: var(--progress-height);
min-width: 0;
line-height: 1.6; line-height: 1.6;
font-size: 13px; font-size: 13px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
word-break: break-all; word-break: break-all;
white-space: pre-wrap; white-space: pre-wrap;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden; overflow: hidden;
cursor: help; cursor: help;
padding: 10px 0;
border-radius: var(--radius-sm);
}
.progress-row {
--progress-height: 84px;
display: flex;
align-items: flex-start;
gap: 16px;
}
.continue-btn {
box-sizing: border-box;
flex-shrink: 0;
width: 220px;
height: var(--progress-height);
border-radius: var(--radius-card);
padding: 10px 16px;
text-align: left;
transition: box-shadow 0.2s ease, background-color 0.2s ease;
&:hover {
box-shadow: var(--shadow-card-hover);
}
:deep(.n-button__content) {
width: 100%;
height: 100%;
}
}
.continue-content {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 4px;
width: 100%;
height: 100%;
}
.continue-action {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
color: var(--color-text-primary);
}
.continue-main {
display: flex;
align-items: baseline;
gap: 6px;
font-size: 13px;
line-height: 1.4;
}
.continue-prefix {
color: var(--color-text-secondary);
}
.continue-title {
max-width: 5em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--color-text-primary);
}
.continue-percent {
font-weight: 700;
color: var(--color-accent);
}
.continue-time {
font-size: 12px;
line-height: 1.4;
color: var(--color-text-secondary);
} }
:global(.intro-popover-scrollbar .n-scrollbar-container) { :global(.intro-popover-scrollbar .n-scrollbar-container) {

View File

@ -1,7 +1,9 @@
<template> <template>
<div class="glass-panel" :style="readerStyle" data-tauri-drag-region @mousedown="onWindowMouseDown"> <div class="glass-panel" :style="readerStyle" data-tauri-drag-region @mousedown="onWindowMouseDown">
<div class="reading-wrap"> <div class="reading-wrap">
<div v-if="currentPage == 0" class="chapter-name">{{ chapter?.title || "加载中..." }}</div> <div class="chapter-name" :class="{ 'chapter-name--hidden': currentPage !== 0 }">
{{ chapter?.title || "加载中..." }}
</div>
<div ref="pageEl" class="page-body" @click="onPageClick"> <div ref="pageEl" class="page-body" @click="onPageClick">
<div class="page-text">{{ currentText }}</div> <div class="page-text">{{ currentText }}</div>
@ -252,6 +254,10 @@ onUnmounted(() => {
margin-bottom: 20px; margin-bottom: 20px;
} }
.chapter-name--hidden {
visibility: hidden;
}
.page-body { .page-body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;

View File

@ -1,18 +1,121 @@
<template> <template>
<div class="setting-page"> <div class="setting-page">
<h2 class="page-title">关于</h2> <h2 class="page-title">关于</h2>
<p class="page-desc">关于本应用的信息</p> <p class="page-desc">关于幽灵阅读器</p>
<div class="about-card"> <div class="about-hero">
<div class="app-logo"></div> <div class="about-logo">
<div class="app-name">幽灵阅读器</div> <img :src="appIcon" alt="幽灵阅读器" />
<div class="app-version">版本 0.1.0</div> </div>
<div class="app-desc">一款安静沉浸的本地阅读器</div> <div class="about-info">
<div class="about-name">幽灵阅读器</div>
<div class="about-subname">Stealth Reader</div>
<div class="about-version">版本 {{ version || "0.0.0" }}</div>
</div>
</div>
<div class="about-intro">
一款安静本地优先的桌面小说阅读器轻量纯本地无账号无广告专注好好读一本书
</div>
<div class="about-section">
<div class="section-title">核心特性</div>
<div class="feature-list">
<div v-for="f in features" :key="f.title" class="feature-item">
<span class="feature-icon" :style="{ backgroundColor: f.bg }">
<n-icon :component="f.icon" :size="16" />
</span>
<div class="feature-body">
<div class="feature-title">{{ f.title }}</div>
<div class="feature-desc">{{ f.desc }}</div>
</div>
</div>
</div>
</div>
<div class="about-section">
<div class="section-title">技术栈</div>
<div class="tech-list">
<div v-for="t in tech" :key="t.name" class="tech-item">
<span class="tech-name">{{ t.name }}</span>
<span class="tech-desc">{{ t.desc }}</span>
</div>
</div>
</div>
<div class="about-footer">
<span>© {{ year }} Stealth Reader</span>
<span>Apache-2.0 License</span>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"></script> <script setup lang="ts">
import { getVersion } from "@tauri-apps/api/app"
import {
BookOutlined,
FileTextOutlined,
FontColorsOutlined,
LockOutlined,
ScanOutlined,
ThunderboltOutlined,
} from "@vicons/antd"
import appIcon from "../../../app-icon.svg"
const year = new Date().getFullYear()
const version = ref("")
getVersion().then((v) => {
version.value = v
})
const features = [
{
title: "本地优先",
desc: "纯本地存储,数据不离开你的设备",
icon: LockOutlined,
bg: "color-mix(in srgb, #667eea 14%, transparent)",
},
{
title: "智能分章",
desc: "自动识别「第X章」「Chapter N」等章节格式",
icon: ScanOutlined,
bg: "color-mix(in srgb, #10b981 14%, transparent)",
},
{
title: "自动编码检测",
desc: "GBK / UTF-8 等中文编码自动识别",
icon: FileTextOutlined,
bg: "color-mix(in srgb, #f59e0b 14%, transparent)",
},
{
title: "沉浸阅读",
desc: "可拖拽窗口、透明背景、翻页阅读",
icon: BookOutlined,
bg: "color-mix(in srgb, #ec4899 14%, transparent)",
},
{
title: "外观自定义",
desc: "字体大小 / 颜色 / 背景随心调整",
icon: FontColorsOutlined,
bg: "color-mix(in srgb, #8b5cf6 14%, transparent)",
},
{
title: "轻量快速",
desc: "Tauri 2 构建,内存占用远低于 Electron",
icon: ThunderboltOutlined,
bg: "color-mix(in srgb, #06b6d4 14%, transparent)",
},
]
const tech = [
{ name: "桌面框架", desc: "Tauri 2 + Rust" },
{ name: "前端框架", desc: "Vue 3 + TypeScript" },
{ name: "UI 组件", desc: "Naive UI" },
{ name: "数据存储", desc: "SQLite (sqlx)" },
{ name: "编码检测", desc: "chardetng" },
]
</script>
<style lang="scss" scoped> <style lang="scss" scoped>
.setting-page { .setting-page {
@ -33,48 +136,170 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.about-card { .about-hero {
display: grid; display: flex;
grid-template-columns: 52px minmax(0, 1fr);
column-gap: 14px;
align-items: center; align-items: center;
min-height: 108px; gap: 16px;
padding: 18px 16px; padding: 20px 16px;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
.app-logo { .about-logo {
width: 52px; width: 64px;
height: 52px; height: 64px;
flex-shrink: 0;
border-radius: 16px;
overflow: hidden;
box-shadow: var(--shadow-card);
img {
width: 100%;
height: 100%;
display: block;
}
}
.about-info {
min-width: 0;
}
.about-name {
font-size: 18px;
font-weight: 700;
color: var(--color-text-primary);
}
.about-subname {
font-size: 13px;
color: var(--color-text-secondary);
margin-top: 1px;
}
.about-version {
display: inline-block;
margin-top: 6px;
padding: 1px 8px;
border-radius: 999px;
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
color: var(--color-accent);
font-size: 12px;
font-weight: 600;
}
.about-intro {
margin: 18px 0;
padding: 14px 16px;
border-radius: var(--radius-sm);
background-color: var(--color-window-bg);
font-size: 13px;
line-height: 1.7;
color: var(--color-text-secondary);
}
.about-section {
& + & {
margin-top: 20px;
}
}
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 10px;
}
.feature-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.feature-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&:hover {
border-color: var(--color-border-hover);
box-shadow: var(--shadow-card);
}
}
.feature-icon {
width: 32px;
height: 32px;
flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 8px; border-radius: 8px;
background: var(--color-accent-gradient); color: var(--color-text-primary);
color: var(--color-text-invert);
font-size: 22px;
font-weight: 700;
grid-row: span 2;
} }
.app-name { .feature-body {
font-size: 16px; min-width: 0;
}
.feature-title {
font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.app-version { .feature-desc {
font-size: 13px; margin-top: 2px;
font-size: 12px;
line-height: 1.5;
color: var(--color-text-secondary); color: var(--color-text-secondary);
margin: 2px 0 0;
} }
.app-desc { .tech-list {
grid-column: 1 / -1; overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.tech-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
min-height: 40px;
& + & {
border-top: 1px solid var(--color-border);
}
}
.tech-name {
font-size: 13px;
font-weight: 600;
color: var(--color-text-primary);
}
.tech-desc {
font-size: 13px; font-size: 13px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
margin: 12px 0 0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.about-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 24px;
padding-top: 14px;
border-top: 1px solid var(--color-border);
font-size: 12px;
color: var(--color-text-secondary);
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@ -82,8 +307,14 @@
padding: 18px 16px 24px; padding: 18px 16px 24px;
} }
.app-desc { .feature-list {
margin-left: 0; grid-template-columns: 1fr;
}
.about-footer {
flex-direction: column;
align-items: flex-start;
gap: 4px;
} }
} }
</style> </style>