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
3 changes: 2 additions & 1 deletion plugins/hushreader/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules
dist
**/*.zip
**/*.zip
test
23 changes: 23 additions & 0 deletions plugins/hushreader/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

All notable changes to this project will be documented in this file.

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

### Added
- **拖拽导入书籍**:将书籍文件拖入书架界面时,显示拖拽悬停覆盖层提示"导入书籍",松手后弹出确认弹窗列出待导入文件,确认后解析书籍并保存元数据,取消则放弃导入
- **快捷文件导入**:支持通过 `hushreader-import` 命令从 ztools 快捷导入书籍文件,`onPluginEnter` 触发时自动解析并添加到书架
- **TXT 章节正则提示气泡**:设置页"TXT 章节识别正则"标签旁新增 ⓘ 提示图标,悬停/聚焦时显示默认规则说明
- **重载元数据**:右键菜单新增"重载元数据"选项,重新解析书籍的章节、封面、标题、作者等信息并更新,同时清除自定义封面
- **恢复封面**:右键菜单新增"恢复封面"选项,EPUB/MOBI 删除自定义封面并重新从文件加载封面,TXT 删除自定义封面恢复为纯色封面
- **多选模式**:书架头部新增多选按钮,点击进入多选模式,可逐个勾选书籍或通过分类栏"全选"按钮批量选中当前分类下所有书籍
- **批量操作**:多选模式下底部显示操作栏,支持批量重载元数据和批量删除,批量删除前弹出确认弹窗

- **书籍信息窗口**:右键菜单新增"书籍信息"选项,打开信息窗口展示封面与标题作者、简介、分类、阅读信息(导入/更新/首次阅读/最近阅读时间、阅读时长、阅读速度);支持编辑模式可上传自定义封面、编辑标题/作者/简介、选择已有分类或新建分类
- **EPUB/MOBI 简介解析**:导入和重载元数据时从 EPUB metadata.description 和 MOBI EXTH record 101 提取书籍简介
- **阅读进度追踪**:保存阅读进度时记录首次阅读时间(firstReadAt)、累计阅读时长(readingTimeMs)、阅读速度(readingSpeed)
- **updatedAt 时间戳**:书籍新增时设置 updatedAt = addedAt,编辑元数据或上传封面时更新 updatedAt,分类变更不更新 updatedAt

### Fixed
- **修复拖拽文件时的路径保存错误**:修复了在拖拽文件导入书籍时,路径保存错误导致相同书籍可以重复导入的问题
- **阅读时长与速度统计逻辑修复**:修复了新会话第一页阅读时间丢失和闲置/关闭期间时间被错误计入的问题,确保阅读时长和速度统计准确。
- **MOBI 封面缓存失效与重复解析性能问题**:修复了每次打开书架时,所有 MOBI 书籍都会被重新解析一次的问题。
- **文件读取失败时导致元数据被静默清空**:修复了在文件读取失败时,元数据被静默清空的问题,确保元数据完整。
- **修复书籍信息编辑时分类交互缺陷**:修复了在书籍信息编辑时,分类交互存在的缺陷。

## [1.3.1](https://github.com/me1dlinger/hushreader/releases/tag/v1.3.1) - 2026-06-17

### Added
Expand Down
18 changes: 11 additions & 7 deletions plugins/hushreader/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<div align="center">

# 隐阅盒 · HushReader
![HushReader Logo](public/logo.png)

# [隐阅盒 · HushReader](https://github.com/me1dlinger/hushreader)

适配 [ZTools](https://github.com/ZToolsCenter/ZTools) 的阅读插件,支持 TXT / EPUB / MOBI 格式

Expand All @@ -19,12 +21,14 @@

## 截图

<div align="center">
<img src="https://files.seeusercontent.com/2026/06/16/tr3Y/image_83.png" width="80%" />
<img src="https://files.seeusercontent.com/2026/06/16/zLc1/image_82.png" width="80%" />
<img src="https://files.seeusercontent.com/2026/06/16/4Uti/image_80.png" width="80%" />
<img src="https://files.seeusercontent.com/2026/06/16/fjG0/image_81.png" width="80%" />
</div>
![书架](https://files.seeusercontent.com/2026/06/17/qvX8/image_86.png)
![暗色模式](https://files.seeusercontent.com/2026/06/17/lu4H/image_85.png)
![书籍信息](https://files.seeusercontent.com/2026/06/18/D9di/image_90.png)
![隐阅窗口设置](https://files.seeusercontent.com/2026/06/17/eCu4/image_89.png)
![功能设置](https://files.seeusercontent.com/2026/06/17/0bqW/image_88.png)
![其他设置](https://files.seeusercontent.com/2026/06/17/y3uD/image_87.png)
![隐阅效果](https://files.seeusercontent.com/2026/06/18/h1Jb/show.gif)


## 快速开始

Expand Down
27 changes: 23 additions & 4 deletions plugins/hushreader/public/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"title": "隐阅盒",
"description": "隐阅盒,ZTools自己的摸鱼阅读,支持TXT/EPUB/MOBI格式,沉浸式阅读",
"author": "meidlinger",
"version": "1.3.1",
"version": "1.3.2",
"main": "index.html",
"preload": "preload/services.js",
"logo": "logo.png",
Expand All @@ -17,11 +17,11 @@
"explain": "打开书架",
"icon": "logo.png",
"cmds": [
"yyh",
"摸鱼阅读",
"隐阅盒",
"书架",
"hushreader"
"摸鱼阅读",
"hushreader",
"yyh"
]
},
{
Expand All @@ -31,6 +31,25 @@
"cmds": [
"开始阅读"
]
},
{
"code": "hushreader-import",
"explain": "导入本地书籍",
"icon": "logo.png",
"cmds": [
"导入书籍",
{
"type": "files",
"fileType": "file",
"extensions": [
"txt",
"epub",
"mobi"
],
"maxLength": 1,
"label": "导入到 隐阅盒"
}
]
}
],
"platform": [
Expand Down
81 changes: 59 additions & 22 deletions plugins/hushreader/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -350,19 +350,58 @@ function toast(msg: string, type: 'info' | 'error' | 'success' = 'info') {
function closePlugin() {
isReaderHidden.value = true
hushreaderActivated.value = false
try { (window as any).ztools?.outPlugin?.() } catch {}
try { (window as any).ztools?.outPlugin?.() } catch { }
}

function saveReadingProgress() {
const book = bookStore.currentBook
if (!book) return
bookStore.updateBook(book.id, {
const now = Date.now()
const updates: Partial<typeof book> = {
lastChapter: readerStore.currentChapterIndex,
progressIndex: readerStore.progressIndex,
lastReadAt: Date.now(),
lastReadAt: now,
totalChapters: readerStore.chapters.length,
readingPercent: readerStore.readingPercent
})
}
if (!book.firstReadAt) {
updates.firstReadAt = now
}

// 初始化或重置会话计时器,避免将跨会话的闲置时间计入阅读时长
if ((window as any).__hushreaderSessionBookId !== book.id) {
(window as any).__hushreaderSessionBookId = book.id;
(window as any).__hushreaderSessionLastActive = now
}

const lastActive = (window as any).__hushreaderSessionLastActive || now
const elapsed = now - lastActive

if (elapsed > 0 && elapsed < 30 * 60 * 1000) {
const totalMs = (book.readingTimeMs || 0) + elapsed
updates.readingTimeMs = totalMs
const totalChars = readerStore.chapters.reduce((sum, ch) => sum + ch.content.length, 0)
const currentReadChars = Math.round(totalChars * (readerStore.readingPercent / 100))
const prevReadChars = book.lastSaveReadChars || 0
const deltaChars = Math.max(0, currentReadChars - prevReadChars)
updates.lastSaveReadChars = currentReadChars
const elapsedMinutes = elapsed / 60000
if (elapsedMinutes > 0 && deltaChars > 0) {
const sessionSpeed = deltaChars / elapsedMinutes
const prevSpeed = book.readingSpeed || 0
if (prevSpeed > 0) {
updates.readingSpeed = Math.round(prevSpeed * 0.6 + sessionSpeed * 0.4)
} else {
updates.readingSpeed = Math.round(sessionSpeed)
}
}
} else {
const totalChars = readerStore.chapters.reduce((sum, ch) => sum + ch.content.length, 0)
updates.lastSaveReadChars = Math.round(totalChars * (readerStore.readingPercent / 100))
}

(window as any).__hushreaderSessionLastActive = now
bookStore.updateBook(book.id, updates)
}
Comment on lines 356 to 405

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

💡 阅读时长与速度统计逻辑缺陷

当前代码使用 book.lastReadAt 来计算两次保存之间的间隔时间 elapsed。由于 lastReadAt 是持久化存储的上一次保存时间(可能来自几天前的上一次阅读会话),这会导致以下两个严重问题:

  1. 新会话的第一页阅读时间丢失:当用户开启新一次阅读时,elapsed 通常会大于 30 分钟,从而触发 else 分支,导致新会话第一页的阅读时间完全没有被记录。
  2. 闲置/关闭期间的时间被错误计入:如果用户关闭插件并在 30 分钟内重新打开,elapsed 会小于 30 分钟,从而将插件关闭期间的闲置时间错误地累加到 readingTimeMs 中,导致阅读时长虚高,且阅读速度计算极度偏低。

🛠️ 解决方案

引入一个基于内存的会话级活跃时间戳 __hushreaderSessionLastActive。当检测到切换书籍或首次打开时,重置该时间戳。这样可以确保 elapsed 仅精确测量当前会话中的实际阅读时间。

function saveReadingProgress() {
  const book = bookStore.currentBook
  if (!book) return
  const now = Date.now()
  const updates: Partial<typeof book> = {
    lastChapter: readerStore.currentChapterIndex,
    progressIndex: readerStore.progressIndex,
    lastReadAt: now,
    totalChapters: readerStore.chapters.length,
    readingPercent: readerStore.readingPercent
  }
  if (!book.firstReadAt) {
    updates.firstReadAt = now
  }
  
  // 初始化或重置会话计时器,避免将跨会话的闲置时间计入阅读时长
  if ((window as any).__hushreaderSessionBookId !== book.id) {
    (window as any).__hushreaderSessionBookId = book.id
    (window as any).__hushreaderSessionLastActive = now
  }
  
  const lastActive = (window as any).__hushreaderSessionLastActive || now
  const elapsed = now - lastActive
  
  if (elapsed > 0 && elapsed < 30 * 60 * 1000) {
    const totalMs = (book.readingTimeMs || 0) + elapsed
    updates.readingTimeMs = totalMs
    const totalChars = readerStore.chapters.reduce((sum, ch) => sum + ch.content.length, 0)
    const currentReadChars = Math.round(totalChars * (readerStore.readingPercent / 100))
    const prevReadChars = book.lastSaveReadChars || 0
    const deltaChars = Math.max(0, currentReadChars - prevReadChars)
    updates.lastSaveReadChars = currentReadChars
    const elapsedMinutes = elapsed / 60000
    if (elapsedMinutes > 0 && deltaChars > 0) {
      const sessionSpeed = deltaChars / elapsedMinutes
      const prevSpeed = book.readingSpeed || 0
      if (prevSpeed > 0) {
        updates.readingSpeed = Math.round(prevSpeed * 0.6 + sessionSpeed * 0.4)
      } else {
        updates.readingSpeed = Math.round(sessionSpeed)
      }
    }
  } else {
    const totalChars = readerStore.chapters.reduce((sum, ch) => sum + ch.content.length, 0)
    updates.lastSaveReadChars = Math.round(totalChars * (readerStore.readingPercent / 100))
  }
  
  (window as any).__hushreaderSessionLastActive = now
  bookStore.updateBook(book.id, updates)
}


function getFileModifiedTime(filePath: string): number | null {
Expand Down Expand Up @@ -420,7 +459,7 @@ async function openBookAndHushreader(bookId: string) {
if (!chapters || fileChanged) {
chapters = await parseBookAndGetChapters(book)
if (!chapters) return
saveChapters(bookId, chapters).catch(() => {})
saveChapters(bookId, chapters).catch(() => { })
}

readerStore.setChapters(chapters)
Expand Down Expand Up @@ -624,20 +663,20 @@ watch(
}
)

onMounted(() => {
configStore.load()
bookStore.load()
;(window as any).ztools?.onPluginEnter?.((action: any) => {
route.value = action.code
enterAction.value = action
})
;(window as any).ztools?.onPluginOut?.((processExit: boolean) => {
if (processExit) {
saveReadingProgress()
hushreaderActivated.value = false
hushreaderWindow?.close?.()
}
})
onMounted(async () => {
await configStore.load()
await bookStore.load()
; (window as any).ztools?.onPluginEnter?.((action: any) => {
route.value = action.code
enterAction.value = action
})
; (window as any).ztools?.onPluginOut?.((processExit: boolean) => {
if (processExit) {
saveReadingProgress()
hushreaderActivated.value = false
hushreaderWindow?.close?.()
}
})
offHushreaderCommand = (window as any).services?.onHushreaderCommand?.(handleHushreaderCommand)

if (!route.value) route.value = 'bookshelf'
Expand All @@ -651,8 +690,6 @@ onBeforeUnmount(() => {
</script>

<template>
<Bookshelf
:enter-action="enterAction"
/>
<Bookshelf :enter-action="enterAction" />
<Toast :message="toastMsg" :type="toastType" />
</template>
49 changes: 46 additions & 3 deletions plugins/hushreader/src/components/Bookshelf/BookCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import type { Book } from '../../stores/books'
const props = defineProps<{
book: Book
listMode?: boolean
selectionMode?: boolean
selected?: boolean
}>()

const emit = defineEmits<{
click: []
contextmenu: [e: MouseEvent]
'cover-error': []
'toggle-select': []
}>()

const imgError = ref(false)
Expand Down Expand Up @@ -46,10 +49,14 @@ function progressText(book: Book): string {
<template>
<div
class="book-card"
:class="{ 'list-mode': listMode }"
@click="$emit('click')"
@contextmenu.prevent="$emit('contextmenu', $event)"
:class="{ 'list-mode': listMode, 'selection-mode': selectionMode, selected }"
@click="selectionMode ? emit('toggle-select') : emit('click')"
@contextmenu.prevent="!selectionMode && emit('contextmenu', $event)"
>
<!-- Selection checkbox -->
<div v-if="selectionMode" class="select-check" :class="{ checked: selected }">
<svg v-if="selected" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<!-- Cover -->
<div
class="book-cover"
Expand Down Expand Up @@ -105,6 +112,42 @@ function progressText(book: Book): string {
border-radius: var(--radius-sm);
}

.book-card.selection-mode {
cursor: pointer;
position: relative;
}

.book-card.selection-mode.selected {
background: var(--c-accent-soft);
}

.select-check {
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--c-border-strong);
background: var(--c-surface-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
transition: all 0.15s var(--ease-out);
}

.select-check.checked {
background: var(--c-accent);
border-color: var(--c-accent);
color: var(--c-ink-inverse);
}

.list-mode .select-check {
position: static;
flex-shrink: 0;
}

.book-cover {
position: relative;
aspect-ratio: 0.68;
Expand Down
Loading
Loading