Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions plugins/hushreader/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
# Changelog

All notable changes to this project will be documented in this file.
## [1.3.3](https://github.com/me1dlinger/hushreader/releases/tag/v1.3.3) - 2026-06-19

### Added
- **自动检测系统深浅色模式**:在插件加载时,根据系统深浅色模式自动切换主题,支持手动切换主题
- **资源管理器打开文件位置**:在书籍右键菜单新增"打开文件位置"选项,使用ztools.shellShowItemInFolder()
- **隐阅定时器**:新增设置项,打开定时器后可设置一个时长,隐阅时间到达之后自动关闭隐阅窗口并Toast提示用户
- **备份和恢复所有阅读进度与配置**:备份和恢复所有阅读进度与配置。插件设置导出,所有书籍的元数据导出。实现设置导入,书籍数据导入
- **多配置切换**:实现多配置切换、添加和删除(最少保留一个配置),导入的配置自动添加为新配置

### Changed
- **分离配置和书籍的导入导出**:将配置和书籍的导入导出分离,配置导入导出时仅和配置相关,书籍导入导出时仅和书籍数据相关
- **定时器相关通知**:在隐阅界面弹出相应通知

### Fixed
- **配置无法导入的问题**:修复配置无法导入的问题
- **优化代码** :减少不必要的性能开销
- **系统主题自动切换**:修复系统主题发生变化时,systemDark 和 effectiveTheme 无法自动更新
- **优化书籍导入流程**:在导入书籍的循环中,每次调用 bookStore.addBook(book) 都会触发一次同步的 save() 写入操作(保存到 dbStorage 或 localStorage)。如果用户导入的书籍数量较多,会产生大量连续的同步 I/O 写入
- **JSON配置导入漏洞**:如果导入的备份 JSON 文件被恶意篡改,包含 __proto__ 或 constructor 等属性,可能会导致原型链污染(Prototype Pollution)漏洞


## [1.3.2](https://github.com/me1dlinger/hushreader/releases/tag/v1.3.2) - 2026-06-18

Expand Down
60 changes: 41 additions & 19 deletions plugins/hushreader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,22 @@

***

[更新日志](./CHANGELOG.md)

***

## 功能一览

| 阅读体验 | 个性化 | 书架管理 |
| :------------------ | :--------------- | :---------------- |
| 沉浸式悬浮窗口,可置于任意位置 | 背景透明度与整体透明度分离控制 | 支持按添加时间、书名、作者排序 |
| 滚轮翻页 / 快捷键翻页 / 自动翻页 | 自定义字体,支持添加系统字体 | 右键编辑元数据(标题、作者) |
| 沉浸式悬浮窗口,可置于任意位置 | 背景透明度与整体透明度分离控制 | 支持按添加时间、书名、作者、最近阅读排序 |
| 滚轮翻页 / 快捷键翻页 / 自动翻页 | 自定义字体,支持添加系统字体 | 右键编辑元数据(标题、作者、分类) |
| 只显示完整行,文字不残缺 | 十六进制颜色输入 + 颜色选择器 | 阅读进度持久化,关闭再开继续读 |
| 文本预处理:压缩空行、清理空白 | 设置实时预览,取消可还原 | — |
| 文本预处理:压缩空行、清理空白 | 设置实时预览,取消可还原 | 拖拽导入 / 快捷文件导入(`导入书籍`) |
| 鼠标移出隐藏三模式(关闭/仅进度/全隐藏) | 亮色 / 暗色主题切换 | 书籍信息窗口(封面、简介、分类、阅读统计) |
| 鼠标移入显示延迟可调 | 列表书架模式 | 多选模式 + 批量重载/删除 |
| 百分比进度编辑跳转 | 窗口大小锁定 | 重载元数据 / 恢复封面 |
| 章节列表高亮当前进度 | — | 书籍分类筛选栏 |

## 截图

Expand All @@ -34,7 +42,8 @@

### 安装

下载release文件,在ZTools中导入
- 已上架市场,ZTools插件市场搜索**隐阅盒**,点击**安装**按钮
- 下载github-release文件,在ZTools**搜索框**完成导入

