Compare commits
2 Commits
72728cf7ac
...
8770cd7337
| Author | SHA1 | Date |
|---|---|---|
|
|
8770cd7337 | |
|
|
6cc048c7b9 |
119
README.md
119
README.md
|
|
@ -1,43 +1,53 @@
|
|||
# 幽灵阅读器 · 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),无滚动条,像读实体书
|
||||
- **阅读进度持久化**:自动保存进度,下次打开继续读
|
||||
- **老板键**:系统级全局快捷键,一键瞬间隐身,任务栏无图标
|
||||
- **幽灵模式**:窗口透明 + 鼠标穿透 + 置顶,真正融入桌面
|
||||
- **系统托盘**:支持伪装图标,静默驻留后台
|
||||
- **零打扰**:无账号、无广告、无在线同步,纯本地
|
||||
### 设置中心
|
||||
- **外观**:窗口阴影开关(主题模式选项已预留)
|
||||
- **阅读设置**:字体大小、字体颜色、背景颜色与透明度、阅读窗口大小、窗口阴影
|
||||
- **快捷键**:按键说明参考
|
||||
- **关于**:版本信息
|
||||
|
||||
## 四种预设模式
|
||||
|
||||
| 模式 | 尺寸 | 透明度 | 鼠标穿透 | 交互 | 适用场景 |
|
||||
|------|------|--------|---------|------|---------|
|
||||
| 专注模式 | 600×400 | 100% | 关闭 | 完整交互 | 午休 / 独处 |
|
||||
| 幽灵模式 | 300×150 | 30% | 开启 | 无(纯看) | 边工作边偷看 |
|
||||
| 隐身模式 | 隐藏 | 0% | - | 无 | 老板来了 |
|
||||
| 迷你模式 | 200×100 | 50% | 可选 | 仅翻页 | 会议中 |
|
||||
### 窗口与界面
|
||||
- 自定义标题栏(Windows / macOS):仅保留关闭与最小化按钮,标题栏区域可拖拽窗口
|
||||
- 窗口支持透明背景、置顶显示、圆角毛玻璃外观
|
||||
- 设置通过 Pinia + 持久化插件跨会话保存
|
||||
|
||||
## 技术栈
|
||||
|
||||
- [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 — 后端能力(编码检测、全局快捷键、系统托盘等)
|
||||
| 层 | 技术 |
|
||||
|----|------|
|
||||
| 桌面壳 | [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` |
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
|
@ -62,37 +72,34 @@ pnpm dev # 启动 Vite 开发服务器(http://localhost:1420)
|
|||
pnpm build # 类型检查 + 构建前端产物
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # Tauri invoke 封装(书籍 / 章节)
|
||||
├── components/ # BookCover、GlobalLoading
|
||||
├── hooks/ # useLoading
|
||||
├── layout/ # 主布局与自定义标题栏(Windows / macOS)
|
||||
├── stores/ # Pinia(setting 持久化)
|
||||
├── types/ # 全局类型定义
|
||||
└── views/ # Home / BookDetail / Reader / Setting 等页面
|
||||
src-tauri/
|
||||
└── src/
|
||||
├── commands/ # Tauri 命令(book_*、chapter_*)
|
||||
├── services/ # SQLite 初始化、TXT 解析
|
||||
└── models/ # 数据模型
|
||||
```
|
||||
|
||||
## 路线图
|
||||
|
||||
### v0.1.0(MVP)
|
||||
|
||||
- TXT 导入 + 编码自动检测
|
||||
- 书架列表 + 删除
|
||||
- 分页阅读 + 字体 / 行距设置
|
||||
- 老板键(显隐)
|
||||
- 窗口置顶 + 透明度调节
|
||||
- 阅读进度保存
|
||||
|
||||
### v1.0.0
|
||||
|
||||
- 幽灵模式(鼠标穿透 + 悬停切换)
|
||||
- 系统托盘 + 伪装图标
|
||||
- 设置面板
|
||||
- 窗口尺寸记忆
|
||||
- 安装包(Windows .msi / macOS .dmg)
|
||||
|
||||
### v1.1.0
|
||||
|
||||
- EPUB 导入支持
|
||||
- 章节手动跳转
|
||||
- 阅读时长统计
|
||||
- 主题切换(IDE 伪装主题)
|
||||
|
||||
### v2.0(未来)
|
||||
|
||||
- 老板键(系统级全局快捷键,一键显隐)
|
||||
- 系统托盘与伪装图标
|
||||
- 阅读进度自动保存与续读
|
||||
- EPUB 解析支持
|
||||
- 主题切换实际应用(浅色 / 深色 / 跟随系统)
|
||||
- 幽灵模式(鼠标穿透 + 极小悬浮窗)
|
||||
- 多设备进度同步(WebDAV / 局域网)
|
||||
- 语音朗读(TTS)
|
||||
- 书摘 / 笔记
|
||||
- 语音朗读(TTS)、书摘 / 笔记
|
||||
|
||||
## 免责声明
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
"width": 800,
|
||||
"height": 600,
|
||||
"transparent": true,
|
||||
"resizable": true,
|
||||
"resizable": false,
|
||||
"decorations": false,
|
||||
"alwaysOnTop": false,
|
||||
"skipTaskbar": false,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ 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']
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
</template>
|
||||
</n-button>
|
||||
|
||||
<n-scrollbar class="page-scrollbar">
|
||||
<section class="detail-header">
|
||||
<div class="book-summary">
|
||||
<div class="summary-cover">
|
||||
|
|
@ -24,9 +23,26 @@
|
|||
<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-scrollbar class="intro-scrollbar">
|
||||
<div class="intro-content">{{ book?.introduction || "暂无简介" }}</div>
|
||||
<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-descriptions-item>
|
||||
</n-descriptions>
|
||||
</div>
|
||||
|
|
@ -80,11 +96,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"
|
||||
|
|
@ -95,6 +111,7 @@ 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)
|
||||
|
|
@ -180,17 +197,19 @@ const onPageSizeChange = (size: number) => {
|
|||
onMounted(() => {
|
||||
loadBook()
|
||||
loadChapters()
|
||||
appWindow.setSize(new LogicalSize(800, 600))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.book-detail {
|
||||
position: relative;
|
||||
height: 93%;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 20px 24px;
|
||||
padding: 18px 24px 16px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
|
@ -259,31 +266,40 @@ onMounted(() => {
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.intro-scrollbar {
|
||||
height: 120px;
|
||||
|
||||
:deep(.n-scrollbar-container) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.n-scrollbar-content) {
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.intro-content {
|
||||
.intro-preview {
|
||||
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 {
|
||||
margin: 16px 0;
|
||||
flex-shrink: 0;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.chapter-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
|
@ -309,7 +325,8 @@ onMounted(() => {
|
|||
}
|
||||
|
||||
.chapter-scrollbar {
|
||||
max-height: 420px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
:deep(.n-scrollbar-container) {
|
||||
height: 100%;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<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="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 class="page-text">{{ currentText }}</div>
|
||||
|
|
@ -9,19 +9,19 @@
|
|||
|
||||
<div class="page-nav">
|
||||
<span class="page-info">{{ pageInfo }}</span>
|
||||
<n-space justify="space-between" class="nav-btns">
|
||||
<n-space>
|
||||
<div class="nav-btns">
|
||||
<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 :disabled="currentPage <= 0" @click="prevPage">上一页</n-button>
|
||||
</n-space>
|
||||
<n-button size="small" text @click="goBack">返回</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 :disabled="currentPage >= pages.length - 1" @click="nextPage">
|
||||
下一页
|
||||
</n-button>
|
||||
<n-button size="small" text @click="switchChapter(1)">下一章</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -126,10 +126,23 @@ 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.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()
|
||||
} else if (e.key === "ArrowRight" || e.key === "PageDown" || e.key === " ") {
|
||||
} else if (e.key === "ArrowDown" || e.key === "PageDown" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
nextPage()
|
||||
}
|
||||
|
|
@ -192,6 +205,7 @@ onUnmounted(() => {
|
|||
<style scoped>
|
||||
.glass-panel {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: var(--reader-background-color);
|
||||
|
|
@ -232,7 +246,7 @@ onUnmounted(() => {
|
|||
.chapter-name {
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
font-size: 22px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--reader-font-color);
|
||||
margin-bottom: 20px;
|
||||
|
|
@ -260,8 +274,9 @@ onUnmounted(() => {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
|
|
@ -275,6 +290,30 @@ 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>
|
||||
|
|
|
|||
|
|
@ -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: ["←"] },
|
||||
{ action: "下一页", keys: ["→"] },
|
||||
{ action: "打开搜索", keys: ["Ctrl", "F"] },
|
||||
{ action: "打开设置", keys: ["Ctrl", ","] },
|
||||
{ action: "上一页", keys: ["←", "PageUp"] },
|
||||
{ action: "下一页", keys: ["→", "PageDown"] },
|
||||
{ action: "上一章", keys: ["←"] },
|
||||
{ action: "下一章", keys: ["→"] },
|
||||
]
|
||||
</script>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue