diff --git a/plugins/hushreader/.gitignore b/plugins/hushreader/.gitignore
index b8b54725..eb8e7331 100644
--- a/plugins/hushreader/.gitignore
+++ b/plugins/hushreader/.gitignore
@@ -1,3 +1,4 @@
node_modules
dist
-**/*.zip
\ No newline at end of file
+**/*.zip
+test
\ No newline at end of file
diff --git a/plugins/hushreader/CHANGELOG.md b/plugins/hushreader/CHANGELOG.md
index ccce34d0..be4c5bb6 100644
--- a/plugins/hushreader/CHANGELOG.md
+++ b/plugins/hushreader/CHANGELOG.md
@@ -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
diff --git a/plugins/hushreader/README.md b/plugins/hushreader/README.md
index b7ca6d62..9789cae1 100644
--- a/plugins/hushreader/README.md
+++ b/plugins/hushreader/README.md
@@ -1,6 +1,8 @@
-# 隐阅盒 · HushReader
+
+
+# [隐阅盒 · HushReader](https://github.com/me1dlinger/hushreader)
适配 [ZTools](https://github.com/ZToolsCenter/ZTools) 的阅读插件,支持 TXT / EPUB / MOBI 格式
@@ -19,12 +21,14 @@
## 截图
-
+
+
+
+
+
+
+
+
## 快速开始
diff --git a/plugins/hushreader/public/plugin.json b/plugins/hushreader/public/plugin.json
index f3822f18..ffed5746 100644
--- a/plugins/hushreader/public/plugin.json
+++ b/plugins/hushreader/public/plugin.json
@@ -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",
@@ -17,11 +17,11 @@
"explain": "打开书架",
"icon": "logo.png",
"cmds": [
- "yyh",
- "摸鱼阅读",
"隐阅盒",
"书架",
- "hushreader"
+ "摸鱼阅读",
+ "hushreader",
+ "yyh"
]
},
{
@@ -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": [
diff --git a/plugins/hushreader/src/App.vue b/plugins/hushreader/src/App.vue
index 43c6bc93..51a1c95d 100644
--- a/plugins/hushreader/src/App.vue
+++ b/plugins/hushreader/src/App.vue
@@ -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
= {
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)
}
function getFileModifiedTime(filePath: string): number | null {
@@ -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)
@@ -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'
@@ -651,8 +690,6 @@ onBeforeUnmount(() => {
-
+
diff --git a/plugins/hushreader/src/components/Bookshelf/BookCard.vue b/plugins/hushreader/src/components/Bookshelf/BookCard.vue
index 87b44676..35869d3b 100644
--- a/plugins/hushreader/src/components/Bookshelf/BookCard.vue
+++ b/plugins/hushreader/src/components/Bookshelf/BookCard.vue
@@ -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)
@@ -46,10 +49,14 @@ function progressText(book: Book): string {
+
+
+import { ref, computed, watch } from 'vue'
+import type { Book } from '../../stores/books'
+import { useBookStore } from '../../stores/books'
+import { saveCustomCover } from '../../utils/db'
+
+const props = defineProps<{ book: Book }>()
+const emit = defineEmits<{
+ close: []
+ saved: [updates: Partial
]
+}>()
+
+const bookStore = useBookStore()
+
+const existingCategories = computed(() => bookStore.categories.filter(c => c !== '全部'))
+
+const isEditing = ref(false)
+
+const editTitle = ref('')
+const editAuthor = ref('')
+const editDescription = ref('')
+const editCategories = ref([])
+const editNewCategory = ref('')
+
+const imgError = ref(false)
+
+const displayCover = computed(() => {
+ if (imgError.value) return undefined
+ return props.book.customCoverImage || props.book.coverImage
+})
+
+function formatDateTime(ts: number | undefined): string {
+ if (!ts) return '-'
+ const d = new Date(ts)
+ const pad = (n: number) => String(n).padStart(2, '0')
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+}
+
+function formatReadingTime(ms: number | undefined): string {
+ if (!ms) return '00:00:00'
+ const totalSeconds = Math.floor(ms / 1000)
+ const h = Math.floor(totalSeconds / 3600)
+ const m = Math.floor((totalSeconds % 3600) / 60)
+ const s = totalSeconds % 60
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
+}
+
+function formatReadingSpeed(speed: number | undefined): string {
+ if (!speed) return '-'
+ return String(Math.round(speed))
+}
+
+function enterEditMode() {
+ editTitle.value = props.book.title
+ editAuthor.value = props.book.author
+ editDescription.value = props.book.description || ''
+ editCategories.value = [...(props.book.categories || [])]
+ editNewCategory.value = ''
+ isEditing.value = true
+}
+
+function cancelEdit() {
+ isEditing.value = false
+}
+
+function toggleEditCategory(cat: string) {
+ const idx = editCategories.value.indexOf(cat)
+ if (idx > -1) {
+ editCategories.value.splice(idx, 1)
+ } else {
+ editCategories.value.push(cat)
+ }
+}
+
+function uploadCover() {
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.accept = 'image/*'
+ input.onchange = () => {
+ const file = input.files?.[0]
+ if (!file) return
+ const reader = new FileReader()
+ reader.onload = () => {
+ const data = reader.result as string
+ emit('saved', { customCoverImage: data, updatedAt: Date.now() })
+ saveCustomCover(props.book.id, data).catch(() => { })
+ }
+ reader.readAsDataURL(file)
+ }
+ input.click()
+}
+
+function addCustomCategory() {
+ const val = editNewCategory.value.trim()
+ if (!val) return
+ const cats = val.split(/[,,]/).map(s => s.trim()).filter(Boolean)
+ cats.forEach(c => {
+ if (!editCategories.value.includes(c)) {
+ editCategories.value.push(c)
+ }
+ })
+ editNewCategory.value = ''
+}
+
+function saveEdit() {
+ addCustomCategory()
+ const finalCats = [...new Set(editCategories.value)]
+
+ const updates: Partial = {
+ title: editTitle.value.trim() || '未命名',
+ author: editAuthor.value.trim(),
+ description: editDescription.value.trim() || undefined,
+ categories: finalCats.length > 0 ? finalCats : undefined,
+ updatedAt: Date.now()
+ }
+ emit('saved', updates)
+ isEditing.value = false
+}
+
+watch(() => props.book, () => {
+ imgError.value = false
+ isEditing.value = false
+})
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+ {{ book.format.toUpperCase() }}
+ {{ book.title }}
+
+
+
+
![]()
+
+ {{ book.format.toUpperCase() }}
+ {{ book.title }}
+
+
点击更换
+
+
+
+
+
+
+
+
简介
+
+ {{ book.description }}
+ 暂无简介
+
+
+
+
+
+
+
+
+
分类
+
+
+ {{ cat }}
+
+ 未分类
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
阅读信息
+
+
+ 导入时间
+ {{ formatDateTime(book.addedAt) }}
+
+
+ 更新时间
+ {{ formatDateTime(book.updatedAt || book.addedAt) }}
+
+
+ 首次阅读
+ {{ formatDateTime(book.firstReadAt) }}
+
+
+ 最近阅读
+ {{ formatDateTime(book.lastReadAt) }}
+
+
+ 阅读时长
+ {{ formatReadingTime(book.readingTimeMs) }}
+
+
+ 阅读速度
+ {{ formatReadingSpeed(book.readingSpeed) }} 字/分钟
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/hushreader/src/components/Bookshelf/ContextMenu.vue b/plugins/hushreader/src/components/Bookshelf/ContextMenu.vue
index 8da1f4e9..bad1a4ab 100644
--- a/plugins/hushreader/src/components/Bookshelf/ContextMenu.vue
+++ b/plugins/hushreader/src/components/Bookshelf/ContextMenu.vue
@@ -3,11 +3,14 @@ import { ref, nextTick, watch } from 'vue'
const props = defineProps<{ pos: { x: number; y: number } }>()
const emit = defineEmits<{
+ 'book-info': []
'chapter-list': []
'change-path': []
'edit-metadata': []
+ 'reload-metadata': []
'set-category': []
'set-cover': []
+ 'restore-cover': []
'delete': []
'close': []
}>()
@@ -39,6 +42,10 @@ watch(