Compare commits

..

No commits in common. "8770cd733745d2598541d8f80c438fd09e650a4f" and "72728cf7acd614025446c719090d38c7971906bf" have entirely different histories.

6 changed files with 109 additions and 173 deletions

119
README.md
View File

@ -1,53 +1,43 @@
# 幽灵阅读器 · Stealth Reader
一款安静、本地优先的桌面小说阅读器。轻量、纯本地、无账号、无广告,专注「好好读一本书」
一款永远浮在屏幕角落、能一键隐身、看起来像系统通知的本地小说阅读器
## 已实现功能
核心不是「读小说」,而是「安全地读小说」——解决职场场景下「想摸鱼但怕被发现」的焦虑。
### 书籍导入与解析
- **本地 TXT 导入**:通过系统文件选择对话框导入,无需任何在线源
- **编码自动检测**:基于 `chardetng` 自动识别 GBK / UTF-8 等常见中文编码
- **自动分章**:正则识别「第 X 章」「Chapter N」「楔子 / 序章 / 前言 / 引子 / 后记 / 尾声 / 番外」等格式;无法识别时整本书作为单章「正文」导入
- **元数据统计**:自动统计章节数与总字数
## 定位
### 书架管理
- 封面网格展示(本地封面文件 / 默认封面)
- 按书名模糊搜索
- 右键菜单编辑(书名 / 作者 / 简介)与删除(二次确认)
- 空状态引导一键导入
桌面端本地小说阅读器,核心卖点是「职场隐身模式」。
### 书籍详情
- 封面 + 作者 / 章节数 / 总字数 / 导入时间 / 上次阅读时间 / 阅读进度
- 简介悬停浮层(超长简介内可滚动)
- 章节列表分页展示(每页 20 / 50 / 100 可选)
- 目标平台Windows / macOS / Linux基于 Tauri 跨平台)
- 核心价值:轻量 + 本地 + 隐蔽
### 分页阅读
- 点击页面左右半屏翻页,或使用键盘(`↑↓` / `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/) |
| 前端 | [Vue 3](https://vuejs.org/) + TypeScript + [Vite](https://vite.dev/) |
| 组件库 | [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` |
- [Tauri 2](https://tauri.app/) — 跨平台桌面壳与窗口级 API
- [Vue 3](https://vuejs.org/) + TypeScript + Vite
- [Naive UI](https://www.naiveui.com/) — 组件库
- [Pinia](https://pinia.vuejs.org/) — 状态管理persistedstate 持久化)
- Rust — 后端能力(编码检测、全局快捷键、系统托盘等)
## 快速开始
@ -72,34 +62,37 @@ pnpm dev # 启动 Vite 开发服务器http://localhost:1420
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/ # 数据模型
```
## 路线图
- 老板键(系统级全局快捷键,一键显隐)
- 系统托盘与伪装图标
- 阅读进度自动保存与续读
- EPUB 解析支持
- 主题切换实际应用(浅色 / 深色 / 跟随系统)
- 幽灵模式(鼠标穿透 + 极小悬浮窗)
### v0.1.0MVP
- TXT 导入 + 编码自动检测
- 书架列表 + 删除
- 分页阅读 + 字体 / 行距设置
- 老板键(显隐)
- 窗口置顶 + 透明度调节
- 阅读进度保存
### v1.0.0
- 幽灵模式(鼠标穿透 + 悬停切换)
- 系统托盘 + 伪装图标
- 设置面板
- 窗口尺寸记忆
- 安装包Windows .msi / macOS .dmg
### v1.1.0
- EPUB 导入支持
- 章节手动跳转
- 阅读时长统计
- 主题切换IDE 伪装主题)
### v2.0(未来)
- 多设备进度同步WebDAV / 局域网)
- 语音朗读TTS、书摘 / 笔记
- 语音朗读TTS
- 书摘 / 笔记
## 免责声明

View File

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

View File

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

View File

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

View File

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

View File

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