Compare commits

..

2 Commits

Author SHA1 Message Date
Yuhang Wu 8770cd7337 v0.1.0 — MVP
 功能亮点
书籍导入与解析

本地 TXT 文件导入(系统文件选择对话框)
自动检测 GBK / UTF-8 等常见中文编码
自动分章:识别「第 X 章」「Chapter N」「楔子/序章/前言/引子/后记/尾声/番外」等格式,无法识别时整书作为单章导入
自动统计章节数与总字数
书架管理

封面网格展示(本地封面 / 默认封面)
按书名模糊搜索
右键菜单编辑与删除(二次确认)
空状态一键导入引导
书籍详情

作者、章节数、总字数、导入时间、上次阅读、阅读进度展示
简介悬停浮层,超长简介可滚动
章节列表分页展示(20 / 50 / 100 每页)
分页阅读

点击页面左右半屏翻页;支持 ↑↓、PgUp/PgDn、空格、←→ 键盘操作
上一章 / 下一章切换与边界提示
阅读外观实时调整:字体大小、字体颜色、背景颜色与透明度、窗口大小
阅读窗口可拖拽,支持窗口阴影开关
设置中心

外观(窗口阴影)、阅读设置(字体/颜色/背景/窗口)、快捷键说明、关于
设置通过 Pinia 持久化,跨会话保存
界面与体验

自定义标题栏(Windows / macOS),仅关闭与最小化
全局加载状态、圆角玻璃质感界面、窗口置顶
📦 技术栈
Tauri 2 + Rust(sqlx + SQLite、chardetng 编码检测)
Vue 3 + TypeScript + Vite
Naive UI + @vicons 图标
Pinia(持久化)
🧩 支持的平台
Windows / macOS(构建产物为本地安装包)
🗓️ 规划中
老板键(全局快捷键显隐)、系统托盘
阅读进度自动保存与续读
EPUB 解析、主题切换实际应用
幽灵模式(鼠标穿透)、多设备同步、TTS
⚠️ 免责声明
本项目仅支持本地文件导入,不内置任何在线书源或下载功能,请使用您拥有合法权利的书籍内容。
2026-08-21 14:20:24 +08:00
Yuhang Wu 6cc048c7b9 refactor: 优化书籍详情页布局与弹窗展示
1. 调整窗口默认不可缩放并固定初始尺寸
2. 移除全局组件类型声明自动导入
3. 重构书籍简介展示:替换原生滚动为悬浮弹窗
4. 调整页面内边距与布局结构适配新设计
2026-08-21 13:56:17 +08:00
6 changed files with 173 additions and 109 deletions

119
README.md
View File

