@ggboy123/dsh-paper-reader 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 插件:论文伴读工作台 —— PDF 转录 / 检索 + 原生对话伴读 + 内置阅读器。
11
11
 
12
- > **👉 普通用户不用看这一页。** 如果你只是想装一个能用的 App,请直接去 [**PaperReader 论文伴读桌面版**](https://github.com/GGboya/PaperReader) —— 那是把本插件预装好的桌面应用,下载 DMG 拖进「应用程序」就能用,不需要任何环境。本仓库面向的是想自己装插件、或想改代码的用户。
12
+ > **👉 普通用户直接看「安装 → 方式一」**:装 DeepSeek 官方桌面端,添加插件即可,不需要任何开发环境。本仓库面向的是想自己装插件、或想改代码的用户。
13
13
 
14
14
  功能迁移自本地独立产品 pdfqa(Go 实现的论文伴读工具)。原则:agent 循环 / 模型层 / 会话持久化全部交给 dsh 框架,插件只做「转录 + 检索 + 阅读器 UI」的薄壳组合。
15
15
 
@@ -23,50 +23,39 @@
23
23
 
24
24
  ## 功能
25
25
 
26
- - 📚 **侧栏文献库树**:「论文伴读」模式开启时接管工作区侧栏,专题 → 论文两级;支持新建专题、上传 PDF(默认关闭,不覆盖官方界面)
26
+ - 📚 **侧栏文献库树**:「论文伴读」模式开启时接管工作区侧栏,专题 → 论文两级;支持新建专题、上传 PDF、**专题/论文改名与删除**(行悬停出 ✎ 🗑;删除两击确认;改名连带转录缓存 / 译文 / 学习档案一起搬家)(默认关闭,不覆盖官方界面)
27
27
  - 💬 **原生对话伴读**:点击论文即在右侧打开它的 dsh 原生会话(流式 / 工具卡 / 用量条全是官方 UI);默认只呈现当前对话,对话列头部的 🕐 下拉回看**历史对话**(真实标题+日期,点哪条进哪条),+ 新建对话
28
28
  - 🔗 **会话绑定论文**:会话 id 编码文献身份,工具调用自动定位当前论文——直接在输入框提问无需点名论文,回答必带页码
29
29
  - 🤖 **专用 agent preset「论文伴读」**:paper 会话自动使用专用 preset —— 像老师一样带读:制定分步阅读计划(每步带页码和思考题)→ 逐节指导、点评 → 出题检验、逐题批改 → 成绩与薄弱点记入 `<文献>.study.json` 学习档案,跨会话累积;快速问答(选中即问)则直接回答,不上课
30
- - 📖 **PDF 阅读器居中**:中间是论文(PDF.js 缩放 / Retina 高清 / 文本层选择 / 适宽模式),最右是该论文的原生对话 —— 视觉换位实现,列宽拖拽/折叠仍是 dsh 原生行为,关掉 PDF 页签即还原官方布局
30
+ - 📖 **PDF 阅读器居中**:中间是论文(PDF.js 缩放 / Retina 高清 / 文本层选择 / 适宽模式 / **滚动 ↔ 单页翻页双模式**——‹ › 翻页、页码输入跳转、← → / PageUp / PageDown / Space / Home / End 键盘翻页),最右是该论文的原生对话 —— 视觉换位实现,列宽拖拽/折叠仍是 dsh 原生行为,关掉 PDF 页签即还原官方布局;状态栏显示 PDF 完整路径,点击复制
31
31
  - 💬 **选中即问**:阅读器里选中文字 → 弹出提问框 → 注入当前会话,原生对话区实时回答
32
32
  - 📍 **引用定位**:回答里的「第 N 页」可点击,平滑跳回 PDF 对应页并闪烁
33
- - 🀄 **中英切换**:顶栏「中」按钮原文 ↔ 纯中文切换;无译文时一键后台生成(babeldoc),生成需配置 `translate` 端点;**无需预装 Python**——首次生成时自动下载 uv + 托管 Python + babeldoc(macOS / Linux / Windows,约几分钟),全程落在用户目录
33
+ - 🀄 **中英切换 + 导出**:顶栏「中」按钮原文 ↔ 纯中文切换;无译文时一键后台生成(babeldoc),生成需配置 `translate` 端点;**无需预装 Python**——首次生成时自动下载 uv + 托管 Python + babeldoc(macOS / Linux / Windows,约几分钟),全程落在用户目录;顶栏 ⤓ 一键**下载当前版本**(原文 / 纯中文 / 中英对照 PDF)
34
34
  - 🔍 **PDF 转录 + 检索**:本地提取(pdf.js,纯 Node 无需 Python),页眉页脚剔除 / 连字 / 断词愈合 / 段落重排,产出页码偏移表;可选接入 TypeSafe/Jev 语义重排(设置面板填端点即启用,不配置时退回纯关键词排序);兼容 pdfqa 的 `data/` 缓存布局(旧缓存读取时自动补建页码索引)
35
35
 
36
- ## 安装
37
-
38
- ### 方式一:PaperReader 桌面版(一键安装,推荐普通用户)
39
-
40
- [PaperReader](https://github.com/GGboya/PaperReader) 是把本插件**预装好**的桌面应用(基于社区桌面客户端 [DSH Desktop](https://github.com/anywhere-labs/dsh-desktop) 打包):
36
+ > **库管理须知**:改名/删除直接作用于磁盘文件(论文的转录缓存、译文、学习档案随之改名/删除);论文/专题改名后,历史伴读会话不再挂在树上(会话本体仍在 dsh 全局会话列表里),学习进度跟着文件走不丢;翻译进行中的论文禁止改名/删除。
41
37
 
42
- 1. 从 [Releases](https://github.com/GGboya/PaperReader/releases/latest) 下载 DMG(macOS Universal,Intel / M 系列芯片都行;Windows 版打包中)
43
- 2. 打开 DMG,把 PaperReader 拖进「应用程序」
44
- 3. 首次打开用 右键 → 打开(免签名发布,只弹这一次);首次启动有几分钟一次性初始化
45
-
46
- 打开就是文献库 + 阅读器 + 伴读对话,不用再执行任何命令。
38
+ ## 安装
47
39
 
48
- ### 方式二:已有桌面客户端,自己装插件
40
+ ### 方式一:官方桌面端装插件(推荐)
49
41
 
50
42
  **DeepSeek 官方桌面端**([deepseek.com/download](https://www.deepseek.com/download/) 下载,即 DeepSeek Harness 桌面版):
51
43
 
52
44
  1. 安装并启动,登录 DeepSeek 账号
53
- 2. 侧栏 → **插件** → **添加插件**,输入 `@ggboy123/dsh-paper-reader` 安装
54
- 3. 重启桌面端生效
45
+ 2. 侧栏 → **插件** → **添加插件**,输入 `@ggboy123/dsh-paper-reader@1.3.0` 安装
46
+ 3. 装完默认**停用**:点进插件详情,打开「启用」开关
47
+ 4. **重启桌面端**(热启用状态下打开论文的链路会静默失效,重启进开机组合才正常,实测)
55
48
 
56
- > 已在官方桌面端 V0.2.0-rc.2(内置 dsh 0.2.0-rc.2,macOS arm64)实测通过:侧栏文献库、新建专题、上传 PDF、PDF 阅读器、原生对话伴读、历史/新建对话按钮全部可用。
57
- > 需要 ≥1.2.0:更早版本会被 0.2.x 运行时的 peer 兼容校验拒载(插件列表里显示异常)。
49
+ 完整流程演示(添加插件 → 启用 → 重启 → 打开论文直接提问):
58
50
 
59
- **社区 DSH Desktop**([anywhere-labs/dsh-desktop](https://github.com/anywhere-labs/dsh-desktop),社区的 DeepSeek Harness 桌面客户端,macOS / Windows,开箱即用,不需要装 Node.js):
51
+ ![官方桌面端安装并使用论文伴读插件](https://raw.githubusercontent.com/GGboya/dsh-paper-reader/main/docs/desktop-official-use.gif)
60
52
 
61
- 1. 下载安装 DSH Desktop 并启动
62
- 2. 托盘菜单 → **Open DSH Terminal**(终端里自带 `dsh`/`pnpm`,只对那个终端生效)
63
- 3. 执行 `dsh plugin add @ggboy123/dsh-paper-reader@1.2.0`
64
- 4. 退出并重开 DSH Desktop(插件变更要重启才进 Loader 组合)
53
+ > 已在官方桌面端 V0.2.0-rc.2(内置 dsh 0.2.0-rc.2,macOS arm64)实测通过:侧栏文献库、新建专题、上传 PDF、PDF 阅读器、原生对话伴读、历史/新建对话按钮全部可用。
54
+ > 需要 ≥1.2.0:更早版本会被 0.2.x 运行时的 peer 兼容校验拒载(插件列表里显示异常)。
65
55
 
66
- > 已在 DSH Desktop 2.0.13(内置 dsh 0.1.5-rc.2)上实测通过:侧栏文献库、PDF 阅读器、选中即问、页码跳转、原生对话伴读、翻译引擎自动安装(uv + Python + babeldoc 全程落在用户目录)全部可用。
67
- > 注意:Desktop 的 `desktop` profile 被 Electron 独占管理,外部 CLI 直接 `dsh plugin --profile desktop add` 会被拒(`managed exclusively by the Electron application`)——必须从 Desktop 自己的终端进。
56
+ > 曾经的 PaperReader 桌面版(基于社区 DSH Desktop 的预装打包)**已不再维护**——统一收敛到官方桌面端 + 插件的形式,老用户请迁移到本方式。
68
57
 
69
- ### 方式三:命令行 dsh(开发者)
58
+ ### 方式二:命令行 dsh(开发者)
70
59
 
71
60
  ```bash
72
61
  dsh plugin --profile web add @ggboy123/dsh-paper-reader
@@ -80,7 +69,7 @@ dsh plugin --profile web add @ggboy123/dsh-paper-reader
80
69
  **已装过的用户升级必须带显式版本号**——不带版本的 `add` 对已存在的依赖是 no-op(pnpm 按首次安装时记录的版本范围解析,不会追新):
81
70
 
82
71
  ```bash
83
- dsh plugin --profile web add @ggboy123/dsh-paper-reader@1.2.0
72
+ dsh plugin --profile web add @ggboy123/dsh-paper-reader@1.3.0
84
73
  # 重启 dsh web 生效;浏览器 Cmd+Shift+R 强刷,避免旧阅读器页面缓存
85
74
  ```
86
75
 
@@ -92,7 +81,11 @@ grep '"version"' ~/.dsh/profiles/web/node_modules/@ggboy123/dsh-paper-reader/pac
92
81
 
93
82
  > pnpm 若开了供应链策略 `minimumReleaseAge`(发布 N 小时内的新包拒装):要么等过窗口期,要么在 `~/.dsh/profiles/web/.npmrc` 加一行 `minimum-release-age-exclude[]=@ggboy123/dsh-paper-reader`(只豁免本插件,不动全局策略)。
94
83
 
95
- 本地开发:
84
+ ### 参与开发(本地调试)
85
+
86
+ 欢迎改代码。流程:clone → 构建 → 以本地目录装进 profile,之后改完 `pnpm run build` 重启对应 profile 即可验证:
87
+
88
+ ![参与开发:clone → pnpm install → build → 本地安装](https://raw.githubusercontent.com/GGboya/dsh-paper-reader/main/docs/dev-workflow.gif)
96
89
 
97
90
  ```bash
98
91
  git clone https://github.com/GGboya/dsh-paper-reader
@@ -123,6 +116,21 @@ dsh --profile web # 侧栏变为文献库树
123
116
 
124
117
  > 伴读模式是**按需开启**的:默认不注册 `sidebar.workspaces`(官方工作区/会话列表原样),侧栏底部「📚 论文伴读」开关点击才接管,再点还原;模式选择记在 localStorage。退出模式时已打开的 PDF 页签和伴读会话不受影响。
125
118
 
119
+ ### 翻译引擎冒烟测试(Windows 依赖完整性)
120
+
121
+ Windows 用户最常见的翻车点是翻译引擎装不上(杀软拦 uv、网络截断、venv 残缺)。安装逻辑 `src/babeldoc-install.ts` 是纯 Node 模块(不依赖 Cordis/Electron),配了 GitHub Actions 在**全新干净机器**上跑冒烟,无需本地 Windows:
122
+
123
+ ```bash
124
+ pnpm build
125
+ pnpm smoke:engine clean # 全新安装全链路
126
+ pnpm smoke:engine managed-uv # 强制走插件托管 uv 下载(sha256 校验路径)
127
+ pnpm smoke:engine pip-fallback # 强制走 pip + 国内镜像备用通道
128
+ pnpm smoke:engine repair # 破坏 venv(删 pymupdf)后验证自检→自动修复
129
+ pnpm smoke:engine translate # 再起本地 mock OpenAI 端点,真跑一遍 babeldoc 翻译管线
130
+ ```
131
+
132
+ CI(`.github/workflows/engine-smoke.yml`):`windows-latest` 跑全部四个场景 + translate-e2e,`macos-latest`/`ubuntu-latest` 跑 clean 防回归;改安装/翻译代码的 push 触发,外加每周一定时(uv/PyPI 在变,代码不动也可能突然坏)。两个测试钩子环境变量:`DSH_PR_MANAGED_UV_ONLY=1`(跳过系统 uv 查找)、`DSH_PR_FORCE_PIP_CHANNEL=1`(跳过 uv 装包通道)。
133
+
126
134
  ## 架构
127
135
 
128
136
  ```
@@ -65,6 +65,10 @@ async function findUv(home) {
65
65
  const own = uvPath(home);
66
66
  if (existsSync(own))
67
67
  return own;
68
+ // 测试钩子:CI runner 常在 PATH 预装 uv,会静默绕过 downloadUv;
69
+ // DSH_PR_MANAGED_UV_ONLY=1 时跳过系统查找,强制走插件托管下载链路(含 sha256 校验)。
70
+ if (process.env.DSH_PR_MANAGED_UV_ONLY === '1')
71
+ return null;
68
72
  if (await whichOk('uv'))
69
73
  return 'uv';
70
74
  const candidates = isWin
@@ -76,6 +80,36 @@ async function findUv(home) {
76
80
  }
77
81
  return null;
78
82
  }
83
+ /** GitHub API 请求头:有 GITHUB_TOKEN/GH_TOKEN 就带上——匿名限流 60/h 按出口 IP 算,
84
+ * CI runner 和企业 NAT 都是共享 IP,极易撞 403;带 token 后按账号 5000/h。 */
85
+ function ghHeaders(accept) {
86
+ const h = { accept, 'user-agent': 'dsh-paper-reader' };
87
+ const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
88
+ if (token)
89
+ h.authorization = `Bearer ${token}`;
90
+ return h;
91
+ }
92
+ /**
93
+ * fetch 带重试:用户网络抖一下(尤其国内到 GitHub)就直接失败太脆了。
94
+ * 只对网络错误和 5xx 重试,4xx 是确定性错误直接抛。
95
+ */
96
+ async function fetchWithRetry(url, init, tries = 3) {
97
+ let lastErr = null;
98
+ for (let i = 0; i < tries; i++) {
99
+ try {
100
+ const r = await fetch(url, init);
101
+ if (r.ok || (r.status >= 400 && r.status < 500))
102
+ return r;
103
+ lastErr = new Error(`HTTP ${r.status}`);
104
+ }
105
+ catch (e) {
106
+ lastErr = e;
107
+ }
108
+ if (i < tries - 1)
109
+ await new Promise((r) => setTimeout(r, 2000 * (i + 1)));
110
+ }
111
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
112
+ }
79
113
  /**
80
114
  * 下载 uv 独立二进制。全程走 api.github.com(资产接口 + octet-stream 直连),
81
115
  * 不依赖 github.com 页面域——后者在部分网络环境不可达,而 API 域通常可达。
@@ -92,8 +126,8 @@ async function downloadUv(home, onPhase) {
92
126
  await mkdir(tmp, { recursive: true });
93
127
  try {
94
128
  onPhase('正在安装翻译引擎(1/3 下载 uv)…');
95
- const rel = await fetch('https://api.github.com/repos/astral-sh/uv/releases/latest', {
96
- headers: { accept: 'application/vnd.github+json', 'user-agent': 'dsh-paper-reader' },
129
+ const rel = await fetchWithRetry('https://api.github.com/repos/astral-sh/uv/releases/latest', {
130
+ headers: ghHeaders('application/vnd.github+json'),
97
131
  signal: AbortSignal.timeout(30_000),
98
132
  });
99
133
  if (!rel.ok)
@@ -104,8 +138,8 @@ async function downloadUv(home, onPhase) {
104
138
  throw new Error(`uv 最新版没有 ${want} 资产`);
105
139
  const sumAsset = assets.find((a) => a.name === want + '.sha256');
106
140
  const download = async (a) => {
107
- const r = await fetch(`https://api.github.com/repos/astral-sh/uv/releases/assets/${a.id}`, {
108
- headers: { accept: 'application/octet-stream', 'user-agent': 'dsh-paper-reader' },
141
+ const r = await fetchWithRetry(`https://api.github.com/repos/astral-sh/uv/releases/assets/${a.id}`, {
142
+ headers: ghHeaders('application/octet-stream'),
109
143
  signal: AbortSignal.timeout(180_000),
110
144
  });
111
145
  if (!r.ok)
@@ -127,7 +161,10 @@ async function downloadUv(home, onPhase) {
127
161
  // 不带 -z:bsdtar(macOS 自带 / Win10 1803+ 的 tar.exe)和 GNU tar 解包时都自动探测格式,
128
162
  // 同一条命令通吃 tar.gz 与 zip(win32 的 uv 只发 zip)
129
163
  await run('tar', ['-xf', pkgPath, '-C', tmp], { timeout: 60_000 });
130
- const extracted = join(tmp, `uv-${triple}`, isWin ? 'uv.exe' : 'uv');
164
+ // tar.gz 有顶层目录 uv-<triple>/;win32 的 zip 是平铺的(uv.exe 直接在根)
165
+ const binName = isWin ? 'uv.exe' : 'uv';
166
+ const nested = join(tmp, `uv-${triple}`, binName);
167
+ const extracted = existsSync(nested) ? nested : join(tmp, binName);
131
168
  if (!existsSync(extracted))
132
169
  throw new Error('uv 解包结果不符合预期');
133
170
  const dst = uvPath(home);
@@ -210,13 +247,24 @@ async function installBabeldoc(uv, venvDir, onPhase) {
210
247
  await run(uv, ['venv', venvDir, '--python', '3.12', '--python-preference', 'only-managed'], { timeout: 10 * 60_000 });
211
248
  }
212
249
  onPhase('正在安装翻译引擎(3/3 安装 babeldoc,首次约几分钟)…');
213
- try {
214
- await run(uv, ['pip', 'install', '--python', py, 'babeldoc'], { timeout: 20 * 60_000 });
215
- }
216
- catch (uvErr) {
250
+ const viaPipFallback = async (cause) => {
217
251
  onPhase('正在安装翻译引擎(3/3 备用通道:pip + 国内镜像)…');
218
- console.warn('[dsh-paper-reader] uv 安装 babeldoc 失败,改用 pip 镜像通道:', uvErr instanceof Error ? uvErr.message : uvErr);
252
+ if (cause) {
253
+ console.warn('[dsh-paper-reader] uv 安装 babeldoc 失败,改用 pip 镜像通道:', cause instanceof Error ? cause.message : cause);
254
+ }
219
255
  await pipInstallBabeldoc(py);
256
+ };
257
+ // 测试钩子:DSH_PR_FORCE_PIP_CHANNEL=1 跳过 uv 通道,让 CI 确定性覆盖 pip 备用路径
258
+ //(杀软锁 uv 入口 exe、PyPI 截断时的逃生通道,Windows 上最关键的一条路径)
259
+ if (process.env.DSH_PR_FORCE_PIP_CHANNEL === '1')
260
+ await viaPipFallback(null);
261
+ else {
262
+ try {
263
+ await run(uv, ['pip', 'install', '--python', py, 'babeldoc'], { timeout: 20 * 60_000 });
264
+ }
265
+ catch (uvErr) {
266
+ await viaPipFallback(uvErr);
267
+ }
220
268
  }
221
269
  if (!existsSync(bin))
222
270
  throw new Error('babeldoc 安装完成但未找到可执行文件');
package/dist/host.js CHANGED
@@ -3,8 +3,8 @@
3
3
  import { createReadStream } from 'node:fs';
4
4
  import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
5
5
  import { randomUUID } from 'node:crypto';
6
- import { join } from 'node:path';
7
- import { listPapers, listTopics, resolveDataDir, resolvePaper } from "./library.js";
6
+ import { basename, join } from 'node:path';
7
+ import { deletePaper, deleteTopic, listPapers, listTopics, paperRefFor, renamePaper, renameTopic, resolveDataDir, resolvePaper, } from "./library.js";
8
8
  import { readTranscript, transcribePaper } from "./transcribe.js";
9
9
  import { pdfVariantPath, restartTranslation, startTranslation, zhStatus } from "./translate.js";
10
10
  import { clearTranslateConfig, maskApiKey, readTranslateConfig, resolveTranslateEndpoint, testTranslateEndpoint, writeTranslateConfig, } from "./translate-config.js";
@@ -169,7 +169,8 @@ export function registerRoutes(ctx, config) {
169
169
  return;
170
170
  }
171
171
  if (sub === '/api/library' && req.method === 'GET') {
172
- json(res, 200, { dataDir, topics: listTopics(dataDir), papers: listPapers(dataDir) });
172
+ const papers = listPapers(dataDir).map((p) => ({ ...p, pdfPath: join(dataDir, p.topic, p.name + '.pdf') }));
173
+ json(res, 200, { dataDir, topics: listTopics(dataDir), papers });
173
174
  return;
174
175
  }
175
176
  // 新建专题(目录)
@@ -213,6 +214,123 @@ export function registerRoutes(ctx, config) {
213
214
  json(res, 200, { ok: true, topic: topic.trim(), name: safeName.slice(0, -4), bytes: buf.length });
214
215
  return;
215
216
  }
217
+ /* ---- 库管理:专题/文献 改名与删除。----
218
+ * 翻译进行中(busy)一律 409 拒绝——babeldoc 异步跑着会往旧路径写 -zh/-dual 产物,
219
+ * 改名/删除会让它写丢;busy 键控在旧路径上,动了就失联。
220
+ * Windows 下删除被阅读器 iframe 占用的文件可能 EBUSY/EPERM,转成可读文案。 */
221
+ const busyPapersInTopic = (topic) => listPapers(dataDir, topic)
222
+ .filter((p) => zhStatus(paperRefFor(dataDir, p.topic, p.name)).busy)
223
+ .map((p) => p.name);
224
+ const fsError = (res2, err) => {
225
+ const e = err;
226
+ if (e?.code === 'EBUSY' || e?.code === 'EPERM') {
227
+ json(res2, 409, { error: '文件被占用(可能正在阅读器或其他程序中打开),关闭后重试' });
228
+ }
229
+ else {
230
+ json(res2, 400, { error: e instanceof Error ? e.message : String(err) });
231
+ }
232
+ };
233
+ if (sub === '/api/library/rename-topic' && req.method === 'POST') {
234
+ const body = (await readBody(req));
235
+ if (!body.topic?.trim()) {
236
+ json(res, 400, { error: '缺少 topic' });
237
+ return;
238
+ }
239
+ const busy = busyPapersInTopic(body.topic.trim());
240
+ if (busy.length) {
241
+ json(res, 409, { error: `以下文献正在翻译,稍后再试:${busy.join('、')}` });
242
+ return;
243
+ }
244
+ try {
245
+ renameTopic(dataDir, body.topic.trim(), body.to ?? '');
246
+ }
247
+ catch (err) {
248
+ fsError(res, err);
249
+ return;
250
+ }
251
+ workspaceCache.delete(join(dataDir, body.topic.trim())); // 旧目录的工作区记录失效(残留无害)
252
+ json(res, 200, { ok: true, topic: body.to?.trim() });
253
+ return;
254
+ }
255
+ if (sub === '/api/library/delete-topic' && req.method === 'POST') {
256
+ const body = (await readBody(req));
257
+ if (!body.topic?.trim()) {
258
+ json(res, 400, { error: '缺少 topic' });
259
+ return;
260
+ }
261
+ const busy = busyPapersInTopic(body.topic.trim());
262
+ if (busy.length) {
263
+ json(res, 409, { error: `以下文献正在翻译,稍后再试:${busy.join('、')}` });
264
+ return;
265
+ }
266
+ try {
267
+ deleteTopic(dataDir, body.topic.trim());
268
+ }
269
+ catch (err) {
270
+ fsError(res, err);
271
+ return;
272
+ }
273
+ json(res, 200, { ok: true });
274
+ return;
275
+ }
276
+ if (sub === '/api/library/rename-paper' && req.method === 'POST') {
277
+ const body = (await readBody(req));
278
+ if (!body.topic?.trim() || !body.name?.trim()) {
279
+ json(res, 400, { error: '缺少 topic / name' });
280
+ return;
281
+ }
282
+ let ref;
283
+ try {
284
+ ref = resolvePaper(dataDir, { topic: body.topic.trim(), name: body.name.trim() });
285
+ }
286
+ catch (err) {
287
+ fsError(res, err);
288
+ return;
289
+ }
290
+ if (zhStatus(ref).busy) {
291
+ json(res, 409, { error: '该文献正在翻译,稍后再试' });
292
+ return;
293
+ }
294
+ let renamed;
295
+ try {
296
+ renamed = renamePaper(dataDir, ref.topic, ref.name, body.to ?? '');
297
+ }
298
+ catch (err) {
299
+ fsError(res, err);
300
+ return;
301
+ }
302
+ json(res, 200, { ok: true, topic: ref.topic, name: body.to?.trim(), renamed });
303
+ return;
304
+ }
305
+ if (sub === '/api/library/delete-paper' && req.method === 'POST') {
306
+ const body = (await readBody(req));
307
+ if (!body.topic?.trim() || !body.name?.trim()) {
308
+ json(res, 400, { error: '缺少 topic / name' });
309
+ return;
310
+ }
311
+ let ref;
312
+ try {
313
+ ref = resolvePaper(dataDir, { topic: body.topic.trim(), name: body.name.trim() });
314
+ }
315
+ catch (err) {
316
+ fsError(res, err);
317
+ return;
318
+ }
319
+ if (zhStatus(ref).busy) {
320
+ json(res, 409, { error: '该文献正在翻译,稍后再试' });
321
+ return;
322
+ }
323
+ let deleted;
324
+ try {
325
+ deleted = deletePaper(dataDir, ref.topic, ref.name);
326
+ }
327
+ catch (err) {
328
+ fsError(res, err);
329
+ return;
330
+ }
331
+ json(res, 200, { ok: true, deleted });
332
+ return;
333
+ }
216
334
  // 新建伴读会话(会话N+1)。最新可见会话还是空白草稿时直接复用它——
217
335
  // 连点「新建」不再堆积空会话(毫无意义的点击什么都不存)。
218
336
  // n 用全量扫描分配(含归档/空白),绝不与磁盘上已有目录撞号。
@@ -262,6 +380,7 @@ export function registerRoutes(ctx, config) {
262
380
  json(res, 200, {
263
381
  topic: ref.topic,
264
382
  name: ref.name,
383
+ pdfPath: ref.pdfPath,
265
384
  pdfBytes: st.size,
266
385
  hasTranscript: t !== null,
267
386
  chars: t?.text.length ?? 0,
@@ -274,11 +393,18 @@ export function registerRoutes(ctx, config) {
274
393
  const ref = locate(url);
275
394
  const pdfPath = pdfVariantPath(ref, url.searchParams.get('variant'));
276
395
  const st = await stat(pdfPath); // 变体不存在时 404/500 由外层兜底
277
- res.writeHead(200, {
396
+ // download=1 → 附件下载(导出译文/原文);文件名即磁盘名 <名>.pdf / -zh.pdf / -dual.pdf
397
+ const headers = {
278
398
  'content-type': 'application/pdf',
279
399
  'content-length': st.size,
280
400
  'cache-control': 'no-cache', // 切换文献/重新生成后必须拿到新的
281
- });
401
+ };
402
+ if (url.searchParams.get('download') === '1') {
403
+ const name = basename(pdfPath);
404
+ headers['content-disposition'] =
405
+ `attachment; filename="paper.pdf"; filename*=UTF-8''${encodeURIComponent(name)}`;
406
+ }
407
+ res.writeHead(200, headers);
282
408
  createReadStream(pdfPath).pipe(res);
283
409
  return;
284
410
  }
package/dist/library.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // <dataDir>/<专题>/<文献>.pages.json 页码偏移表(转录时生成,检索映射页码用)
7
7
  // pdf2zh 的产物(-en / -zh / -dual)不算用户文献。
8
8
  // (问答不落盘存档——会话持久化交给 dsh 自己;pdfqa 时代的 -qa/ 目录不再读写。)
9
- import { readdirSync, existsSync, mkdirSync, statSync } from 'node:fs';
9
+ import { readdirSync, existsSync, mkdirSync, statSync, renameSync, rmSync } from 'node:fs';
10
10
  import { join, resolve, extname, basename } from 'node:path';
11
11
  import { homedir } from 'node:os';
12
12
  const PDF2ZH_SUFFIXES = ['-en', '-zh', '-dual'];
@@ -101,3 +101,94 @@ export function transcriptCached(ref) {
101
101
  return false;
102
102
  }
103
103
  }
104
+ // ================= 库管理:改名/删除(纯文件系统操作,供 host 路由调用) =================
105
+ /** 名字校验(专题/文献共用)。合法返回 null,否则返回中文错误文案。 */
106
+ export function validateEntryName(raw) {
107
+ const name = raw.trim();
108
+ if (!name)
109
+ return '名字不能为空';
110
+ if (name.length > 120)
111
+ return '名字过长(最多 120 个字符)';
112
+ if (/[\\/]|\.\./.test(name))
113
+ return '名字不能包含 / \\ 或 ..';
114
+ if (name.startsWith('.'))
115
+ return '名字不能以 . 开头';
116
+ return null;
117
+ }
118
+ /** 构造文献引用(不做存在性校验;给只按路径判定的调用方用,如翻译 busy 检查)。 */
119
+ export function paperRefFor(dataDir, topic, name) {
120
+ const pdfPath = join(dataDir, topic, name + '.pdf');
121
+ const base = pdfPath.slice(0, -'.pdf'.length);
122
+ return { topic, name, pdfPath, txtPath: base + '.txt', pagesPath: base + '.pages.json' };
123
+ }
124
+ /** 文献的全部关联文件后缀(含本体 .pdf):转录缓存 + 页码索引 + babeldoc 译文 + 学习档案。
125
+ * 命名与 translate.ts(-zh/-dual)和 study.ts(.study.json)的产物规则一致。 */
126
+ const PAPER_SIDECARS = ['.pdf', '.txt', '.pages.json', '-zh.pdf', '-dual.pdf', '.study.json'];
127
+ /** 某文献的全部关联文件路径(存在与否由调用方自查)。 */
128
+ export function derivedFilesFor(pdfPath) {
129
+ const stem = pdfPath.slice(0, -'.pdf'.length);
130
+ return PAPER_SIDECARS.map((s) => stem + s);
131
+ }
132
+ /** 改名专题(目录 rename)。from 不存在 / to 已存在 / 名字非法 → throw 中文 Error。
133
+ * 已知局限:大小写不敏感文件系统(macOS/Windows)上纯大小写改名会被「目标已存在」
134
+ * 拒绝——不做临时名两跳绕过,保持简单。 */
135
+ export function renameTopic(dataDir, from, to) {
136
+ const err = validateEntryName(to);
137
+ if (err)
138
+ throw new Error(err);
139
+ if (to.trim() === from)
140
+ return;
141
+ const src = join(dataDir, from);
142
+ if (!existsSync(src))
143
+ throw new Error(`专题不存在: ${from}`);
144
+ const dst = join(dataDir, to.trim());
145
+ if (existsSync(dst))
146
+ throw new Error(`已存在同名专题: ${to.trim()}`);
147
+ renameSync(src, dst);
148
+ }
149
+ /** 改名文献:连带派生文件(转录/页码索引/译文/学习档案)逐个跟随,不存在的跳过。
150
+ * 返回实际改名的文件名清单(含 .pdf 本体)。 */
151
+ export function renamePaper(dataDir, topic, from, to) {
152
+ const err = validateEntryName(to);
153
+ if (err)
154
+ throw new Error(err);
155
+ to = to.trim();
156
+ if (to === from)
157
+ return [];
158
+ const dir = join(dataDir, topic);
159
+ if (!existsSync(join(dir, from + '.pdf')))
160
+ throw new Error(`文献不存在: ${topic}/${from}`);
161
+ if (existsSync(join(dir, to + '.pdf')))
162
+ throw new Error(`已存在同名文献: ${to}`);
163
+ const renamed = [];
164
+ for (const suf of PAPER_SIDECARS) {
165
+ const src = join(dir, from + suf);
166
+ if (!existsSync(src))
167
+ continue;
168
+ renameSync(src, join(dir, to + suf));
169
+ renamed.push(from + suf);
170
+ }
171
+ return renamed;
172
+ }
173
+ /** 删除文献及其全部派生文件。返回实际删除的文件名清单。 */
174
+ export function deletePaper(dataDir, topic, name) {
175
+ const dir = join(dataDir, topic);
176
+ const pdfPath = join(dir, name + '.pdf');
177
+ if (!existsSync(pdfPath))
178
+ throw new Error(`文献不存在: ${topic}/${name}`);
179
+ const deleted = [];
180
+ for (const f of derivedFilesFor(pdfPath)) {
181
+ if (!existsSync(f))
182
+ continue;
183
+ rmSync(f);
184
+ deleted.push(basename(f));
185
+ }
186
+ return deleted;
187
+ }
188
+ /** 删除专题(递归,含其下全部文献与派生文件)。 */
189
+ export function deleteTopic(dataDir, topic) {
190
+ const dir = join(dataDir, topic);
191
+ if (!existsSync(dir))
192
+ throw new Error(`专题不存在: ${topic}`);
193
+ rmSync(dir, { recursive: true, force: true });
194
+ }
package/lib/client.js CHANGED
@@ -31,6 +31,8 @@ window.__ModuleLoader__.load({
31
31
  empty: '文献库为空', hint: '新建专题或上传 PDF 开始', newTopic: '新建专题',
32
32
  cancel: '取消', ok: '确定', defaultTopic: '默认专题', uploadBtn: '上传 PDF',
33
33
  newTopicFail: '新建专题失败',
34
+ rename: '重命名', renameTopic: '重命名专题', renamePaper: '重命名论文',
35
+ del: '删除', delArm: '确认删除?', renameFail: '重命名失败', delFail: '删除失败',
34
36
  upload: '上传 PDF 到此专题', newChat: '新建对话', session: '会话', pdfTab: '论文 PDF',
35
37
  history: '历史对话',
36
38
  sync: '对话还停在另一篇文献的会话里,点击切到当前文献',
@@ -52,6 +54,8 @@ window.__ModuleLoader__.load({
52
54
  empty: 'Library is empty', hint: 'Create a topic or upload a PDF to start', newTopic: 'New topic',
53
55
  cancel: 'Cancel', ok: 'OK', defaultTopic: 'Default', uploadBtn: 'Upload PDF',
54
56
  newTopicFail: 'Failed to create topic',
57
+ rename: 'Rename', renameTopic: 'Rename topic', renamePaper: 'Rename paper',
58
+ del: 'Delete', delArm: 'Delete?', renameFail: 'Rename failed', delFail: 'Delete failed',
55
59
  upload: 'Upload PDF into this topic', newChat: 'New chat', session: 'Session', pdfTab: 'Paper PDF',
56
60
  history: 'Chat history',
57
61
  sync: 'Chat is still on another paper — click to switch to the current one',
@@ -112,6 +116,37 @@ window.__ModuleLoader__.load({
112
116
  return r.json()
113
117
  }
114
118
 
119
+ /* ---------- 改名搬家:论文粒度的本地记忆键跟随新身份 ----------
120
+ dpr.variant.<topic>/<name>(语言版本记忆)、dpr.sessionN.<topic>/<name>(会话序号)、
121
+ dpr.last(上次打开)。论文改名精确搬单键;专题改名把该专题前缀的键整体搬。 */
122
+ function migrateLsKeys(oldTopic, newTopic, oldName, newName) {
123
+ try {
124
+ if (oldName != null) {
125
+ for (const base of ['dpr.variant.', 'dpr.sessionN.']) {
126
+ const from = base + oldTopic + '/' + oldName
127
+ const to = base + newTopic + '/' + newName
128
+ const v = localStorage.getItem(from)
129
+ if (v != null) { localStorage.setItem(to, v); localStorage.removeItem(from) }
130
+ }
131
+ } else {
132
+ for (const base of ['dpr.variant.', 'dpr.sessionN.']) {
133
+ const pre = base + oldTopic + '/'
134
+ for (let i = localStorage.length - 1; i >= 0; i--) {
135
+ const k = localStorage.key(i)
136
+ if (k && k.startsWith(pre)) {
137
+ localStorage.setItem(base + newTopic + '/' + k.slice(pre.length), localStorage.getItem(k))
138
+ localStorage.removeItem(k)
139
+ }
140
+ }
141
+ }
142
+ }
143
+ const last = JSON.parse(localStorage.getItem('dpr.last') || 'null')
144
+ if (last && last.topic === oldTopic && (oldName == null || last.name === oldName)) {
145
+ localStorage.setItem('dpr.last', JSON.stringify({ ...last, topic: newTopic, ...(oldName != null ? { name: newName } : {}) }))
146
+ }
147
+ } catch { /* 隐私模式等写不了就算了 */ }
148
+ }
149
+
115
150
  /* 当前在屏(主视图)会话 id:0.1.x 是会话列表快照的 .current 字段;
116
151
  0.2.0 起快照没有该字段,官方以 retainedBy.mainView>0 标记主视图保留的会话
117
152
  (dsh-client-ui-workspace 自己的 current() 就是这么读的)—— 两代都取到。 */
@@ -194,6 +229,8 @@ window.__ModuleLoader__.load({
194
229
  const lastShownRef = React.useRef(0)
195
230
  useEffect(() => {
196
231
  const on = (e) => {
232
+ // 空库(最后一篇被删):撤掉阅读器 iframe,回到「从文献库选择一篇论文」占位
233
+ if (e.detail.empty) { try { localStorage.removeItem('dpr.last') } catch {}; setCur(null); return }
197
234
  // 跳转链接挂的页码/引文(jumpToPaperPage 的跨论文路径):随 src 传给阅读器 bootstrap
198
235
  const j = pendingJump
199
236
  pendingJump = null
@@ -512,8 +549,8 @@ window.__ModuleLoader__.load({
512
549
  (实测同版本 Electron 调用即抛 "prompt() is not supported",桌面端点「+」
513
550
  曾因此毫无反应),一律走 createPortal 挂 document.body,避免被侧栏 overflow 裁剪。 ---------- */
514
551
  function AskText(props) {
515
- const { title, confirmText, cancelText, onConfirm, onCancel } = props
516
- const [v, setV] = useState('')
552
+ const { title, confirmText, cancelText, onConfirm, onCancel, initial } = props
553
+ const [v, setV] = useState(() => initial ?? '')
517
554
  const ref = React.useRef(null)
518
555
  useEffect(() => { ref.current?.focus() }, [])
519
556
  const submit = () => { const s = v.trim(); if (s) onConfirm(s) }
@@ -692,6 +729,76 @@ window.__ModuleLoader__.load({
692
729
  // supported",点击无任何反应),改为 AskText 页内弹窗(见 LibraryTree return)。
693
730
  const newTopic = useCallback(() => setAskTopic(true), [])
694
731
 
732
+ /* ---------- 库管理:改名/删除(用户反馈补齐) ----------
733
+ 已知代价(README 明示):sessionId 编码旧 topic/name,改名后历史伴读会话从树上
734
+ 消失(会话本体仍在 dsh 全局列表);study.json/译文/转录缓存随文件改名全部保留。 */
735
+ const [renaming, setRenaming] = useState(null) // {kind:'topic'|'paper', topic, name}
736
+ const [armDel, setArmDel] = useState(null) // {kind, topic, name}:两段式删除的待确认态
737
+
738
+ // 两段式删除的待确认态 3s 自动复位(复刻阅读器 ↻ 重译按钮的 arm 模式)
739
+ useEffect(() => {
740
+ if (!armDel) return undefined
741
+ const timer = setTimeout(() => setArmDel(null), 3000)
742
+ return () => clearTimeout(timer)
743
+ }, [armDel])
744
+
745
+ const doRename = useCallback(async (kind, topic, name, to) => {
746
+ const newTopicName = kind === 'topic' ? to : topic
747
+ const newName = kind === 'paper' ? to : name
748
+ try {
749
+ await api(`/library/rename-${kind}`, {
750
+ method: 'POST', headers: { 'content-type': 'application/json' },
751
+ body: JSON.stringify(kind === 'topic' ? { topic, to } : { topic, name, to }),
752
+ })
753
+ } catch (err) { toast(t('renameFail') + ': ' + err.message); return }
754
+ // 本地记忆(语言版本/会话序号/上次打开/折叠态)搬到新身份
755
+ migrateLsKeys(topic, newTopicName, kind === 'paper' ? name : null, kind === 'paper' ? newName : null)
756
+ setSessions((m) => { const c = { ...m }; delete c[`${topic}/${name}`]; return c })
757
+ if (kind === 'topic') setClosed((c) => {
758
+ if (!(topic in c)) return c
759
+ const n = { ...c }; n[to] = c[topic]; delete n[topic]
760
+ try { localStorage.setItem('dpr.treeClosed', JSON.stringify(n)) } catch {}
761
+ return n
762
+ })
763
+ // 改的是当前论文(或其所在专题)→ 树与阅读器 iframe 跟到新身份
764
+ if (cur && (kind === 'topic' ? cur.topic === topic : (cur.topic === topic && cur.name === name))) {
765
+ setCur((c) => ({ ...c, topic: newTopicName, name: newName }))
766
+ window.dispatchEvent(new CustomEvent('dpr:paper', { detail: { topic: newTopicName, name: newName } }))
767
+ }
768
+ refresh()
769
+ }, [cur, refresh])
770
+
771
+ const doDelete = useCallback(async (kind, topic, name) => {
772
+ try {
773
+ await api(`/library/delete-${kind}`, {
774
+ method: 'POST', headers: { 'content-type': 'application/json' },
775
+ body: JSON.stringify(kind === 'topic' ? { topic } : { topic, name }),
776
+ })
777
+ } catch (err) { toast(t('delFail') + ': ' + err.message); return }
778
+ setSessions((m) => { const c = { ...m }; delete c[`${topic}/${name}`]; return c })
779
+ refresh()
780
+ // 删的是当前论文(或其所在专题)→ 切到剩余第一篇;全空则收起阅读器
781
+ if (cur && (kind === 'topic' ? cur.topic === topic : (cur.topic === topic && cur.name === name))) {
782
+ const d = await api('/library').catch(() => null)
783
+ const next = d?.papers?.[0] || null
784
+ if (next) {
785
+ setCur({ topic: next.topic, name: next.name })
786
+ window.dispatchEvent(new CustomEvent('dpr:paper', { detail: { topic: next.topic, name: next.name } }))
787
+ } else {
788
+ setCur(null)
789
+ try { localStorage.removeItem('dpr.last') } catch {}
790
+ window.dispatchEvent(new CustomEvent('dpr:paper', { detail: { empty: true } }))
791
+ }
792
+ }
793
+ }, [cur, refresh])
794
+
795
+ // 两段式删除点击:第一击进入待确认(按钮变红显示「确认删除?」),第二击执行
796
+ const clickDelete = useCallback((kind, topic, name) => {
797
+ const armed = armDel && armDel.kind === kind && armDel.topic === topic && armDel.name === name
798
+ if (armed) { setArmDel(null); doDelete(kind, topic, name); return }
799
+ setArmDel({ kind, topic, name })
800
+ }, [armDel, doDelete])
801
+
695
802
  const upload = useCallback((topic) => {
696
803
  uploadTopic.current = topic
697
804
  fileInput.current?.click()
@@ -770,9 +877,10 @@ window.__ModuleLoader__.load({
770
877
  h('button', { type: 'button', style: S.linkBtn, onClick: newTopic }, '+ ', t('newTopic')))),
771
878
  topics.map((topic) => {
772
879
  const isClosed = !!closed[topic]
880
+ const topicArmed = armDel && armDel.kind === 'topic' && armDel.topic === topic
773
881
  return h('div', { key: topic },
774
882
  h('div', {
775
- style: S.topicHead,
883
+ style: S.topicHead, 'data-dpr-row': '',
776
884
  onClick: () => {
777
885
  const next = { ...closed, [topic]: !isClosed }
778
886
  setClosed(next)
@@ -785,16 +893,39 @@ window.__ModuleLoader__.load({
785
893
  style: S.iconBtn, title: t('upload'),
786
894
  onClick: (e) => { e.stopPropagation(); upload(topic) },
787
895
  }, '⬆'),
896
+ h('button', {
897
+ 'data-dpr-act': 'rename', style: S.iconBtn, title: t('rename'),
898
+ onClick: (e) => { e.stopPropagation(); setRenaming({ kind: 'topic', topic }) },
899
+ }, '✎'),
900
+ h('button', {
901
+ 'data-dpr-act': 'del', ...(topicArmed ? { 'data-dpr-armed': '' } : {}),
902
+ style: topicArmed ? { ...S.iconBtn, color: 'var(--dsw-alias-state-error-primary, #cf222e)' } : S.iconBtn,
903
+ title: t('del'),
904
+ onClick: (e) => { e.stopPropagation(); clickDelete('topic', topic, undefined) },
905
+ }, topicArmed ? t('delArm') : '🗑'),
788
906
  ),
789
907
  !isClosed && h('div', { style: S.items },
790
908
  papers.filter((p) => p.topic === topic).map((p) => {
791
909
  const isCurrentPaper = cur && cur.topic === p.topic && cur.name === p.name
910
+ const paperArmed = armDel && armDel.kind === 'paper' && armDel.topic === p.topic && armDel.name === p.name
792
911
  return h('div', {
793
912
  key: p.name,
794
913
  style: { ...S.item, ...(isCurrentPaper ? S.active : {}) },
795
- title: p.name,
914
+ 'data-dpr-row': '',
915
+ title: p.pdfPath || p.name,
796
916
  onClick: () => enter(p.topic, p.name),
797
- }, '📄 ', h('span', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' } }, p.name))
917
+ }, '📄 ',
918
+ h('span', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' } }, p.name),
919
+ h('button', {
920
+ 'data-dpr-act': 'rename', style: S.iconBtn, title: t('rename'),
921
+ onClick: (e) => { e.stopPropagation(); setRenaming({ kind: 'paper', topic: p.topic, name: p.name }) },
922
+ }, '✎'),
923
+ h('button', {
924
+ 'data-dpr-act': 'del', ...(paperArmed ? { 'data-dpr-armed': '' } : {}),
925
+ style: paperArmed ? { ...S.iconBtn, color: 'var(--dsw-alias-state-error-primary, #cf222e)' } : S.iconBtn,
926
+ title: t('del'),
927
+ onClick: (e) => { e.stopPropagation(); clickDelete('paper', p.topic, p.name) },
928
+ }, paperArmed ? t('delArm') : '🗑'))
798
929
  }),
799
930
  ),
800
931
  )
@@ -816,6 +947,19 @@ window.__ModuleLoader__.load({
816
947
  onCancel: () => setAskTopic(false),
817
948
  }),
818
949
  document.body),
950
+ renaming && createPortal(
951
+ h(AskText, {
952
+ title: (renaming.kind === 'topic' ? t('renameTopic') : t('renamePaper')) + ':',
953
+ initial: renaming.kind === 'topic' ? renaming.topic : renaming.name,
954
+ confirmText: t('ok'), cancelText: t('cancel'),
955
+ onConfirm: (name) => {
956
+ const r = renaming
957
+ setRenaming(null)
958
+ doRename(r.kind, r.topic, r.name, name)
959
+ },
960
+ onCancel: () => setRenaming(null),
961
+ }),
962
+ document.body),
819
963
  )
820
964
  }
821
965
 
@@ -979,6 +1123,9 @@ window.__ModuleLoader__.load({
979
1123
  const styleEl = document.createElement('style')
980
1124
  styleEl.id = 'dpr-surface-css'
981
1125
  styleEl.textContent = '[data-dpr-rail]:hover,[data-dpr-mode]:hover{background:var(--dsw-alias-interactive-bg-hover)!important}'
1126
+ // 树行内的 ✎🗑 操作按钮:默认隐身,行悬停才现身(两段式删除的待确认态常亮)
1127
+ + '[data-dpr-act]{opacity:0!important;transition:opacity .12s}'
1128
+ + '[data-dpr-row]:hover [data-dpr-act],[data-dpr-armed]{opacity:1!important}'
982
1129
  document.head.appendChild(styleEl)
983
1130
  }
984
1131
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggboy123/dsh-paper-reader",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "DeepSeek Harness 插件:论文伴读工作台 — PDF 转录 / 检索 / 原生对话伴读 + 内置阅读器页面。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,6 +45,7 @@
45
45
  "scripts": {
46
46
  "build": "tsc -p tsconfig.json",
47
47
  "typecheck": "tsc -p tsconfig.json --noEmit",
48
+ "smoke:engine": "node scripts/engine-smoke.mjs",
48
49
  "prepublishOnly": "tsc -p tsconfig.json"
49
50
  },
50
51
  "keywords": [
package/reader/index.html CHANGED
@@ -32,12 +32,22 @@
32
32
  /* min-width:0 —— flex 项默认 min-width:auto 会拒绝收缩,空间不够时转而去挤按钮;
33
33
  让标题自己截断(已有 ellipsis),按钮才能保持原样 */
34
34
  #status { min-width: 0; color: var(--text-2); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
35
+ /* PDF 文件路径:可点的只读文本(点击复制),窄列自动截断 */
36
+ #pdf-path { flex: none; max-width: 34%; padding: 0; border: none; background: none; color: var(--text-2); font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
37
+ #pdf-path:hover { color: var(--accent); }
35
38
 
36
39
  /* ---------- PDF 区 ---------- */
37
40
  #main { display: flex; flex: 1; min-height: 0; }
38
41
  #pdf-pane { flex: 1; overflow: auto; padding: 16px; }
39
42
  /* safe center:页宽超过视口时退回左对齐——普通 center 会让左右同时溢出且左侧滚不到 */
40
43
  #pdf-pages { display: flex; flex-direction: column; align-items: safe center; gap: 14px; }
44
+ /* 翻页模式:单页垂直居中(safe center 同理,放大后顶部仍可滚到) */
45
+ #pdf-pages.paged { min-height: 100%; justify-content: safe center; gap: 0; }
46
+
47
+ /* ---------- 翻页控件 ---------- */
48
+ #pager { display: flex; align-items: center; gap: 4px; flex: none; }
49
+ #page-input { width: 3.5em; padding: 3px 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; font-size: 12.5px; text-align: right; }
50
+ #page-total { flex: none; color: var(--text-2); font-size: 12px; white-space: nowrap; }
41
51
  .page { position: relative; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.18); }
42
52
  .page.flash { outline: 3px solid var(--hl); outline-offset: 2px; transition: outline .15s; }
43
53
  /* textLayer CSS 必须是 pdf.js 6.x 官方规则的子集:TextLayer 只往 span 写 --font-height/--scale-x/--rotate
@@ -99,9 +109,18 @@
99
109
 
100
110
  <div id="bar">
101
111
  <span id="status">论文伴读</span>
112
+ <button id="pdf-path" style="display:none" title="点击复制文件路径"></button>
102
113
  <button id="zh-toggle" class="tbtn" title="切换中文译文(没有译文时可生成)" style="display:none">中</button>
103
114
  <button id="zh-regen" class="tbtn" title="重新翻译:删除现有译文,用当前端点重跑(换模型后用)" style="display:none">↻</button>
115
+ <button id="dl-btn" class="tbtn" title="下载 PDF" style="display:none">⤓</button>
104
116
  <span class="spacer"></span>
117
+ <button id="mode-toggle" class="tbtn" title="切换 滚动/翻页模式" style="display:none">▣</button>
118
+ <span id="pager" style="display:none">
119
+ <button id="page-prev" class="tbtn" title="上一页">‹</button>
120
+ <input id="page-input" type="text" inputmode="numeric" title="页码,回车跳转">
121
+ <span id="page-total">/ ?</span>
122
+ <button id="page-next" class="tbtn" title="下一页">›</button>
123
+ </span>
105
124
  <button id="cfg-open" class="tbtn" title="翻译端点设置">⚙</button>
106
125
  <button id="zoom-out" class="tbtn">−</button>
107
126
  <span id="zoom-label">100%</span>
@@ -168,6 +187,18 @@ function toast(msg, ms = 3200) {
168
187
  }
169
188
  function esc(s) { return s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
170
189
 
190
+ // 路径复制:clipboard API 失败(iframe 权限等)退回 textarea + execCommand
191
+ $('pdf-path').onclick = async () => {
192
+ const p = $('pdf-path').textContent;
193
+ if (!p) return;
194
+ try { await navigator.clipboard.writeText(p); toast('路径已复制'); return; } catch { /* 走兜底 */ }
195
+ const ta = document.createElement('textarea');
196
+ ta.value = p; document.body.appendChild(ta); ta.select();
197
+ try { document.execCommand('copy'); toast('路径已复制'); }
198
+ catch { toast('复制失败,路径见按钮悬停提示'); }
199
+ ta.remove();
200
+ };
201
+
171
202
  let current = null; // { topic, name }
172
203
  let zhState = { zh: false, dual: false, busy: false };
173
204
  let statusMeta = ''; // openPaper 的「name · N 页」基线,翻译阶段标签褪去后恢复它
@@ -176,6 +207,11 @@ let zhVariant = null; // null=原文 'dual'=中英对照 'zh'=纯中文
176
207
  // 加上这里的恢复,「中文界面跳中文」在同篇跳转/重开/跨论文三条路径上都成立
177
208
  const variantKey = (topic, name) => `dpr.variant.${topic}/${name}`;
178
209
 
210
+ // 阅读模式:scroll=连续滚动(默认,原行为)paged=单页翻页;每读者记忆
211
+ let pageMode = localStorage.getItem('dpr.pageMode') === 'paged' ? 'paged' : 'scroll';
212
+ // 当前页:paged 是唯一渲染依据;scroll 由 IntersectionObserver 跟随回写(模式切换/跳页共享)
213
+ let curPage = 1;
214
+
179
215
  /* ================= 打开文献 ================= */
180
216
  function pdfUrl(variant) {
181
217
  const q = `topic=${encodeURIComponent(current.topic)}&name=${encodeURIComponent(current.name)}`;
@@ -186,6 +222,7 @@ async function openPaper(topic, name, opts = {}) {
186
222
  if (!name) return;
187
223
  current = { topic, name };
188
224
  localStorage.setItem('dpr.last', JSON.stringify(current));
225
+ curPage = opts.page || 1; // 换论文从第一页(或跳转目标页)开始;变体切换/重译不走这里,页码保持
189
226
  $('status').textContent = '加载 PDF…';
190
227
  const q = `topic=${encodeURIComponent(topic)}&name=${encodeURIComponent(name)}`;
191
228
  const meta = await api('/paper?' + q);
@@ -193,16 +230,43 @@ async function openPaper(topic, name, opts = {}) {
193
230
  ? `${name} · ${meta.pageCount ?? '?'} 页`
194
231
  : `${name} · 未转录(提问时会自动转录)`;
195
232
  $('status').textContent = statusMeta;
233
+ // 文件路径展示(点击复制):独立于 #status,翻译阶段的 phase 覆写不影响它
234
+ const pathBtn = $('pdf-path');
235
+ pathBtn.textContent = meta.pdfPath || '';
236
+ pathBtn.title = (meta.pdfPath || '') + '(点击复制)';
237
+ pathBtn.style.display = meta.pdfPath ? '' : 'none';
196
238
  await refreshZhState();
197
239
  // 恢复该论文上次的语言版本(译文已不存在时回落原文)
198
240
  const saved = localStorage.getItem(variantKey(topic, name));
199
241
  zhVariant = (saved === 'zh' && zhState.zh) || (saved === 'dual' && zhState.dual) ? saved : null;
200
242
  $('zh-toggle').classList.toggle('active', zhVariant !== null);
201
243
  if (zhVariant) { fitMode = true; fitBtn.classList.add('active'); } // 译文版页宽不同,强制适宽
244
+ updateDlBtn();
202
245
  await loadPdf(pdfUrl(zhVariant));
203
246
  if (opts.page) jumpTo(opts.page, opts.q); // 不 await:跳转内部自带「等页面渲出来」轮询
204
247
  }
205
248
 
249
+ /* ================= 下载/导出 ================= */
250
+ // 下载当前正在看的那个版本(原文/纯中文/中英对照)——导出译文的主要入口
251
+ function updateDlBtn() {
252
+ const btn = $('dl-btn');
253
+ if (!current) { btn.style.display = 'none'; return; }
254
+ btn.style.display = '';
255
+ btn.title = zhVariant === 'zh' ? '下载:纯中文版 PDF'
256
+ : zhVariant === 'dual' ? '下载:中英对照版 PDF' : '下载:原文 PDF';
257
+ }
258
+ function downloadCurrent() {
259
+ if (!current) return;
260
+ // 同源请求自动带 cookie(鉴权围栏);download 属性空值 → 文件名取 content-disposition
261
+ const a = document.createElement('a');
262
+ a.href = pdfUrl(zhVariant) + '&download=1';
263
+ a.download = '';
264
+ document.body.appendChild(a);
265
+ a.click();
266
+ a.remove();
267
+ }
268
+ $('dl-btn').onclick = downloadCurrent;
269
+
206
270
  /* ================= 中英切换 ================= */
207
271
  async function refreshZhState() {
208
272
  zhState = await api(`/zh?topic=${encodeURIComponent(current.topic)}&name=${encodeURIComponent(current.name)}`);
@@ -236,6 +300,7 @@ $('zh-toggle').onclick = async () => {
236
300
  zhVariant = zhVariant === null ? (zhState.zh ? 'zh' : 'dual') : null;
237
301
  localStorage.setItem(variantKey(current.topic, current.name), zhVariant ?? '');
238
302
  $('zh-toggle').classList.toggle('active', zhVariant !== null);
303
+ updateDlBtn();
239
304
  // 切换变体后强制适宽:译文版页宽与原文不同(对照双栏≈2×),不重算会溢出裁切
240
305
  fitMode = true; fitBtn.classList.add('active');
241
306
  await loadPdf(pdfUrl(zhVariant));
@@ -428,7 +493,7 @@ let renderToken = 0;
428
493
  mq.addEventListener('change', function onChange() {
429
494
  mq.removeEventListener('change', onChange);
430
495
  dpr = window.devicePixelRatio || 1;
431
- if (pdfDoc) renderAllPages();
496
+ if (pdfDoc) rerender();
432
497
  watchDpr();
433
498
  });
434
499
  })();
@@ -437,62 +502,129 @@ async function loadPdf(url) {
437
502
  await ensurePdfjs(); // 就绪前禁止触 pdfjsLib(defer 竞态,见文件头注释)
438
503
  pdfDoc = await pdfjsLib.getDocument({ url }).promise; // v6 起不接受字符串参数,必须传对象
439
504
  $('empty').style.display = 'none';
440
- if (fitMode) { fitToWidth(); return; } // fitToWidth 内部 setZoom → renderAllPages
441
- await renderAllPages();
505
+ // 翻页控件与模式切换按钮:文档就绪后才出现
506
+ $('pager').style.display = '';
507
+ $('mode-toggle').style.display = '';
508
+ if (fitMode) { fitToWidth(); return; } // fitToWidth 内部 setZoom → rerender
509
+ await rerender();
510
+ }
511
+
512
+ /** 构建单页 DOM 骨架(.page + canvas + textLayer),位图与文本由 paintPage 异步填充。 */
513
+ function makePageNode(i, viewport) {
514
+ const div = document.createElement('div');
515
+ div.className = 'page';
516
+ div.dataset.page = i;
517
+ div.style.width = viewport.width + 'px';
518
+
519
+ const canvas = document.createElement('canvas');
520
+ canvas.width = Math.ceil(viewport.width * dpr);
521
+ canvas.height = Math.ceil(viewport.height * dpr);
522
+ canvas.style.width = viewport.width + 'px';
523
+ canvas.style.height = viewport.height + 'px';
524
+ div.appendChild(canvas);
525
+
526
+ const tl = document.createElement('div');
527
+ tl.className = 'textLayer';
528
+ tl.style.width = viewport.width + 'px';
529
+ tl.style.height = viewport.height + 'px';
530
+ tl.style.setProperty('--scale-factor', String(viewport.scale));
531
+ div.appendChild(tl);
532
+ return div;
442
533
  }
443
534
 
535
+ /** 渲染单页内容(canvas 位图 + pdf.js 6.x TextLayer 文本层)。token 失效(被更新的渲染
536
+ 抢占)时中途放弃——旧 renderAllPages 循环体里的守卫原样搬进来。 */
537
+ async function paintPage(page, div, viewport, token) {
538
+ const canvas = div.firstChild;
539
+ const tl = div.lastChild;
540
+ await page.render({
541
+ canvasContext: canvas.getContext('2d'),
542
+ viewport,
543
+ transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : null,
544
+ }).promise;
545
+ if (token !== renderToken) return;
546
+ const tc = await page.getTextContent();
547
+ if (token !== renderToken) return;
548
+ // pdf.js 6.x: renderTextLayer() 移除,改 TextLayer 类
549
+ await new pdfjsLib.TextLayer({ textContentSource: tc, container: tl, viewport }).render();
550
+ }
551
+
552
+ /** 当前模式的全量重渲入口:缩放/DPR/换文档/换变体统一走这里。 */
553
+ function rerender() {
554
+ return pageMode === 'paged' ? renderPaged() : renderAllPages();
555
+ }
556
+
557
+ // 隐藏期间被跳过的重渲请求:尺寸恢复(resize)后补跑。
558
+ // ⚠️ 曾经的坑(用户实测「点放大后全空白」):守卫在 ++renderToken 之后——隐藏期间的
559
+ // 渲染请求先作废在途渲染、自己又因高度 0 提前返回,容器已被清空却无人再画;且退出
560
+ // 适宽模式后 resize 只重算适宽(if (fitMode)),不补渲 → 永久空白。改为:隐藏期间
561
+ // 的请求直接跳过(不动 token、在途渲染照常画完),标记 pending 等恢复后补画。
562
+ let pendingRerender = false;
563
+
444
564
  async function renderAllPages() {
565
+ const pane = $('pdf-pane');
566
+ if (pane.clientHeight === 0) { pendingRerender = true; return; } // 悬浮层隐藏期间不重渲(否则锚点恢复会把滚动清零)
445
567
  const token = ++renderToken;
446
568
  const container = $('pdf-pages');
447
- const pane = $('pdf-pane');
448
- if (pane.clientHeight === 0) return; // 悬浮层隐藏期间不重渲(否则锚点恢复会把滚动清零)
569
+ const scale = zoomScale; // 单次渲染内快照:隐藏期间的 setZoom 改全局值也不至于画出混合缩放
449
570
  // 滚动比例锚点:不用 DOM rect(并发重渲时旧页可能已被清空,rect 全灭 → 锚点归零)
450
571
  const range = pane.scrollHeight - pane.clientHeight;
451
572
  const anchorRatio = range > 0 ? pane.scrollTop / range : 0;
452
573
  container.innerHTML = '';
453
- $('zoom-label').textContent = Math.round(zoomScale / 1.5 * 100) + '%';
574
+ container.classList.remove('paged');
575
+ $('zoom-label').textContent = Math.round(scale / 1.5 * 100) + '%';
454
576
 
455
577
  for (let i = 1; i <= pdfDoc.numPages; i++) {
456
578
  if (token !== renderToken) return;
457
579
  const page = await pdfDoc.getPage(i);
458
580
  if (token !== renderToken) return;
459
- const viewport = page.getViewport({ scale: zoomScale });
460
- const div = document.createElement('div');
461
- div.className = 'page';
462
- div.dataset.page = i;
463
- div.style.width = viewport.width + 'px';
464
-
465
- const canvas = document.createElement('canvas');
466
- canvas.width = Math.ceil(viewport.width * dpr);
467
- canvas.height = Math.ceil(viewport.height * dpr);
468
- canvas.style.width = viewport.width + 'px';
469
- canvas.style.height = viewport.height + 'px';
470
- div.appendChild(canvas);
471
-
472
- const tl = document.createElement('div');
473
- tl.className = 'textLayer';
474
- tl.style.width = viewport.width + 'px';
475
- tl.style.height = viewport.height + 'px';
476
- tl.style.setProperty('--scale-factor', String(zoomScale));
477
- div.appendChild(tl);
581
+ const viewport = page.getViewport({ scale });
582
+ const div = makePageNode(i, viewport);
478
583
  container.appendChild(div);
479
-
480
- await page.render({
481
- canvasContext: canvas.getContext('2d'),
482
- viewport,
483
- transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : null,
484
- }).promise;
485
- if (token !== renderToken) return;
486
- const tc = await page.getTextContent();
584
+ await paintPage(page, div, viewport, token);
487
585
  if (token !== renderToken) return;
488
- // pdf.js 6.x: renderTextLayer() 移除,改 TextLayer 类
489
- await new pdfjsLib.TextLayer({ textContentSource: tc, container: tl, viewport }).render();
490
586
  }
491
587
  const newRange = pane.scrollHeight - pane.clientHeight;
492
588
  pane.scrollTop = anchorRatio * Math.max(0, newRange);
589
+ observePages();
590
+ }
591
+
592
+ /** 翻页模式:只渲染 curPage 一页(按需渲染,天然无长文档性能问题)。 */
593
+ async function renderPaged() {
594
+ if ($('pdf-pane').clientHeight === 0) { pendingRerender = true; return; } // 同 renderAllPages:跳过并标记,恢复后补画
595
+ const token = ++renderToken;
596
+ const container = $('pdf-pages');
597
+ const scale = zoomScale; // 同 renderAllPages:快照,防混合缩放
598
+ curPage = Math.min(Math.max(1, curPage), pdfDoc.numPages);
599
+ container.innerHTML = '';
600
+ container.classList.add('paged');
601
+ $('zoom-label').textContent = Math.round(scale / 1.5 * 100) + '%';
602
+ updatePager();
603
+ const page = await pdfDoc.getPage(curPage);
604
+ if (token !== renderToken) return;
605
+ const viewport = page.getViewport({ scale });
606
+ const div = makePageNode(curPage, viewport);
607
+ container.appendChild(div);
608
+ await paintPage(page, div, viewport, token);
609
+ }
610
+
611
+ /** 滚动模式当前页探测:视口竖直中线带(±45%)压到哪页,哪页就是当前页。
612
+ 每次全量重渲后重建(页节点全换)。翻页控件与模式切换共享 curPage。 */
613
+ let pageObserver = null;
614
+ function observePages() {
615
+ pageObserver?.disconnect();
616
+ pageObserver = new IntersectionObserver((entries) => {
617
+ for (const en of entries) {
618
+ if (en.isIntersecting) {
619
+ curPage = +en.target.dataset.page || curPage;
620
+ updatePager();
621
+ }
622
+ }
623
+ }, { root: $('pdf-pane'), rootMargin: '-45% 0px -45% 0px', threshold: 0 });
624
+ document.querySelectorAll('#pdf-pages .page').forEach((d) => pageObserver.observe(d));
493
625
  }
494
626
 
495
- function setZoom(newScale) { if (!pdfDoc) return; zoomScale = Math.min(4.5, Math.max(0.6, newScale)); renderAllPages(); }
627
+ function setZoom(newScale) { if (!pdfDoc) return; zoomScale = Math.min(4.5, Math.max(0.6, newScale)); rerender(); }
496
628
  // 默认适宽:rightbar 窄列里 1.5 倍缩放会把页面撑出视口(中英对照双栏页宽≈2×,更明显)
497
629
  let fitMode = true;
498
630
  const fitBtn = $('zoom-fit');
@@ -507,9 +639,99 @@ function fitToWidth() {
507
639
  $('zoom-in').onclick = () => { exitFitMode(); setZoom(zoomScale + 0.25); };
508
640
  $('zoom-out').onclick = () => { exitFitMode(); setZoom(zoomScale - 0.25); };
509
641
  fitBtn.onclick = () => { if (!pdfDoc) return; fitMode = true; fitBtn.classList.add('active'); fitBtn.blur(); fitToWidth(); };
510
- window.addEventListener('resize', () => { if (fitMode) fitToWidth(); });
642
+ window.addEventListener('resize', () => {
643
+ if (fitMode) fitToWidth(); // setZoom → rerender,顺带消化 pendingRerender
644
+ else if (pendingRerender && pdfDoc) { pendingRerender = false; rerender(); } // 隐藏期间欠下的重渲,恢复尺寸后补画
645
+ });
646
+
647
+ /* ================= 翻页模式:页码控件 + 模式切换 + 键盘 ================= */
648
+ function flashPage() {
649
+ const div = document.querySelector(`.page[data-page="${curPage}"]`);
650
+ if (!div) return;
651
+ div.classList.add('flash');
652
+ setTimeout(() => div.classList.remove('flash'), 1600);
653
+ }
654
+
655
+ /** 页码控件刷新:输入框(未聚焦时才回写,别打断正在敲页码的人)/总页数/首末页禁用。 */
656
+ function updatePager() {
657
+ const total = pdfDoc ? pdfDoc.numPages : 0;
658
+ $('page-total').textContent = '/ ' + (total || '?');
659
+ const input = $('page-input');
660
+ if (document.activeElement !== input) input.value = total ? curPage : '';
661
+ $('page-prev').disabled = pageMode === 'paged' && curPage <= 1;
662
+ $('page-next').disabled = pageMode === 'paged' && curPage >= total;
663
+ }
664
+
665
+ /** 跳到第 n 页(夹取):paged 重渲单页+闪烁;scroll 平滑滚到该页。 */
666
+ async function goPage(n) {
667
+ if (!pdfDoc) return;
668
+ const p = Math.min(Math.max(1, (n | 0) || 1), pdfDoc.numPages);
669
+ if (pageMode === 'paged') {
670
+ if (p === curPage) { flashPage(); return; }
671
+ curPage = p;
672
+ await renderPaged();
673
+ flashPage();
674
+ } else {
675
+ curPage = p;
676
+ jumpToPage(p);
677
+ }
678
+ }
679
+
680
+ $('page-prev').onclick = () => goPage(curPage - 1);
681
+ $('page-next').onclick = () => goPage(curPage + 1);
682
+ function commitPageInput() {
683
+ const v = parseInt($('page-input').value, 10);
684
+ if (Number.isFinite(v) && v >= 1) goPage(v);
685
+ else updatePager(); // 非法输入:恢复当前页显示
686
+ }
687
+ // Enter 走 blur 统一提交;Escape 只恢复显示不跳页(标志位让 blur 知道这是放弃编辑)
688
+ let pageInputEscaped = false;
689
+ $('page-input').addEventListener('keydown', (e) => {
690
+ if (e.key === 'Enter') { e.preventDefault(); e.target.blur(); }
691
+ else if (e.key === 'Escape') { pageInputEscaped = true; e.target.blur(); }
692
+ });
693
+ $('page-input').addEventListener('blur', () => {
694
+ if (pageInputEscaped) { pageInputEscaped = false; updatePager(); return; }
695
+ commitPageInput();
696
+ });
697
+
698
+ /** 模式切换:paged→scroll 先全量渲染再跳回当前页;scroll→paged 用观察器最近回写的 curPage。
699
+ 图标显示「当前模式」:▣ 单页翻页(active)/ ≣ 连续滚动。 */
700
+ function updateModeToggle() {
701
+ const btn = $('mode-toggle');
702
+ btn.textContent = pageMode === 'paged' ? '▣' : '≣';
703
+ btn.classList.toggle('active', pageMode === 'paged');
704
+ btn.title = pageMode === 'paged' ? '当前:单页翻页,点击切回连续滚动' : '当前:连续滚动,点击切到单页翻页';
705
+ updatePager();
706
+ }
707
+ $('mode-toggle').onclick = async () => {
708
+ if (!pdfDoc) return;
709
+ pageMode = pageMode === 'paged' ? 'scroll' : 'paged';
710
+ localStorage.setItem('dpr.pageMode', pageMode);
711
+ updateModeToggle();
712
+ if (pageMode === 'paged') {
713
+ await renderPaged();
714
+ flashPage();
715
+ } else {
716
+ await renderAllPages();
717
+ jumpToPage(curPage);
718
+ }
719
+ };
720
+ updateModeToggle();
721
+
722
+ // 键盘翻页(仅 paged):输入框聚焦/带修饰键时不抢;Space 在单页模式下无滚动量,拿来做下一页
723
+ document.addEventListener('keydown', (e) => {
724
+ if (pageMode !== 'paged' || !pdfDoc) return;
725
+ const tag = (e.target.tagName || '').toUpperCase();
726
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.metaKey || e.ctrlKey || e.altKey) return;
727
+ if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault(); goPage(curPage + 1); }
728
+ else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); goPage(curPage - 1); }
729
+ else if (e.key === 'Home') { e.preventDefault(); goPage(1); }
730
+ else if (e.key === 'End') { e.preventDefault(); goPage(pdfDoc.numPages); }
731
+ });
511
732
 
512
733
  function jumpToPage(n) {
734
+ if (pageMode === 'paged') { goPage(n); return; }
513
735
  const div = document.querySelector(`.page[data-page="${n}"]`);
514
736
  if (!div) return;
515
737
  div.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -553,9 +775,17 @@ function highlightSnippet(page, q) {
553
775
  }
554
776
 
555
777
  /* 统一跳转入口:先等目标页渲染出来(fit 模式下 loadPdf 返回时页面可能还在渲),
556
- 有 q 就尝试片段高亮,否则整页跳转 + 边框闪烁 */
778
+ 有 q 就尝试片段高亮,否则整页跳转 + 边框闪烁。
779
+ paged 模式目标页不在 DOM 里(只渲当前页),轮询永远等不到 → 直接设页渲染。 */
557
780
  async function jumpTo(page, q) {
558
781
  const p0 = Math.max(1, (page | 0) || 1);
782
+ if (pageMode === 'paged') {
783
+ if (!pdfDoc) return;
784
+ const p = Math.min(p0, pdfDoc.numPages);
785
+ if (p !== curPage) { curPage = p; await renderPaged(); }
786
+ if (!(q && highlightSnippet(p, q))) flashPage();
787
+ return;
788
+ }
559
789
  for (let i = 0; i < 100; i++) {
560
790
  if (pdfDoc && document.querySelector(`.page[data-page="${Math.min(p0, pdfDoc.numPages)}"]`)) {
561
791
  const p = Math.min(p0, pdfDoc.numPages);