### 开发

Expand All @@ -48,7 +57,7 @@ npm run build # 构建生产版本

在 ZTools 中输入以下关键词即可唤起:

`yyh` · `摸鱼阅读` · `隐阅盒` · `书架` · `hushreader`
`隐阅盒` · `hushreader` · `摸鱼阅读` · `书架` · `yyh` · `开始阅读` · `导入书籍`(支持拖入 txt/epub/mobi 文件)

## 技术细节

Expand All @@ -57,18 +66,27 @@ npm run build # 构建生产版本

阅读进度使用**字符偏移量**(`progressIndex`)而非页码保存,窗口大小或字体变化时进度不会丢失。

**存储策略**:优先使用 `ztools.dbStorage`,回退到 `localStorage`。
**存储策略**:轻量数据(书籍列表不含封面、阅读进度、配置)使用 `ztools.dbStorage`,回退到 `localStorage`;封面和章节内容使用 `ztools.db`(PouchDB 风格数据库),通过 `ztools.db.promises` API 管理 `cover_{bookId}`、`custom_cover_{bookId}`、`chapters_{bookId}` 等文档

**保存时机**:翻页时 · 章节切换时 · 自动翻页 tick · 插件退出时

**每本书保存**:

| 字段 | 说明 |
| --------------- | -------- |
| `lastChapter` | 当前章节索引 |
| `progressIndex` | 章节内字符偏移量 |
| `lastReadAt` | 最后阅读时间戳 |
| `totalChapters` | 总章节数 |
| 字段 | 说明 |
| ------------------ | ----------- |
| `lastChapter` | 当前章节索引 |
| `progressIndex` | 章节内字符偏移量 |
| `lastReadAt` | 最后阅读时间戳 |
| `totalChapters` | 总章节数 |
| `firstReadAt` | 首次阅读时间戳 |
| `readingTimeMs` | 累计阅读时长(毫秒) |
| `readingSpeed` | 阅读速度(字/分钟) |
| `readingPercent` | 阅读百分比 |
| `updatedAt` | 更新时间戳 |
| `description` | 书籍简介 |
| `categories` | 分类数组 |
| `customCoverImage` | 自定义封面 |
| `fileModifiedAt` | 文件修改时间 |

</details>