@ -1,43 +1,53 @@
# 幽灵阅读器 · Stealth Reader # 幽灵阅读器 · Stealth Reader
一款永远浮在屏幕角落、能一键隐身、看起来像系统通知的本地小说阅读器 一款安静、本地优先的桌面小说阅读器。轻量、纯本地、无账号、无广告,专注「好好读一本书」
核心不是「读小说」,而是「安全地读小说」——解决职场场景下「想摸鱼但怕被发现」的焦虑。 ## 已实现功能
## 定位 ### 书籍导入与解析
- **本地 TXT 导入**:通过系统文件选择对话框导入,无需任何在线源
- **编码自动检测**:基于 `chardetng` 自动识别 GBK / UTF-8 等常见中文编码
- **自动分章**:正则识别「第 X 章」「Chapter N」「楔子 / 序章 / 前言 / 引子 / 后记 / 尾声 / 番外」等格式;无法识别时整本书作为单章「正文」导入
- **元数据统计**:自动统计章节数与总字数
桌面端本地小说阅读器,核心卖点是「职场隐身模式」。 ### 书架管理
- 封面网格展示(本地封面文件 / 默认封面)
- 按书名模糊搜索
- 右键菜单编辑(书名 / 作者 / 简介)与删除(二次确认)
- 空状态引导一键导入
- 目标平台Windows / macOS / Linux基于 Tauri 跨平台) ### 书籍详情
- 核心价值:轻量 + 本地 + 隐蔽 - 封面 + 作者 / 章节数 / 总字数 / 导入时间 / 上次阅读时间 / 阅读进度
- 简介悬停浮层(超长简介内可滚动)
- 章节列表分页展示(每页 20 / 50 / 100 可选)
## 特性 ### 分页阅读
- 点击页面左右半屏翻页,或使用键盘(`↑↓` / `PgUp` / `PgDn` / 空格 / `←→`
- 上一章 / 下一章快速切换,边界提示
- 阅读外观实时生效:字体大小、字体颜色、背景颜色与透明度、窗口大小
- 阅读窗口可拖拽,支持窗口阴影开关
- **本地导入**:支持 TXT 导入,自动检测 GBK / UTF-8 编码 ### 设置中心
- **自动分章**:正则识别「第 X 章」等常见格式,自动拆分章节 - **外观**:窗口阴影开关(主题模式选项已预留)
- **分页阅读**:按键翻页(↑↓ / PgUp / PgDn无滚动条像读实体书 - **阅读设置**:字体大小、字体颜色、背景颜色与透明度、阅读窗口大小、窗口阴影
- **阅读进度持久化**:自动保存进度,下次打开继续读 - **快捷键**:按键说明参考
- **老板键**:系统级全局快捷键,一键瞬间隐身,任务栏无图标 - **关于**:版本信息
- **幽灵模式**:窗口透明 + 鼠标穿透 + 置顶,真正融入桌面
- **系统托盘**:支持伪装图标,静默驻留后台
- **零打扰**:无账号、无广告、无在线同步,纯本地
## 四种预设模式 ### 窗口与界面
- 自定义标题栏Windows / macOS仅保留关闭与最小化按钮标题栏区域可拖拽窗口
| 模式 | 尺寸 | 透明度 | 鼠标穿透 | 交互 | 适用场景 | - 窗口支持透明背景、置顶显示、圆角毛玻璃外观
|------|------|--------|---------|------|---------| - 设置通过 Pinia + 持久化插件跨会话保存
| 专注模式 | 600×400 | 100% | 关闭 | 完整交互 | 午休 / 独处 |
| 幽灵模式 | 300×150 | 30% | 开启 | 无(纯看) | 边工作边偷看 |
| 隐身模式 | 隐藏 | 0% | - | 无 | 老板来了 |
| 迷你模式 | 200×100 | 50% | 可选 | 仅翻页 | 会议中 |
## 技术栈 ## 技术栈
- [Tauri 2](https://tauri.app/) — 跨平台桌面壳与窗口级 API | 层 | 技术 |
- [Vue 3](https://vuejs.org/) + TypeScript + Vite |----|------|
- [Naive UI](https://www.naiveui.com/) — 组件库 | 桌面壳 | [Tauri 2](https://tauri.app/) |
- [Pinia](https://pinia.vuejs.org/) — 状态管理persistedstate 持久化) | 前端 | [Vue 3](https://vuejs.org/) + TypeScript + [Vite](https://vite.dev/) |
- Rust — 后端能力(编码检测、全局快捷键、系统托盘等) | 组件库 | [Naive UI](https://www.naiveui.com/) + @vicons 图标 |
| 状态 | [Pinia](https://pinia.vuejs.org/) + `pinia-plugin-persistedstate` |
| 后端 | Rust`sqlx` + SQLite、`chardetng`(编码检测)、`regex`(分章) |
| 插件 | `tauri-plugin-dialog`(文件选择)、`tauri-plugin-store`、`tauri-plugin-opener` |
## 快速开始 ## 快速开始
@ -62,37 +72,34 @@ pnpm dev # 启动 Vite 开发服务器http://localhost:1420
pnpm build # 类型检查 + 构建前端产物 pnpm build # 类型检查 + 构建前端产物
``` ```
## 项目结构
```
src/
├── api/ # Tauri invoke 封装(书籍 / 章节)
├── components/ # BookCover、GlobalLoading
├── hooks/ # useLoading
├── layout/ # 主布局与自定义标题栏Windows / macOS
├── stores/ # Piniasetting 持久化)
├── types/ # 全局类型定义
└── views/ # Home / BookDetail / Reader / Setting 等页面
src-tauri/
└── src/
├── commands/ # Tauri 命令book_*、chapter_*
├── services/ # SQLite 初始化、TXT 解析
└── models/ # 数据模型
```
## 路线图 ## 路线图
### v0.1.0MVP - 老板键(系统级全局快捷键,一键显隐)
- 系统托盘与伪装图标
- TXT 导入 + 编码自动检测 - 阅读进度自动保存与续读
- 书架列表 + 删除 - EPUB 解析支持
- 分页阅读 + 字体 / 行距设置 - 主题切换实际应用(浅色 / 深色 / 跟随系统)
- 老板键(显隐) - 幽灵模式(鼠标穿透 + 极小悬浮窗)
- 窗口置顶 + 透明度调节
- 阅读进度保存
### v1.0.0
- 幽灵模式(鼠标穿透 + 悬停切换)
- 系统托盘 + 伪装图标
- 设置面板
- 窗口尺寸记忆
- 安装包Windows .msi / macOS .dmg
### v1.1.0
- EPUB 导入支持
- 章节手动跳转
- 阅读时长统计
- 主题切换IDE 伪装主题)
### v2.0(未来)
- 多设备进度同步WebDAV / 局域网) - 多设备进度同步WebDAV / 局域网)
- 语音朗读TTS - 语音朗读TTS、书摘 / 笔记
- 书摘 / 笔记
## 免责声明 ## 免责声明

View File

@ -17,7 +17,7 @@
"width": 800, "width": 800,
"height": 600, "height": 600,
"transparent": true, "transparent": true,
"resizable": true, "resizable": false,
"decorations": false, "decorations": false,
"alwaysOnTop": false, "alwaysOnTop": false,
"skipTaskbar": false, "skipTaskbar": false,

View File

@ -38,6 +38,7 @@ declare module 'vue' {
NModal: typeof import('naive-ui')['NModal'] NModal: typeof import('naive-ui')['NModal']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider'] NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NPagination: typeof import('naive-ui')['NPagination'] NPagination: typeof import('naive-ui')['NPagination']
NPopover: typeof import('naive-ui')['NPopover']
NRadioButton: typeof import('naive-ui')['NRadioButton'] NRadioButton: typeof import('naive-ui')['NRadioButton']
NRadioGroup: typeof import('naive-ui')['NRadioGroup'] NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NScrollbar: typeof import('naive-ui')['NScrollbar'] NScrollbar: typeof import('naive-ui')['NScrollbar']

View File

@ -6,7 +6,6 @@
</template> </template>
</n-button> </n-button>
<n-scrollbar class="page-scrollbar">
<section class="detail-header"> <section class="detail-header">
<div class="book-summary"> <div class="book-summary">
<div class="summary-cover"> <div class="summary-cover">
@ -24,9 +23,26 @@
<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">
<n-scrollbar class="intro-scrollbar"> <n-popover
<div class="intro-content">{{ book?.introduction || "暂无简介" }}</div> trigger="hover"
</n-scrollbar> placement="bottom-start"
:show-arrow="true"
content-class="intro-popover-panel"
:content-style="{
width: '520px',
maxWidth: 'calc(100vw - 64px)',
maxHeight: '180px',
padding: '0',
overflow: 'hidden',
}"
>
<template #trigger>
<div class="intro-preview">{{ book?.introduction || "暂无简介" }}</div>
</template>
<n-scrollbar class="intro-popover-scrollbar" style="height: 180px">
<div class="intro-popover">{{ book?.introduction || "暂无简介" }}</div>
</n-scrollbar>
</n-popover>
</n-descriptions-item> </n-descriptions-item>
</n-descriptions> </n-descriptions>
</div> </div>
@ -80,11 +96,11 @@
/> />
</div> </div>
</section> </section>
</n-scrollbar>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getCurrentWindow, LogicalSize } from '@tauri-apps/api/window'
import { ArrowLeftOutlined } from "@vicons/antd" import { ArrowLeftOutlined } from "@vicons/antd"
import dayjs from "dayjs" import dayjs from "dayjs"
import BookApi from "@/api/book" import BookApi from "@/api/book"
@ -95,6 +111,7 @@ import type { Books, Chapters } from "@/types/global"
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const message = useMessage() const message = useMessage()
const appWindow = getCurrentWindow()
const { withLoading } = useLoading() const { withLoading } = useLoading()
const bookId = computed(() => Number(route.query.id) || 0) const bookId = computed(() => Number(route.query.id) || 0)
@ -180,17 +197,19 @@ const onPageSizeChange = (size: number) => {
onMounted(() => { onMounted(() => {
loadBook() loadBook()
loadChapters() loadChapters()
appWindow.setSize(new LogicalSize(800, 600))
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.book-detail { .book-detail {
position: relative; position: relative;
height: 93%; box-sizing: border-box;
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
padding: 20px 24px; padding: 18px 24px 16px;
} }
.back-btn { .back-btn {
@ -207,20 +226,8 @@ onMounted(() => {
} }
} }
.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;
@ -259,31 +266,40 @@ onMounted(() => {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.intro-scrollbar { .intro-preview {
height: 120px;
:deep(.n-scrollbar-container) {
height: 100%;
}
:deep(.n-scrollbar-content) {
padding-right: 8px;
}
}
.intro-content {
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;
cursor: help;
}
:global(.intro-popover-scrollbar .n-scrollbar-container) {
height: 100%;
}
:global(.intro-popover) {
box-sizing: border-box;
padding: 12px 8px 12px 12px;
color: var(--color-text-primary);
font-size: 13px;
line-height: 1.7;
word-break: break-all;
white-space: pre-wrap;
} }
.detail-divider { .detail-divider {
margin: 16px 0; flex-shrink: 0;
margin: 14px 0;
} }
.chapter-section { .chapter-section {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
@ -309,7 +325,8 @@ onMounted(() => {
} }
.chapter-scrollbar { .chapter-scrollbar {
max-height: 420px; flex: 1;
min-height: 0;
:deep(.n-scrollbar-container) { :deep(.n-scrollbar-container) {
height: 100%; height: 100%;

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="glass-panel" :style="readerStyle" data-tauri-drag-region> <div class="glass-panel" :style="readerStyle" data-tauri-drag-region @mousedown="onWindowMouseDown">
<div class="reading-wrap"> <div class="reading-wrap">
<div class="chapter-name">{{ chapter?.title || "加载中..." }}</div> <div v-if="currentPage == 0" class="chapter-name">{{ 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>
@ -9,19 +9,19 @@
<div class="page-nav"> <div class="page-nav">
<span class="page-info">{{ pageInfo }}</span> <span class="page-info">{{ pageInfo }}</span>
<n-space justify="space-between" class="nav-btns"> <div class="nav-btns">
<n-space> <n-space class="nav-group nav-group--left" :size="0">
<n-button size="small" text @click="switchChapter(-1)">上一章</n-button> <n-button size="small" text @click="switchChapter(-1)">上一章</n-button>
<n-button size="small" text :disabled="currentPage <= 0" @click="prevPage">上一页</n-button> <n-button size="small" text :disabled="currentPage <= 0" @click="prevPage">上一页</n-button>
</n-space> </n-space>
<n-button size="small" text @click="goBack">返回</n-button> <n-button class="nav-back" size="small" text @click="goBack">返回</n-button>
<n-space> <n-space class="nav-group nav-group--right" :size="0">
<n-button size="small" text :disabled="currentPage >= pages.length - 1" @click="nextPage"> <n-button size="small" text :disabled="currentPage >= pages.length - 1" @click="nextPage">
下一页 下一页
</n-button> </n-button>
<n-button size="small" text @click="switchChapter(1)">下一章</n-button> <n-button size="small" text @click="switchChapter(1)">下一章</n-button>
</n-space> </n-space>
</n-space> </div>
</div> </div>
</div> </div>
</div> </div>
@ -126,10 +126,23 @@ const onPageClick = (e: MouseEvent) => {
else nextPage() else nextPage()
} }
const onWindowMouseDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null
if (target?.closest("button, a, input, textarea, select, [role='button']")) return
void appWindow.startDragging()
}
const onKeydown = (e: KeyboardEvent) => { const onKeydown = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft" || e.key === "PageUp") { if (e.key === "ArrowLeft") {
e.preventDefault()
switchChapter(-1)
} else if (e.key === "ArrowRight") {
e.preventDefault()
switchChapter(1)
} else if (e.key === "ArrowUp" || e.key === "PageUp") {
e.preventDefault()
prevPage() prevPage()
} else if (e.key === "ArrowRight" || e.key === "PageDown" || e.key === " ") { } else if (e.key === "ArrowDown" || e.key === "PageDown" || e.key === " ") {
e.preventDefault() e.preventDefault()
nextPage() nextPage()
} }
@ -192,6 +205,7 @@ onUnmounted(() => {
<style scoped> <style scoped>
.glass-panel { .glass-panel {
position: relative; position: relative;
box-sizing: border-box;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background: var(--reader-background-color); background: var(--reader-background-color);
@ -232,7 +246,7 @@ onUnmounted(() => {
.chapter-name { .chapter-name {
flex-shrink: 0; flex-shrink: 0;
text-align: center; text-align: center;
font-size: 22px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: var(--reader-font-color); color: var(--reader-font-color);
margin-bottom: 20px; margin-bottom: 20px;
@ -260,8 +274,9 @@ onUnmounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 4px;
margin-top: 16px; margin-top: 8px;
padding-bottom: 2px;
} }
.page-info { .page-info {
@ -275,6 +290,30 @@ onUnmounted(() => {
} }
.nav-btns { .nav-btns {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 2px;
width: 100%; width: 100%;
} }
.nav-group {
min-width: 0;
overflow: hidden;
}
.nav-group--right {
justify-content: flex-end;
}
.nav-btns :deep(.n-button) {
min-width: 0;
padding: 0 3px;
font-size: 12px;
white-space: nowrap;
}
.nav-back {
padding: 0 4px;
}
</style> </style>

View File

@ -9,7 +9,7 @@
<span class="shortcut-keys"> <span class="shortcut-keys">
<template v-for="(key, i) in item.keys" :key="i"> <template v-for="(key, i) in item.keys" :key="i">
<kbd class="shortcut-key">{{ key }}</kbd> <kbd class="shortcut-key">{{ key }}</kbd>
<span v-if="i < item.keys.length - 1" class="shortcut-sep">+</span> <span v-if="i < item.keys.length - 1" class="shortcut-sep">/</span>
</template> </template>
</span> </span>
</div> </div>
@ -19,10 +19,10 @@
<script setup lang="ts"> <script setup lang="ts">
const shortcuts = [ const shortcuts = [
{ action: "上一页", keys: ["←"] }, { action: "上一页", keys: ["←", "PageUp"] },
{ action: "下一页", keys: ["→"] }, { action: "下一页", keys: ["→", "PageDown"] },
{ action: "打开搜索", keys: ["Ctrl", "F"] }, { action: "上一章", keys: ["←"] },
{ action: "打开设置", keys: ["Ctrl", ","] }, { action: "下一章", keys: ["→"] },
] ]
</script> </script>