Expand Down Expand Up @@ -177,32 +195,36 @@ HTML/纯文本判断 → 分章解析或按 TXT 逻辑处理
├── src/
│ ├── App.vue # 根组件
│ ├── main.ts # 应用入口
│ ├── main.css # 应用样式
│ ├── env.d.ts #环境变量类型定义
│ ├── main.css # 应用样式
│ ├── env.d.ts # 环境变量类型定义
│ ├── stores/
│ │ ├── books.ts # 书籍数据 + 持久化
│ │ ├── config.ts # 配置数据 + 持久化
│ │ └── reader.ts # 阅读器状态 + 分页
│ ├── utils/
│ │ ├── db.ts # 数据库操作工具
│ │ ├── db.ts # 数据库操作工具(封面/章节缓存)
│ │ ├── txtParser.ts # TXT 解析 + 分页 + 预处理
│ │ ├── epubParser.ts # EPUB 解析
│ │ └── mobiParser.ts # MOBI 解析 + 加密检测 + 封面提取
│ └── components/
│ ├── Bookshelf/ # 书架组件
│ │ ├── index.vue
│ │ ├── BookCard.vue
│ │ ├── ContextMenu.vue
│ │ ├── Modal.vue
│ │ └── Toast.vue
│ │ ├── BookInfoModal.vue # 书籍信息窗口
│ │ ├── ContextMenu.vue # 右键菜单
│ │ ├── Modal.vue # 通用弹窗
│ │ ├── ThemeToggle.vue # 主题切换
│ │ └── Toast.vue # Toast 提示
│ └── Settings/ # 设置面板
│ └── index.vue
├── .gitignore
├── CHANGELOG.md # 更新日志
├── LICENSE
├── package.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
├── vite.config.ts
└── ztools.api.md # ZTools API 文档
```

## 开源协议
Expand Down
2 changes: 1 addition & 1 deletion plugins/hushreader/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "hushreader",
"private": true,
"version": "1.0.0",
"version": "1.3.3",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
144 changes: 144 additions & 0 deletions plugins/hushreader/public/hushreader.html
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,77 @@
background: transparent;
color: var(--hushreader-text-color);
}

.hushreader-timer {
position: absolute;
top: 4px;
left: 10px;
z-index: 4;
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
line-height: 1.3;
opacity: 0;
pointer-events: none;
color: var(--hushreader-text-color);
font-variant-numeric: tabular-nums;
transition: opacity 120ms ease;
}

.show-timer .hushreader-timer {
opacity: 0.66;
}

.hushreader-notification {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
opacity: 0;
pointer-events: none;
transition: opacity 200ms ease;
}

.hushreader-notification.visible {
opacity: 1;
pointer-events: auto;
}

.hushreader-notification-box {
max-width: 80%;
padding: 10px 18px;
border-radius: 8px;
background: rgba(30, 31, 34, 0.92);
color: var(--hushreader-text-color);
font-size: 12px;
line-height: 1.5;
text-align: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}

.hushreader-notification-box .notif-action {
display: inline-block;
margin-top: 8px;
padding: 3px 14px;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 4px;
background: transparent;
color: var(--hushreader-text-color);
font-size: 11px;
cursor: pointer;
transition: background 120ms ease;
}

.hushreader-notification-box .notif-action:hover {
background: rgba(255, 255, 255, 0.1);
}
</style>
</head>

Expand All @@ -396,18 +467,26 @@
</div>
<button class="hushreader-hit next" type="button" aria-label="下一页" data-command="next"></button>
<div id="hushreaderMeta" class="hushreader-meta"></div>
<div id="hushreaderTimer" class="hushreader-timer"></div>
<div class="hushreader-resize-zone height" data-resize="height" aria-hidden="true"></div>
<div class="hushreader-resize-zone width" data-resize="width" aria-hidden="true"></div>
<div class="hushreader-resize-zone corner" data-resize="both" aria-hidden="true"></div>
<div id="hushreaderSizeHint" class="hushreader-size-hint"></div>
</section>
<div id="hushreaderContextMenu" class="hushreader-context-menu" style="display:none"></div>
<div id="hushreaderNotification" class="hushreader-notification">
<div class="hushreader-notification-box">
<div id="notifMessage"></div>
<button id="notifAction" class="notif-action" style="display:none">确定</button>
</div>
</div>
</main>

<script>
const root = document.getElementById("hushreader")
const linesNode = document.getElementById("hushreaderLines")
const metaNode = document.getElementById("hushreaderMeta")
const timerNode = document.getElementById("hushreaderTimer")
const sizeHintNode = document.getElementById("hushreaderSizeHint")
const contextMenuNode = document.getElementById("hushreaderContextMenu")

Expand All @@ -427,13 +506,69 @@
let isKeyboardActive = false
let isProgressEditing = false
let isAutoPaging = false
let timerRemaining = null
let timerTickInterval = 0
let notifAutoCloseTimer = 0

function formatTimer(ms) {
if (ms == null || ms <= 0) return ''
const totalSec = Math.ceil(ms / 1000)
const min = Math.floor(totalSec / 60)
const sec = totalSec % 60
return `${min}:${String(sec).padStart(2, '0')}`
}

function startTimerTick() {
clearInterval(timerTickInterval)
if (timerRemaining == null || timerRemaining <= 0) {
timerNode.textContent = ''
root.classList.remove('show-timer')
return
}
root.classList.add('show-timer')
timerNode.textContent = formatTimer(timerRemaining)
timerTickInterval = setInterval(() => {
timerRemaining = Math.max(0, timerRemaining - 1000)
if (timerRemaining <= 0) {
clearInterval(timerTickInterval)
timerNode.textContent = ''
root.classList.remove('show-timer')
} else {
timerNode.textContent = formatTimer(timerRemaining)
}
}, 1000)
}

function sendCommand(command) {
if (window.ztools?.sendToParent) {
window.ztools.sendToParent("hushreader-command", command)
}
}

const notifNode = document.getElementById("hushreaderNotification")
const notifMessageNode = document.getElementById("notifMessage")
const notifActionNode = document.getElementById("notifAction")

window.hushreaderShowNotification = function hushreaderShowNotification(message) {
clearTimeout(notifAutoCloseTimer)
notifMessageNode.textContent = message
notifActionNode.style.display = "inline-block"
notifNode.classList.add("visible")
}

function closeNotification() {
clearTimeout(notifAutoCloseTimer)
notifNode.classList.remove("visible")
notifMessageNode.textContent = ""
notifActionNode.style.display = "none"
sendCommand("notification-close")
}

notifActionNode.addEventListener("click", (e) => {
e.stopPropagation()
closeNotification()
})

function setAutoHidden(flag) {
const mode = latestSettings.hideOnMouseLeave
const shouldHide = Boolean(flag && mode && mode !== 'off')
Expand Down Expand Up @@ -719,6 +854,15 @@

isAutoPaging = Boolean(settings.autoFlipEnabled)

const newTimerRemaining = settings.timerEnabled ? settings.timerRemaining : null
if (newTimerRemaining !== timerRemaining) {
const needsReset = timerRemaining == null || newTimerRemaining == null || Math.abs(newTimerRemaining - timerRemaining) > 2000
timerRemaining = newTimerRemaining
if (needsReset) {
startTimerTick()
}
}
Comment on lines +857 to +864

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

hushreaderSetState 中,settings.timerRemaining 是通过 Date.now() 动态计算的,因此每次状态推送(如翻页、鼠标移入移出等)时,其值都会发生微小的变化。这会导致 newTimerRemaining !== timerRemaining 始终为 true,从而在每次状态更新时都频繁地清除并重新创建 setInterval 定时器。

这不仅会带来不必要的性能开销,还会导致倒计时显示出现抖动或卡顿。建议仅在时间偏差较大(例如大于 2 秒)或定时器状态发生根本改变时才重新初始化定时器。

Suggested change
const newTimerRemaining = settings.timerEnabled ? settings.timerRemaining : null
if (newTimerRemaining !== timerRemaining) {
timerRemaining = newTimerRemaining
startTimerTick()
}
const newTimerRemaining = settings.timerEnabled ? settings.timerRemaining : null
if (newTimerRemaining !== timerRemaining) {
const needsReset = timerRemaining == null || newTimerRemaining == null || Math.abs(newTimerRemaining - timerRemaining) > 2000
timerRemaining = newTimerRemaining
if (needsReset) {
startTimerTick()
}
}


root.className = [
"hushreader-shell",
settings.showHushreaderMeta ? "show-meta" : "",
Expand Down
4 changes: 2 additions & 2 deletions plugins/hushreader/public/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
"$schema": "node_modules/@ztools-center/ztools-api-types/resource/ztools.schema.json",
"name": "hushreader",
"title": "隐阅盒",
"description": "隐阅盒,ZTools自己的摸鱼阅读,支持TXT/EPUB/MOBI格式,沉浸式阅读",
"description": "ZTools自己的摸鱼阅读,支持TXT/EPUB/MOBI格式,沉浸式阅读",
"author": "meidlinger",
"version": "1.3.2",
"version": "1.3.3",
"main": "index.html",
"preload": "preload/services.js",
"logo": "logo.png",
Expand Down
13 changes: 13 additions & 0 deletions plugins/hushreader/public/preload/services.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,5 +170,18 @@ window.services = {
)
fs.writeFileSync(filePath, text, { encoding: 'utf-8' })
return filePath
},

writeFileToPath(filePath, content, encoding) {
const fullPath = path.resolve(filePath)
const dir = path.dirname(fullPath)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(fullPath, content, { encoding: encoding || 'utf-8' })
return fullPath
},

readFileFromPath(filePath) {
const fullPath = path.resolve(filePath)
return fs.readFileSync(fullPath, 'utf-8')
}
}
Loading
Loading