dsh-plugin-litsearch 0.3.0 → 0.4.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.
Files changed (39) hide show
  1. package/README.md +158 -41
  2. package/client/client.js +2632 -926
  3. package/lib/host/events.d.ts +9 -1
  4. package/lib/host/events.js +50 -3
  5. package/lib/host/export/bibtex.js +1 -4
  6. package/lib/host/export/common.d.ts +0 -2
  7. package/lib/host/export/common.js +0 -7
  8. package/lib/host/export/csv.js +1 -5
  9. package/lib/host/export/markdown.d.ts +1 -1
  10. package/lib/host/export/markdown.js +9 -26
  11. package/lib/host/export/ris.js +1 -4
  12. package/lib/host/paths.d.ts +1 -1
  13. package/lib/host/paths.js +1 -1
  14. package/lib/host/pdf/store.d.ts +5 -3
  15. package/lib/host/pdf/store.js +50 -17
  16. package/lib/host/router.js +146 -79
  17. package/lib/host/tasks/download.d.ts +104 -0
  18. package/lib/host/tasks/download.js +274 -0
  19. package/lib/host/tasks/filter.d.ts +8 -1
  20. package/lib/host/tasks/filter.js +14 -0
  21. package/lib/host/tasks/service.d.ts +44 -11
  22. package/lib/host/tasks/service.js +216 -48
  23. package/lib/host/tasks/states.d.ts +1 -3
  24. package/lib/host/tasks/states.js +2 -66
  25. package/lib/host/tasks/stats.js +3 -0
  26. package/lib/host/tasks/summary.js +1 -1
  27. package/lib/host/tools.d.ts +5 -1
  28. package/lib/host/tools.js +813 -37
  29. package/lib/host/zotero/connector.d.ts +19 -10
  30. package/lib/host/zotero/connector.js +142 -39
  31. package/lib/host/zotero/match.d.ts +27 -0
  32. package/lib/host/zotero/match.js +59 -0
  33. package/lib/search/config.d.ts +0 -2
  34. package/lib/search/config.js +0 -2
  35. package/lib/search/oa.d.ts +8 -0
  36. package/lib/search/oa.js +53 -6
  37. package/lib/search/types.d.ts +154 -10
  38. package/lib/search/types.js +11 -0
  39. package/package.json +1 -1
package/client/client.js CHANGED
@@ -41,6 +41,10 @@ const TASK_LANGUAGES = [
41
41
  "ita",
42
42
  "por"
43
43
  ];
44
+ /** 是否需要引导用户到浏览器手动保存。 */
45
+ function isManualPdfFailure(reason) {
46
+ return reason !== void 0 && reason !== "failed";
47
+ }
44
48
  SOURCE_IDS.filter((id) => id !== "googlescholar");
45
49
  /** 写端点的自定义请求头(与 JSON content-type 一起构成非简单请求)。 */
46
50
  const WRITE_HEADER = "x-dsh-litsearch-write";
@@ -114,6 +118,10 @@ function fetchHealth(signal) {
114
118
  function fetchEvents(sinceRev, signal) {
115
119
  return request(`/events?sinceRev=${encodeURIComponent(String(sinceRev))}`, { signal });
116
120
  }
121
+ /** 面板把「当前页 / 当前筛选」告诉 Host,模型工具才能准确解析下载范围。 */
122
+ function postViewSnapshot(body) {
123
+ return request("/view", writeInit("POST", body));
124
+ }
117
125
  function listTasks(signal) {
118
126
  return request("/tasks", { signal });
119
127
  }
@@ -188,7 +196,7 @@ function probeSource(source, signal) {
188
196
  signal
189
197
  });
190
198
  }
191
- /** 批量写入筛读 / 分组 / 必读顺序 / 备注 / 标签。 */
199
+ /** 批量写入筛读 / 分组。 */
192
200
  function annotateTask(id, papers, rev, signal) {
193
201
  const body = { papers };
194
202
  if (rev !== void 0) body.rev = rev;
@@ -219,6 +227,10 @@ function probeStorage() {
219
227
  function probeZotero() {
220
228
  return request("/settings/zotero", writeInit("POST", {}));
221
229
  }
230
+ /** 读取可写入的 Zotero 库与分类。 */
231
+ function fetchZoteroTargets() {
232
+ return request("/settings/zotero/targets", writeInit("POST", {}));
233
+ }
222
234
  /** 下载单篇 PDF 到用户目录。 */
223
235
  function downloadPaperPdf(taskId, paperId, signal) {
224
236
  return request(taskPath(taskId, `/papers/${encodeURIComponent(paperId)}/download`), {
@@ -226,16 +238,23 @@ function downloadPaperPdf(taskId, paperId, signal) {
226
238
  signal
227
239
  });
228
240
  }
229
- function syncZotero(taskId, paperIds) {
230
- return request(taskPath(taskId, "/zotero"), writeInit("POST", { paperIds }));
241
+ function syncZotero(taskId, paperIds, target) {
242
+ return request(taskPath(taskId, "/zotero"), writeInit("POST", {
243
+ paperIds,
244
+ ...target === void 0 ? {} : {
245
+ target: target.id,
246
+ ...target.name === void 0 ? {} : { targetName: target.name },
247
+ ...target.filesEditable === void 0 ? {} : { targetFilesEditable: target.filesEditable }
248
+ }
249
+ }));
250
+ }
251
+ /** 点击「查看已下载」前确认本地文件仍在(页面加载后可能被删除)。 */
252
+ function recheckPaperPdf(taskId, paperId) {
253
+ return request(taskPath(taskId, `/papers/${encodeURIComponent(paperId)}/recheck`), writeInit("POST", {}));
231
254
  }
232
- /** 内联 PDF 预览地址(支持 Range);查询参数让 Host 找到会话工作目录。 */
233
- function paperPdfUrl(taskId, paperId) {
234
- const params = new URLSearchParams();
235
- const sessionId = currentSessionId();
236
- if (sessionId !== "") params.set("session", sessionId);
237
- const suffix = params.toString();
238
- return `${BASE}/tasks/${encodeURIComponent(taskId)}/papers/${encodeURIComponent(paperId)}/pdf${suffix === "" ? "" : `?${suffix}`}`;
255
+ /** 在系统文件管理器中定位已下载的 PDF(打开所在文件夹并选中文件)。 */
256
+ function revealLocalFile(path) {
257
+ return request("/files/reveal", writeInit("POST", { path }));
239
258
  }
240
259
  /** Europe PMC 全文 HTML 地址(用于 iframe)。 */
241
260
  function paperFullTextUrl(taskId, paperId) {
@@ -254,6 +273,97 @@ function exportUrl(id, format, query = {}, ids) {
254
273
  const suffix = params.toString();
255
274
  return `${BASE}/export${suffix === "" ? "" : `?${suffix}`}`;
256
275
  }
276
+ // src/client/downloadCommandStore.ts
277
+ /**
278
+ * 模型下载指令的客户端暂存与分发。
279
+ *
280
+ * 指令由全局 `/events` 轮询写入,但真正执行下载的是某个任务 tab 里的
281
+ * `TaskPane`;tab 可能还没挂载(focus 正在拉起它),所以这里做模块级
282
+ * 待消费队列,并按 commandId 幂等去重 —— 刷新页面也不会重放旧指令。
283
+ */
284
+ const SEEN_KEY$1 = "dsh-litsearch.downloads.seen";
285
+ const SEEN_MAX$1 = 200;
286
+ /** 指令是否仍在有效期内(允许客户端时钟略慢 1 分钟)。 */
287
+ function isFreshDownload(command, now = Date.now()) {
288
+ return Number.isFinite(command.at) && command.at <= now + 6e4 && now - command.at <= 6e5;
289
+ }
290
+ function loadSeen$1() {
291
+ try {
292
+ const raw = globalThis.localStorage?.getItem(SEEN_KEY$1);
293
+ if (raw === null || raw === void 0 || raw === "") return [];
294
+ const parsed = JSON.parse(raw);
295
+ if (!Array.isArray(parsed)) return [];
296
+ return parsed.filter((item) => typeof item === "string").slice(-200);
297
+ } catch {
298
+ return [];
299
+ }
300
+ }
301
+ function rememberSeen$1(commandId) {
302
+ seen$1.add(commandId);
303
+ while (seen$1.size > SEEN_MAX$1) {
304
+ const oldest = seen$1.values().next().value;
305
+ if (oldest === void 0) break;
306
+ seen$1.delete(oldest);
307
+ }
308
+ try {
309
+ globalThis.localStorage?.setItem(SEEN_KEY$1, JSON.stringify([...seen$1]));
310
+ } catch {}
311
+ }
312
+ const seen$1 = new Set(loadSeen$1());
313
+ let pending$2 = [];
314
+ let version$2 = 0;
315
+ const listeners$3 = /* @__PURE__ */ new Set();
316
+ function subscribe$2(listener) {
317
+ listeners$3.add(listener);
318
+ return () => {
319
+ listeners$3.delete(listener);
320
+ };
321
+ }
322
+ function getVersion$1() {
323
+ return version$2;
324
+ }
325
+ function getServerVersion$1() {
326
+ return 0;
327
+ }
328
+ function bump$1() {
329
+ version$2 += 1;
330
+ for (const listener of listeners$3) listener();
331
+ }
332
+ /**
333
+ * 把 `/events` 增量里的下载指令写入待消费队列。
334
+ *
335
+ * 不受自动打开开关与首次轮询影响:下载指令总是入队,tab 由工具同时发出的
336
+ * focus 请求拉起,未挂载时指令在此等待。
337
+ */
338
+ function publishDownloadCommands(commands, screenSessionId) {
339
+ if (commands === void 0 || commands.length === 0) return;
340
+ let added = 0;
341
+ for (const command of commands) {
342
+ if (!isFreshDownload(command)) continue;
343
+ if (seen$1.has(command.commandId)) continue;
344
+ if (command.sessionId !== void 0 && screenSessionId !== void 0 && command.sessionId !== screenSessionId) continue;
345
+ if (pending$2.some((item) => item.commandId === command.commandId)) continue;
346
+ rememberSeen$1(command.commandId);
347
+ pending$2 = [...pending$2, command];
348
+ added += 1;
349
+ }
350
+ if (added > 0) bump$1();
351
+ }
352
+ /** 订阅某个任务的待消费指令;返回数组按到达顺序排列。 */
353
+ function usePendingDownloadCommands(taskId) {
354
+ const current = (0, react.useSyncExternalStore)(subscribe$2, getVersion$1, getServerVersion$1);
355
+ return (0, react.useMemo)(() => {
356
+ if (taskId === null) return [];
357
+ return pending$2.filter((command) => command.taskId === taskId);
358
+ }, [current, taskId]);
359
+ }
360
+ /** 指令已被某个 TaskPane 接走,避免别的 tab 或后续 effect 重复执行。 */
361
+ function markConsumed(commandId) {
362
+ const next = pending$2.filter((command) => command.commandId !== commandId);
363
+ if (next.length === pending$2.length) return;
364
+ pending$2 = next;
365
+ bump$1();
366
+ }
257
367
  // src/client/health.ts
258
368
  /**
259
369
  * `/health` 的共享快照。
@@ -262,17 +372,17 @@ function exportUrl(id, format, query = {}, ids) {
262
372
  * Host 侧设置改了不会主动推送,这里用一份模块级快照 + 定时刷新兜底。
263
373
  */
264
374
  let snapshot = null;
265
- const listeners$1 = /* @__PURE__ */ new Set();
375
+ const listeners$2 = /* @__PURE__ */ new Set();
266
376
  function emit() {
267
- for (const listener of listeners$1) listener();
377
+ for (const listener of listeners$2) listener();
268
378
  }
269
379
  function getHealth() {
270
380
  return snapshot;
271
381
  }
272
382
  function subscribeHealth(listener) {
273
- listeners$1.add(listener);
383
+ listeners$2.add(listener);
274
384
  return () => {
275
- listeners$1.delete(listener);
385
+ listeners$2.delete(listener);
276
386
  };
277
387
  }
278
388
  /** 拉取一次健康信息;失败时保留旧快照并返回 null。 */
@@ -296,28 +406,28 @@ function useHealth() {
296
406
  * 同一个 client bundle 里主页历史、任务列表弹窗可能挂在不同 tab 上;
297
407
  * 任一处新建 / 重命名 / 删除任务后 bump 一个版本号,其它处据此重拉列表。
298
408
  */
299
- let version = 0;
300
- const listeners = /* @__PURE__ */ new Set();
409
+ let version$1 = 0;
410
+ const listeners$1 = /* @__PURE__ */ new Set();
301
411
  const summaries = /* @__PURE__ */ new Map();
302
412
  const summaryListeners = /* @__PURE__ */ new Set();
303
413
  function notifyTaskListChanged() {
304
- version += 1;
305
- for (const listener of listeners) listener();
414
+ version$1 += 1;
415
+ for (const listener of listeners$1) listener();
306
416
  }
307
- function subscribe(listener) {
308
- listeners.add(listener);
417
+ function subscribe$1(listener) {
418
+ listeners$1.add(listener);
309
419
  return () => {
310
- listeners.delete(listener);
420
+ listeners$1.delete(listener);
311
421
  };
312
422
  }
313
423
  function getSnapshot() {
314
- return version;
424
+ return version$1;
315
425
  }
316
426
  function getServerSnapshot() {
317
427
  return 0;
318
428
  }
319
429
  function useTaskListVersion() {
320
- return (0, react.useSyncExternalStore)(subscribe, getSnapshot, getServerSnapshot);
430
+ return (0, react.useSyncExternalStore)(subscribe$1, getSnapshot, getServerSnapshot);
321
431
  }
322
432
  /**
323
433
  * 把 `/events` 拿到的任务摘要广播给已挂载的任务 tab。
@@ -338,6 +448,93 @@ function subscribeTaskSummary(listener) {
338
448
  summaryListeners.delete(listener);
339
449
  };
340
450
  }
451
+ // src/client/zoteroCommandStore.ts
452
+ /**
453
+ * 模型「批量存入 Zotero」指令的客户端暂存与分发。
454
+ *
455
+ * 与 downloadCommandStore 同构但完全独立:两条链路各自幂等、各自排队,
456
+ * 下载指令的改动不会波及 Zotero,反之亦然。指令由全局 `/events` 轮询写入,
457
+ * 真正执行的是任务 tab 里的 `TaskPane`;tab 可能还没挂载(focus 正在拉起它),
458
+ * 所以这里做模块级待消费队列,并按 commandId 去重 —— 刷新页面也不会重放。
459
+ */
460
+ const SEEN_KEY = "dsh-litsearch.zotero.seen";
461
+ const SEEN_MAX = 200;
462
+ /** 指令是否仍在有效期内(允许客户端时钟略慢 1 分钟)。 */
463
+ function isFreshZotero(command, now = Date.now()) {
464
+ return Number.isFinite(command.at) && command.at <= now + 6e4 && now - command.at <= 6e5;
465
+ }
466
+ function loadSeen() {
467
+ try {
468
+ const raw = globalThis.localStorage?.getItem(SEEN_KEY);
469
+ if (raw === null || raw === void 0 || raw === "") return [];
470
+ const parsed = JSON.parse(raw);
471
+ if (!Array.isArray(parsed)) return [];
472
+ return parsed.filter((item) => typeof item === "string").slice(-200);
473
+ } catch {
474
+ return [];
475
+ }
476
+ }
477
+ function rememberSeen(commandId) {
478
+ seen.add(commandId);
479
+ while (seen.size > SEEN_MAX) {
480
+ const oldest = seen.values().next().value;
481
+ if (oldest === void 0) break;
482
+ seen.delete(oldest);
483
+ }
484
+ try {
485
+ globalThis.localStorage?.setItem(SEEN_KEY, JSON.stringify([...seen]));
486
+ } catch {}
487
+ }
488
+ const seen = new Set(loadSeen());
489
+ let pending$1 = [];
490
+ let version = 0;
491
+ const listeners = /* @__PURE__ */ new Set();
492
+ function subscribe(listener) {
493
+ listeners.add(listener);
494
+ return () => {
495
+ listeners.delete(listener);
496
+ };
497
+ }
498
+ function getVersion() {
499
+ return version;
500
+ }
501
+ function getServerVersion() {
502
+ return 0;
503
+ }
504
+ function bump() {
505
+ version += 1;
506
+ for (const listener of listeners) listener();
507
+ }
508
+ /** 把 `/events` 增量里的 Zotero 指令写入待消费队列。 */
509
+ function publishZoteroCommands(commands, screenSessionId) {
510
+ if (commands === void 0 || commands.length === 0) return;
511
+ let added = 0;
512
+ for (const command of commands) {
513
+ if (!isFreshZotero(command)) continue;
514
+ if (seen.has(command.commandId)) continue;
515
+ if (command.sessionId !== void 0 && screenSessionId !== void 0 && command.sessionId !== screenSessionId) continue;
516
+ if (pending$1.some((item) => item.commandId === command.commandId)) continue;
517
+ rememberSeen(command.commandId);
518
+ pending$1 = [...pending$1, command];
519
+ added += 1;
520
+ }
521
+ if (added > 0) bump();
522
+ }
523
+ /** 订阅某个任务的待消费指令;返回数组按到达顺序排列。 */
524
+ function usePendingZoteroCommands(taskId) {
525
+ const current = (0, react.useSyncExternalStore)(subscribe, getVersion, getServerVersion);
526
+ return (0, react.useMemo)(() => {
527
+ if (taskId === null) return [];
528
+ return pending$1.filter((command) => command.taskId === taskId);
529
+ }, [current, taskId]);
530
+ }
531
+ /** 指令已被某个 TaskPane 接走,避免别的 tab 或后续 effect 重复执行。 */
532
+ function markZoteroConsumed(commandId) {
533
+ const next = pending$1.filter((command) => command.commandId !== commandId);
534
+ if (next.length === pending$1.length) return;
535
+ pending$1 = next;
536
+ bump();
537
+ }
341
538
  // src/client/autoOpen.ts
342
539
  /**
343
540
  * 模型任务的自动打开 + 任务标题缓存 + 「待打开」队列。
@@ -555,6 +752,8 @@ function startAutoOpen(options) {
555
752
  seeded = true;
556
753
  rev = page.rev;
557
754
  publishTaskSummaries(page.tasks);
755
+ publishDownloadCommands(page.downloads, options.sessionId?.());
756
+ publishZoteroCommands(page.zotero, options.sessionId?.());
558
757
  const settings = getHealth()?.settings;
559
758
  const autoOpen = settings?.autoOpenTaskTab ?? true;
560
759
  if (autoOpen && page.focus !== void 0 && isFreshFocus(page.focus)) {
@@ -941,18 +1140,32 @@ const dicts = {
941
1140
  "pdf.status.unknown": "未获取全文",
942
1141
  "pdf.status.available": "有全文直链",
943
1142
  "pdf.status.downloaded": "已下载",
1143
+ "pdf.status.missing": "文件已删除",
944
1144
  "pdf.status.html-only": "仅 HTML 全文",
945
1145
  "pdf.status.paywalled": "需订阅",
946
1146
  "pdf.status.blocked": "被出版商拒绝",
947
1147
  "pdf.status.failed": "获取失败",
948
- "pdf.preview": "预览",
1148
+ "pdf.htmlLoading": "HTML 全文加载中…",
949
1149
  "pdf.closePreview": "收起",
950
- "pdf.download": "下载 PDF",
1150
+ "pdf.download": "下载",
1151
+ "pdf.downloading": "下载中…",
1152
+ "pdf.downloadedAction": "查看已下载",
1153
+ "pdf.missingAction": "下载文件已删除点击重新下载",
1154
+ "pdf.retry": "重试下载",
1155
+ "pdf.downloadHint": "下载 PDF 并保存到:{path}",
1156
+ "pdf.downloadedHint": "已保存到:{path}\n点击在右侧面板打开该 PDF",
1157
+ "pdf.missingHint": "本地 PDF 已被删除或移走,点击重新下载",
1158
+ "pdf.revealInFolder": "在文件系统中打开",
1159
+ "pdf.downloadingHint": "正在下载 PDF,请稍候…",
1160
+ "pdf.retryHint": "上次下载失败:{message}\n点击重试",
1161
+ "pdf.downloadDirUnknown": "工作目录下的 dsh-litsearch 文件夹",
951
1162
  "pdf.fulltext": "HTML 全文",
952
1163
  "pdf.zotero": "存入 Zotero",
1164
+ "pdf.zoteroSynced": "Zotero 已同步",
1165
+ "pdf.zoteroRetry": "Zotero 同步失败",
953
1166
  "pdf.error": "全文获取失败:{message}",
954
1167
  "pdf.downloaded": "已保存:{name}",
955
- "pdf.htmlFallback": "没有可下载的 PDF,已标记为 HTML 全文可读({message})",
1168
+ "pdf.htmlFallback": "未找到可下载的 PDF,已切换为 HTML 全文;可点击 PDF 链接在浏览器打开。",
956
1169
  "pdf.zoteroDone": "已存入 Zotero:{n} 篇",
957
1170
  "pdf.zoteroPartial": "Zotero:成功 {saved} 篇,失败 {failed} 篇;{message}",
958
1171
  "pdf.zoteroFailed": "Zotero 同步失败:{message}",
@@ -961,7 +1174,11 @@ const dicts = {
961
1174
  "pdf.zoteroFallbackDismiss": "关闭",
962
1175
  "bulk.count": "已选 {n} 篇",
963
1176
  "bulk.select": "选择这篇文献",
964
- "bulk.selectPage": "全选本页",
1177
+ "bulk.selectPage": "本页全选",
1178
+ "bulk.selectPageHint": "把当前页的文献加入已选,其他页的选中不受影响",
1179
+ "bulk.unselectAll": "取消全选",
1180
+ "bulk.unselectAllHint": "清空所有已选文献(含其他页)",
1181
+ "bulk.keepHint": "已选文献跨页保留,翻页不会丢失",
965
1182
  "bulk.clear": "清空",
966
1183
  "bulk.setScreening": "设置筛读状态",
967
1184
  "bulk.setGroup": "归入分组",
@@ -971,7 +1188,33 @@ const dicts = {
971
1188
  "bulk.insert": "加入对话",
972
1189
  "bulk.inserted": "已作为引用插入 {n} 篇(编号列表)",
973
1190
  "bulk.export": "按选中导出",
974
- "bulk.downloadDone": "批量下载:成功 {ok} 篇,失败 {failed} 篇",
1191
+ "bulk.downloadDone": "批量下载:成功 {ok} 篇,需手动获取 {manual} 篇,失败 {failed} 篇",
1192
+ "bulk.revealDir": "打开下载目录",
1193
+ "batch.title": "批量下载",
1194
+ "batch.desc": "共 {n} 篇;已下载的文献会自动跳过,状态实时更新。",
1195
+ "batch.total": "共 {n} 篇",
1196
+ "batch.downloaded": "已下载 {n}",
1197
+ "batch.downloading": "下载中 {n}",
1198
+ "batch.pending": "待下载 {n}",
1199
+ "batch.manual": "需手动获取 {n}",
1200
+ "batch.failed": "失败 {n}",
1201
+ "batch.progress": "进度 {done}/{total}",
1202
+ "batch.complete": "下载任务完成",
1203
+ "batch.untitled": "(未获取标题)",
1204
+ "batch.close": "关闭",
1205
+ "batch.modelTitle": "模型发起的批量下载",
1206
+ "batch.modelDesc": "模型已选出 {n} 篇文献,正在逐篇下载;可关闭弹窗或点「取消下载」中止。",
1207
+ "batch.originModel": "模型发起",
1208
+ "batch.source": "来源 {source}",
1209
+ "batch.allSkipped": "清单中的文献均已下载,已跳过",
1210
+ "batch.cancel": "取消下载",
1211
+ "batch.cancelled": "已取消,剩余文献未下载",
1212
+ "batch.empty": "模型指令里没有可下载的文献",
1213
+ "batch.status.pending": "未下载",
1214
+ "batch.status.downloading": "下载中…",
1215
+ "batch.status.downloaded": "已下载",
1216
+ "batch.status.manual": "需手动获取",
1217
+ "batch.status.failed": "失败",
975
1218
  "fulltext.loading": "统计全文覆盖…",
976
1219
  "fulltext.hint": "统计基于全部 {total} 篇,与概览同口径,不受当前筛选影响;点状态可切换清单,列表分页加载。",
977
1220
  "fulltext.hintFallback": "统计暂不可用,仍可按状态查看文献清单。",
@@ -987,15 +1230,6 @@ const dicts = {
987
1230
  "annotate.noGroup": "未分组",
988
1231
  "annotate.createGroup": "+ 新建分组…",
989
1232
  "annotate.groupHint": "还没有分组;可在任务的「分组」页新建,选「未分组」可清除分组",
990
- "annotate.expand": "标注",
991
- "annotate.collapse": "收起标注",
992
- "annotate.note": "备注",
993
- "annotate.notePlaceholder": "为什么读 / 方法要点 / 可否引用",
994
- "annotate.tags": "标签",
995
- "annotate.tagsPlaceholder": "逗号分隔",
996
- "annotate.shortlistOrder": "必读顺序",
997
- "annotate.save": "保存标注",
998
- "annotate.shortlistBadge": "必读 #{n}",
999
1233
  "entity.title": "实体",
1000
1234
  "entity.count": "{n} 个实体",
1001
1235
  "entity.hint": "来自 PubTator3,按标准标识符去重",
@@ -1036,7 +1270,7 @@ const dicts = {
1036
1270
  "groups.empty": "还没有分组。先建一个「为什么读」的类别,再逐篇归入。",
1037
1271
  "groups.namePlaceholder": "如:核心方法",
1038
1272
  "groups.descPlaceholder": "如:用于方法对比",
1039
- "groups.deleteConfirm": "删除分组「{name}」?组内文献会回到未分组,标注不受影响。",
1273
+ "groups.deleteConfirm": "删除分组「{name}」?组内文献会回到未分组,筛读状态不受影响。",
1040
1274
  "groups.saved": "分组已保存",
1041
1275
  "groups.saving": "正在保存…",
1042
1276
  "groups.retry": "重试保存",
@@ -1044,11 +1278,6 @@ const dicts = {
1044
1278
  "notes.taskPlaceholder": "记录检索策略、纳入排除标准、阶段性结论…",
1045
1279
  "notes.save": "保存笔记",
1046
1280
  "notes.saved": "笔记已保存",
1047
- "notes.paperSection": "逐篇备注与标签",
1048
- "notes.empty": "还没有逐篇备注。可在结果卡片的「标注」里写。",
1049
- "notes.loading": "读取逐篇备注…",
1050
- "notes.hint": "逐篇备注按当前任务已加载的前 200 篇统计。",
1051
- "notes.tags": "标签:{value}",
1052
1281
  "compare.title": "与旧任务对比",
1053
1282
  "compare.base": "基准任务",
1054
1283
  "compare.pick": "选择一个旧任务",
@@ -1171,7 +1400,7 @@ const dicts = {
1171
1400
  "result.infoLabel": "状态与来源",
1172
1401
  "result.infoHint": "筛读状态、命中的数据库、被引次数、开放获取与全文状态",
1173
1402
  "result.actionLabel": "快捷操作",
1174
- "result.actionHint": "对这篇文献执行操作:加入对话、打开详情、预览或下载全文、存入 Zotero",
1403
+ "result.actionHint": "对这篇文献执行操作:加入对话、打开详情、下载 PDF 或 HTML 全文、存入 Zotero",
1175
1404
  "error.noEditor": "当前面板不在会话上下文中,无法插入到输入框",
1176
1405
  "error.draftChanged": "输入框草稿已变化,未插入;请避免在插入期间编辑草稿",
1177
1406
  "error.sourceFailed": "数据源 {source} 检索失败:{message}",
@@ -1192,7 +1421,7 @@ const dicts = {
1192
1421
  "group.zotero": "Zotero",
1193
1422
  "group.zoteroHint": "通过 Zotero 桌面端 connector 保存条目与 PDF;只连接本机 127.0.0.1。",
1194
1423
  "field.contactEmail": "联系邮箱",
1195
- "field.contactEmailHint": "作为 OpenAlex / Crossref / NCBI 的 polite pool 标识,可留空;填写后限流更宽松",
1424
+ "field.contactEmailHint": "作为 OpenAlex / Crossref / NCBI 的 polite pool 标识;填写后还会启用 Unpaywall,按 DOI 补全开放获取 PDF 地址,提升批量下载成功率",
1196
1425
  "field.ncbiApiKey": "NCBI API Key",
1197
1426
  "field.ncbiApiKeyHint": "可选;配置后 PubMed E-utilities 额度从 3 次/秒提升到 10 次/秒",
1198
1427
  "field.s2ApiKey": "Semantic Scholar Key",
@@ -1279,6 +1508,45 @@ const dicts = {
1279
1508
  "zotero.probeOk": "连接正常(Zotero {version});当前分类:{name}",
1280
1509
  "zotero.probeOkNoCollection": "连接正常,但 Zotero 里还没有选中分类",
1281
1510
  "zotero.probeFail": "连接失败:{message}",
1511
+ "zotero.dialog.title": "存入 Zotero",
1512
+ "zotero.dialog.desc": "共 {n} 篇。选择要存入的 Zotero 目录,再点「同步」。",
1513
+ "zotero.dialog.modelTitle": "模型发起的批量存入 Zotero",
1514
+ "zotero.dialog.modelDescAuto": "模型已选出 {n} 篇并指定目录,正在自动同步;可随时取消。",
1515
+ "zotero.dialog.modelDescPick": "模型已选出 {n} 篇,请确认目标目录后点「同步」。",
1516
+ "zotero.dialog.originModel": "模型发起",
1517
+ "zotero.dialog.modelTarget": "模型指定目录:{name}",
1518
+ "zotero.dialog.modelTargetMissing": "模型指定的目录「{name}」已不在 Zotero 目录列表中,请手动选择目录后再同步。",
1519
+ "zotero.dialog.modelNoAuto": "无法读取 Zotero 目录,已取消自动同步;请恢复后手动点「同步」。",
1520
+ "zotero.dialog.modelEmpty": "模型指令里没有可同步的文献",
1521
+ "zotero.dialog.source": "来源 {source}",
1522
+ "zotero.dialog.target": "目标目录",
1523
+ "zotero.dialog.current": "(当前选中)",
1524
+ "zotero.dialog.readonly": "(不可写附件)",
1525
+ "zotero.dialog.loading": "正在读取 Zotero 目录…",
1526
+ "zotero.dialog.loadFail": "无法读取 Zotero 目录:{message}",
1527
+ "zotero.dialog.unknownError": "未知错误",
1528
+ "zotero.dialog.noTarget": "Zotero 里没有可写入的目录,请先在 Zotero 中新建分类",
1529
+ "zotero.dialog.rerunHint": "重新同步会在 Zotero 里新建条目,不会覆盖已有条目。",
1530
+ "zotero.dialog.total": "共 {n} 篇",
1531
+ "zotero.dialog.synced": "已同步 {n}",
1532
+ "zotero.dialog.syncingCount": "同步中 {n}",
1533
+ "zotero.dialog.pending": "待同步 {n}",
1534
+ "zotero.dialog.failed": "失败 {n}",
1535
+ "zotero.dialog.running": "正在同步中…",
1536
+ "zotero.dialog.done": "同步完成:{n} 篇已存入 Zotero",
1537
+ "zotero.dialog.partial": "同步结束:成功 {saved} 篇,失败 {failed} 篇",
1538
+ "zotero.dialog.progress": "已处理 {done}/{total}",
1539
+ "zotero.dialog.sync": "同步",
1540
+ "zotero.dialog.syncing": "同步中…",
1541
+ "zotero.dialog.resync": "重新同步",
1542
+ "zotero.dialog.cancel": "取消",
1543
+ "zotero.dialog.close": "关闭",
1544
+ "zotero.dialog.untitled": "(无标题)",
1545
+ "zotero.dialog.failFallback": "同步失败",
1546
+ "zotero.dialog.status.pending": "待同步",
1547
+ "zotero.dialog.status.syncing": "同步中…",
1548
+ "zotero.dialog.status.synced": "已同步",
1549
+ "zotero.dialog.status.failed": "失败",
1282
1550
  "form.unavailable": "该插件当前未加载,暂时无法配置。",
1283
1551
  "form.readOnly": "本部署的设置为只读。",
1284
1552
  "form.saveFailed": "本部署没有接受这些值,已保留供你修改。",
@@ -1472,18 +1740,32 @@ const dicts = {
1472
1740
  "pdf.status.unknown": "No full text yet",
1473
1741
  "pdf.status.available": "Full-text link",
1474
1742
  "pdf.status.downloaded": "Downloaded",
1743
+ "pdf.status.missing": "File deleted",
1475
1744
  "pdf.status.html-only": "HTML full text",
1476
1745
  "pdf.status.paywalled": "Paywalled",
1477
1746
  "pdf.status.blocked": "Blocked by publisher",
1478
1747
  "pdf.status.failed": "Fetch failed",
1479
- "pdf.preview": "Preview",
1748
+ "pdf.htmlLoading": "Loading full text…",
1480
1749
  "pdf.closePreview": "Close",
1481
- "pdf.download": "Download PDF",
1750
+ "pdf.download": "Download",
1751
+ "pdf.downloading": "Downloading…",
1752
+ "pdf.downloadedAction": "View downloaded",
1753
+ "pdf.missingAction": "Downloaded file deleted — click to re-download",
1754
+ "pdf.retry": "Retry download",
1755
+ "pdf.downloadHint": "Download the PDF to: {path}",
1756
+ "pdf.downloadedHint": "Saved to: {path}\nClick to open the PDF in the right panel",
1757
+ "pdf.missingHint": "The local PDF was deleted or moved. Click to download again.",
1758
+ "pdf.revealInFolder": "Show in file manager",
1759
+ "pdf.downloadingHint": "Downloading PDF, please wait…",
1760
+ "pdf.retryHint": "Last download failed: {message}\nClick to retry",
1761
+ "pdf.downloadDirUnknown": "the dsh-litsearch folder under the workspace",
1482
1762
  "pdf.fulltext": "HTML full text",
1483
1763
  "pdf.zotero": "Save to Zotero",
1764
+ "pdf.zoteroSynced": "Synced to Zotero",
1765
+ "pdf.zoteroRetry": "Zotero sync failed",
1484
1766
  "pdf.error": "Full text failed: {message}",
1485
1767
  "pdf.downloaded": "Saved: {name}",
1486
- "pdf.htmlFallback": "No downloadable PDF; marked as HTML full text ({message})",
1768
+ "pdf.htmlFallback": "No downloadable PDF found; switched to HTML full text. Open the PDF link in a browser if needed.",
1487
1769
  "pdf.zoteroDone": "Saved to Zotero: {n}",
1488
1770
  "pdf.zoteroPartial": "Zotero: {saved} saved, {failed} failed; {message}",
1489
1771
  "pdf.zoteroFailed": "Zotero sync failed: {message}",
@@ -1492,7 +1774,11 @@ const dicts = {
1492
1774
  "pdf.zoteroFallbackDismiss": "Dismiss",
1493
1775
  "bulk.count": "{n} selected",
1494
1776
  "bulk.select": "Select this paper",
1495
- "bulk.selectPage": "Select page",
1777
+ "bulk.selectPage": "Select this page",
1778
+ "bulk.selectPageHint": "Add this page to the selection; other pages stay selected",
1779
+ "bulk.unselectAll": "Clear all",
1780
+ "bulk.unselectAllHint": "Clear the whole selection, including other pages",
1781
+ "bulk.keepHint": "Selection is kept when you change pages",
1496
1782
  "bulk.clear": "Clear",
1497
1783
  "bulk.setScreening": "Set screening",
1498
1784
  "bulk.setGroup": "Assign group",
@@ -1502,7 +1788,33 @@ const dicts = {
1502
1788
  "bulk.insert": "Add to chat",
1503
1789
  "bulk.inserted": "Inserted {n} papers as a quoted numbered list",
1504
1790
  "bulk.export": "Export selected",
1505
- "bulk.downloadDone": "Download: {ok} succeeded, {failed} failed",
1791
+ "bulk.downloadDone": "Download: {ok} succeeded, {manual} need manual save, {failed} failed",
1792
+ "bulk.revealDir": "Open download folder",
1793
+ "batch.title": "Batch download",
1794
+ "batch.desc": "Downloading {n} papers; already downloaded ones are skipped. Status updates live.",
1795
+ "batch.total": "{n} total",
1796
+ "batch.downloaded": "{n} downloaded",
1797
+ "batch.downloading": "{n} downloading",
1798
+ "batch.pending": "{n} pending",
1799
+ "batch.manual": "{n} need manual save",
1800
+ "batch.failed": "{n} failed",
1801
+ "batch.progress": "Progress {done}/{total}",
1802
+ "batch.complete": "Download task complete",
1803
+ "batch.untitled": "(title unavailable)",
1804
+ "batch.close": "Close",
1805
+ "batch.modelTitle": "Model-initiated batch download",
1806
+ "batch.modelDesc": "The model selected {n} papers; downloading them here. You can close this dialog or cancel.",
1807
+ "batch.originModel": "Model request",
1808
+ "batch.source": "Source: {source}",
1809
+ "batch.allSkipped": "All papers in this list were already downloaded and were skipped",
1810
+ "batch.cancel": "Cancel download",
1811
+ "batch.cancelled": "Cancelled; remaining papers were not downloaded",
1812
+ "batch.empty": "No papers to download in the model request",
1813
+ "batch.status.pending": "Not downloaded",
1814
+ "batch.status.downloading": "Downloading…",
1815
+ "batch.status.downloaded": "Downloaded",
1816
+ "batch.status.manual": "Manual save",
1817
+ "batch.status.failed": "Failed",
1506
1818
  "fulltext.loading": "Computing full-text coverage…",
1507
1819
  "fulltext.hint": "Coverage covers all {total} papers (same basis as the overview) and ignores current filters. Click a status to switch lists; the list loads in pages.",
1508
1820
  "fulltext.hintFallback": "Coverage is unavailable; you can still browse papers by status.",
@@ -1518,15 +1830,6 @@ const dicts = {
1518
1830
  "annotate.noGroup": "Ungrouped",
1519
1831
  "annotate.createGroup": "+ New group…",
1520
1832
  "annotate.groupHint": "No groups yet; create one on the Groups tab, or pick Ungrouped to clear",
1521
- "annotate.expand": "Annotate",
1522
- "annotate.collapse": "Hide annotation",
1523
- "annotate.note": "Note",
1524
- "annotate.notePlaceholder": "Why read it / method / quotable",
1525
- "annotate.tags": "Tags",
1526
- "annotate.tagsPlaceholder": "comma separated",
1527
- "annotate.shortlistOrder": "Shortlist order",
1528
- "annotate.save": "Save annotation",
1529
- "annotate.shortlistBadge": "Shortlist #{n}",
1530
1833
  "entity.title": "Entities",
1531
1834
  "entity.count": "{n} entities",
1532
1835
  "entity.hint": "From PubTator3, deduped by standard identifier",
@@ -1575,11 +1878,6 @@ const dicts = {
1575
1878
  "notes.taskPlaceholder": "Search strategy, inclusion criteria, interim conclusions…",
1576
1879
  "notes.save": "Save notes",
1577
1880
  "notes.saved": "Notes saved",
1578
- "notes.paperSection": "Per-paper notes and tags",
1579
- "notes.empty": "No per-paper notes yet. Use \"Annotate\" on a result card.",
1580
- "notes.loading": "Reading per-paper notes…",
1581
- "notes.hint": "Per-paper notes cover the first 200 loaded papers of this task.",
1582
- "notes.tags": "Tags: {value}",
1583
1881
  "compare.title": "Compare with an older task",
1584
1882
  "compare.base": "Base task",
1585
1883
  "compare.pick": "Pick an older task",
@@ -1702,7 +2000,7 @@ const dicts = {
1702
2000
  "result.infoLabel": "Status & sources",
1703
2001
  "result.infoHint": "Screening status, matched databases, citations, open access and full-text status",
1704
2002
  "result.actionLabel": "Actions",
1705
- "result.actionHint": "Actions on this paper: insert into chat, open details, preview or download full text, save to Zotero",
2003
+ "result.actionHint": "Actions on this paper: insert into chat, open details, download PDF or HTML full text, save to Zotero",
1706
2004
  "error.noEditor": "This panel is not in a conversation context; cannot insert into the composer",
1707
2005
  "error.draftChanged": "The draft changed; nothing was inserted. Avoid editing while inserting.",
1708
2006
  "error.sourceFailed": "Source {source} failed: {message}",
@@ -1723,7 +2021,7 @@ const dicts = {
1723
2021
  "group.zotero": "Zotero",
1724
2022
  "group.zoteroHint": "Saves items and PDFs through the Zotero desktop connector; only 127.0.0.1 is allowed.",
1725
2023
  "field.contactEmail": "Contact email",
1726
- "field.contactEmailHint": "Used as the polite-pool identifier for OpenAlex / Crossref / NCBI; optional, but it raises rate limits",
2024
+ "field.contactEmailHint": "Polite-pool identifier for OpenAlex / Crossref / NCBI; also enables Unpaywall, which adds open-access PDF links by DOI and improves batch downloads",
1727
2025
  "field.ncbiApiKey": "NCBI API key",
1728
2026
  "field.ncbiApiKeyHint": "Optional; raises PubMed E-utilities quota from 3 req/s to 10 req/s",
1729
2027
  "field.s2ApiKey": "Semantic Scholar key",
@@ -1810,6 +2108,45 @@ const dicts = {
1810
2108
  "zotero.probeOk": "Connected (Zotero {version}); selected collection: {name}",
1811
2109
  "zotero.probeOkNoCollection": "Connected, but no collection is selected in Zotero",
1812
2110
  "zotero.probeFail": "Connection failed: {message}",
2111
+ "zotero.dialog.title": "Save to Zotero",
2112
+ "zotero.dialog.desc": "{n} item(s). Choose a Zotero collection, then click Sync.",
2113
+ "zotero.dialog.modelTitle": "Model-initiated Zotero sync",
2114
+ "zotero.dialog.modelDescAuto": "The model selected {n} papers and a collection; syncing automatically. You can cancel.",
2115
+ "zotero.dialog.modelDescPick": "The model selected {n} papers. Confirm the collection, then click Sync.",
2116
+ "zotero.dialog.originModel": "Model request",
2117
+ "zotero.dialog.modelTarget": "Collection requested by the model: {name}",
2118
+ "zotero.dialog.modelTargetMissing": "The collection \"{name}\" requested by the model is no longer available. Choose one manually before syncing.",
2119
+ "zotero.dialog.modelNoAuto": "Could not load Zotero collections, so auto-sync was cancelled. Click Sync manually once available.",
2120
+ "zotero.dialog.modelEmpty": "No papers to sync in the model request",
2121
+ "zotero.dialog.source": "Source: {source}",
2122
+ "zotero.dialog.target": "Target collection",
2123
+ "zotero.dialog.current": "(selected in Zotero)",
2124
+ "zotero.dialog.readonly": "(attachments not writable)",
2125
+ "zotero.dialog.loading": "Loading Zotero collections…",
2126
+ "zotero.dialog.loadFail": "Could not load Zotero collections: {message}",
2127
+ "zotero.dialog.unknownError": "Unknown error",
2128
+ "zotero.dialog.noTarget": "No writable collection found. Create one in Zotero first.",
2129
+ "zotero.dialog.rerunHint": "Re-syncing creates new items in Zotero; existing items are not overwritten.",
2130
+ "zotero.dialog.total": "Total {n}",
2131
+ "zotero.dialog.synced": "Synced {n}",
2132
+ "zotero.dialog.syncingCount": "Syncing {n}",
2133
+ "zotero.dialog.pending": "Pending {n}",
2134
+ "zotero.dialog.failed": "Failed {n}",
2135
+ "zotero.dialog.running": "Syncing…",
2136
+ "zotero.dialog.done": "Done: {n} item(s) saved to Zotero",
2137
+ "zotero.dialog.partial": "Finished: {saved} saved, {failed} failed",
2138
+ "zotero.dialog.progress": "Processed {done}/{total}",
2139
+ "zotero.dialog.sync": "Sync",
2140
+ "zotero.dialog.syncing": "Syncing…",
2141
+ "zotero.dialog.resync": "Sync again",
2142
+ "zotero.dialog.cancel": "Cancel",
2143
+ "zotero.dialog.close": "Close",
2144
+ "zotero.dialog.untitled": "(untitled)",
2145
+ "zotero.dialog.failFallback": "Sync failed",
2146
+ "zotero.dialog.status.pending": "Pending",
2147
+ "zotero.dialog.status.syncing": "Syncing…",
2148
+ "zotero.dialog.status.synced": "Synced",
2149
+ "zotero.dialog.status.failed": "Failed",
1813
2150
  "form.unavailable": "This plugin is not loaded, so it cannot be configured right now.",
1814
2151
  "form.readOnly": "This deployment stores settings read-only.",
1815
2152
  "form.saveFailed": "This deployment did not accept these values; they were kept for you to fix.",
@@ -1992,11 +2329,32 @@ const actionRowStyle = {
1992
2329
  gap: 10,
1993
2330
  marginTop: 8
1994
2331
  };
1995
- /** Toast。 */
2332
+ /** 提醒浮层容器:贴面板底部堆叠,不遮挡操作按钮的点击。 */
2333
+ const toastStackStyle = {
2334
+ position: "absolute",
2335
+ left: 15,
2336
+ right: 15,
2337
+ bottom: 14,
2338
+ zIndex: 30,
2339
+ display: "flex",
2340
+ flexDirection: "column",
2341
+ gap: 6,
2342
+ pointerEvents: "none"
2343
+ };
2344
+ /** 底部自动消失的提醒:黑底白字。 */
1996
2345
  const toastStyle = {
2346
+ padding: "8px 12px",
2347
+ borderRadius: 8,
2348
+ border: "1px solid rgba(255,255,255,0.16)",
2349
+ background: "rgba(0,0,0,0.92)",
2350
+ color: "#fff",
1997
2351
  fontSize: 12,
1998
- color: ACCENT
2352
+ lineHeight: 1.5,
2353
+ boxShadow: "0 6px 20px rgba(0,0,0,0.28)",
2354
+ pointerEvents: "none"
1999
2355
  };
2356
+ /** 下载失败等错误提醒:同样黑底白字,左侧红条区分。 */
2357
+ const toastErrorStyle = { borderLeft: `3px solid ${ERROR}` };
2000
2358
  // src/client/TaskListDialog.tsx
2001
2359
  /**
2002
2360
  * 右上角「搜索任务列表」弹窗:列出全部任务,可打开、重命名、保留、删除。
@@ -2523,7 +2881,7 @@ function SyntaxHint({ t, sources, texts, infos }) {
2523
2881
  const names = affected.map((source) => t(`source.${source}`)).join(" / ");
2524
2882
  const title = (infos ?? []).filter((info) => affected.includes(info.id)).map((info) => `${info.label}:${info.syntax}`).join("\n");
2525
2883
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2526
- style: HINT_STYLE$3,
2884
+ style: HINT_STYLE$4,
2527
2885
  title: title === "" ? void 0 : title,
2528
2886
  children: tpl(t("search.syntaxWarn"), { sources: names })
2529
2887
  });
@@ -2533,7 +2891,7 @@ function sourceSyntaxTitle(infos, id) {
2533
2891
  const info = infos?.find((entry) => entry.id === id);
2534
2892
  return info === void 0 ? void 0 : info.syntax;
2535
2893
  }
2536
- const HINT_STYLE$3 = {
2894
+ const HINT_STYLE$4 = {
2537
2895
  fontSize: 11,
2538
2896
  lineHeight: 1.5,
2539
2897
  color: "#b26a00"
@@ -2655,7 +3013,10 @@ function LitsearchPage({ t, onOpenTask }) {
2655
3013
  };
2656
3014
  const pageSize = health?.settings.defaultPageSize ?? 20;
2657
3015
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2658
- style: panelStyle,
3016
+ style: {
3017
+ ...panelStyle,
3018
+ position: "relative"
3019
+ },
2659
3020
  children: [
2660
3021
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2661
3022
  style: pageHeaderStyle,
@@ -2747,15 +3108,20 @@ function LitsearchPage({ t, onOpenTask }) {
2747
3108
  },
2748
3109
  children: error
2749
3110
  }),
2750
- pending.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2751
- style: bannerStyle,
2752
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: tpl(t("pending.title"), { n: pending.length }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2753
- variant: "outline",
2754
- size: "sm",
2755
- type: "button",
2756
- onClick: openPending,
2757
- children: t("pending.open")
2758
- })]
3111
+ pending.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3112
+ style: toastStackStyle,
3113
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3114
+ style: bannerStyle,
3115
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3116
+ style: { minWidth: 0 },
3117
+ children: tpl(t("pending.title"), { n: pending.length })
3118
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3119
+ type: "button",
3120
+ style: bannerButtonStyle,
3121
+ onClick: openPending,
3122
+ children: t("pending.open")
3123
+ })]
3124
+ })
2759
3125
  }),
2760
3126
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
2761
3127
  style: sectionTitleStyle,
@@ -2884,14 +3250,18 @@ const historyMetaStyle = {
2884
3250
  opacity: .66
2885
3251
  };
2886
3252
  const bannerStyle = {
3253
+ ...toastStyle,
3254
+ pointerEvents: "auto",
2887
3255
  display: "flex",
2888
3256
  alignItems: "center",
2889
3257
  justifyContent: "space-between",
2890
- gap: 8,
2891
- padding: "6px 8px",
2892
- border: "1px solid rgba(80,140,255,0.4)",
2893
- borderRadius: 6,
2894
- fontSize: 12
3258
+ gap: 8
3259
+ };
3260
+ const bannerButtonStyle = {
3261
+ ...linkButtonStyle,
3262
+ color: "#fff",
3263
+ textDecoration: "underline",
3264
+ flexShrink: 0
2895
3265
  };
2896
3266
  // src/client/compose.ts
2897
3267
  /** 插入时摘要最多保留多少字符,避免草稿过长。 */
@@ -2986,6 +3356,133 @@ function taskConditionsText(t, detail) {
2986
3356
  function truncate(value, max) {
2987
3357
  return value.length <= max ? value : `${value.slice(0, max)}…`;
2988
3358
  }
3359
+ // src/client/DownloadButton.tsx
3360
+ /**
3361
+ * 「下载 PDF / 查看已下载」动作按钮。
3362
+ *
3363
+ * 未下载时是单个下载按钮;已下载时变为「查看已下载 ▾」拆分按钮:点文字在右侧
3364
+ * 预览本地 PDF,点箭头展开菜单,可在系统文件管理器中定位该文件。文件被删除后
3365
+ * 回到普通按钮并提示点击重新下载。
3366
+ */
3367
+ /** 已下载状态的绿色,与徽章 / 状态色一致。 */
3368
+ const DOWNLOADED_COLOR = "#2e9e5b";
3369
+ /** 本地文件已被删除的警示色。 */
3370
+ const MISSING_COLOR = "#d64545";
3371
+ function DownloadButton({ t, label, hint, downloadedPath, missing = false, downloading = false, disabled = false, style, onDownload, onOpen, onReveal }) {
3372
+ const [menuOpen, setMenuOpen] = (0, react.useState)(false);
3373
+ const groupRef = (0, react.useRef)(null);
3374
+ const path = downloadedPath ?? "";
3375
+ const downloaded = !missing && path !== "" && onOpen !== void 0;
3376
+ const color = missing ? MISSING_COLOR : downloaded ? DOWNLOADED_COLOR : void 0;
3377
+ (0, react.useEffect)(() => {
3378
+ if (!menuOpen) return;
3379
+ const closeOnOutside = (event) => {
3380
+ if (groupRef.current !== null && !groupRef.current.contains(event.target)) setMenuOpen(false);
3381
+ };
3382
+ const closeOnEscape = (event) => {
3383
+ if (event.key === "Escape") setMenuOpen(false);
3384
+ };
3385
+ document.addEventListener("mousedown", closeOnOutside);
3386
+ document.addEventListener("keydown", closeOnEscape);
3387
+ return () => {
3388
+ document.removeEventListener("mousedown", closeOnOutside);
3389
+ document.removeEventListener("keydown", closeOnEscape);
3390
+ };
3391
+ }, [menuOpen]);
3392
+ const main = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3393
+ type: "button",
3394
+ style: {
3395
+ ...style,
3396
+ display: "inline-flex",
3397
+ alignItems: "center",
3398
+ gap: 4,
3399
+ ...color === void 0 ? null : { color }
3400
+ },
3401
+ disabled: disabled || downloading,
3402
+ title: hint,
3403
+ "aria-busy": downloading,
3404
+ onClick: () => {
3405
+ if (downloaded) onOpen?.(path);
3406
+ else onDownload();
3407
+ },
3408
+ children: [downloading && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SpinnerIcon, {}), label]
3409
+ });
3410
+ if (!downloaded || onReveal === void 0) return main;
3411
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3412
+ ref: groupRef,
3413
+ style: GROUP_STYLE$2,
3414
+ children: [
3415
+ main,
3416
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3417
+ type: "button",
3418
+ style: {
3419
+ ...style,
3420
+ ...ARROW_STYLE,
3421
+ color: DOWNLOADED_COLOR
3422
+ },
3423
+ "aria-haspopup": "menu",
3424
+ "aria-expanded": menuOpen,
3425
+ "aria-label": t("pdf.revealInFolder"),
3426
+ title: t("pdf.revealInFolder"),
3427
+ disabled,
3428
+ onClick: () => setMenuOpen((value) => !value),
3429
+ children: "▾"
3430
+ }),
3431
+ menuOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3432
+ role: "menu",
3433
+ style: MENU_STYLE,
3434
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3435
+ type: "button",
3436
+ role: "menuitem",
3437
+ style: MENU_ITEM_STYLE,
3438
+ onClick: () => {
3439
+ setMenuOpen(false);
3440
+ onReveal?.(path);
3441
+ },
3442
+ children: t("pdf.revealInFolder")
3443
+ })
3444
+ })
3445
+ ]
3446
+ });
3447
+ }
3448
+ const GROUP_STYLE$2 = {
3449
+ position: "relative",
3450
+ display: "inline-flex",
3451
+ alignItems: "center",
3452
+ gap: 2
3453
+ };
3454
+ const ARROW_STYLE = {
3455
+ padding: "0 1px",
3456
+ lineHeight: 1,
3457
+ fontSize: 20
3458
+ };
3459
+ const MENU_STYLE = {
3460
+ position: "absolute",
3461
+ top: "100%",
3462
+ right: 0,
3463
+ marginTop: 3,
3464
+ zIndex: 40,
3465
+ display: "flex",
3466
+ flexDirection: "column",
3467
+ minWidth: 132,
3468
+ padding: 2,
3469
+ borderRadius: 6,
3470
+ border: "1px solid rgba(127,127,127,0.3)",
3471
+ background: "#fff",
3472
+ boxShadow: "0 6px 20px rgba(0,0,0,0.18)"
3473
+ };
3474
+ const MENU_ITEM_STYLE = {
3475
+ padding: "5px 8px",
3476
+ border: "none",
3477
+ borderRadius: 4,
3478
+ background: "transparent",
3479
+ color: "#1f2328",
3480
+ fontSize: 12,
3481
+ lineHeight: 1.4,
3482
+ textAlign: "left",
3483
+ whiteSpace: "nowrap",
3484
+ cursor: "pointer"
3485
+ };
2989
3486
  // src/client/Entities.tsx
2990
3487
  /**
2991
3488
  * PubTator3 实体标注面板。
@@ -3059,9 +3556,9 @@ function EntityPanel({ t, taskId, paperId, pmid }) {
3059
3556
  });
3060
3557
  const groups = groupByType(entities);
3061
3558
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3062
- style: WRAP_STYLE$6,
3559
+ style: WRAP_STYLE$7,
3063
3560
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3064
- style: HINT_STYLE$2,
3561
+ style: HINT_STYLE$3,
3065
3562
  children: [
3066
3563
  tpl(t("entity.count"), { n: entities.length }),
3067
3564
  " · ",
@@ -3104,7 +3601,7 @@ function entityTitle(entity) {
3104
3601
  entity.identifier
3105
3602
  ].filter((part) => part !== "").join(" · ");
3106
3603
  }
3107
- const WRAP_STYLE$6 = {
3604
+ const WRAP_STYLE$7 = {
3108
3605
  display: "flex",
3109
3606
  flexDirection: "column",
3110
3607
  gap: 5,
@@ -3113,7 +3610,7 @@ const WRAP_STYLE$6 = {
3113
3610
  borderRadius: 6,
3114
3611
  background: "rgba(127,127,127,0.07)"
3115
3612
  };
3116
- const HINT_STYLE$2 = {
3613
+ const HINT_STYLE$3 = {
3117
3614
  fontSize: 11,
3118
3615
  opacity: .68
3119
3616
  };
@@ -3159,76 +3656,762 @@ function insertQuote(actions, text, summary) {
3159
3656
  const span = actions.captureInsertion();
3160
3657
  return actions.insertText(text, span) ? "inserted" : "draft-changed";
3161
3658
  }
3162
- // src/client/PaperPane.tsx
3659
+ // src/client/PreviewFrame.tsx
3163
3660
  /**
3164
- * 独立论文 tab:`dsh-resource://litsearch-paper/<taskId>/<paperId>`。
3661
+ * 内联 HTML 全文预览框。
3165
3662
  *
3166
- * 展示完整摘要、标识符、筛读与标注、全文预览和 PubTator 实体;数据来自
3167
- * 单篇详情端点,避免为了打开一篇论文拉取整页结果。
3663
+ * 打开后先在框内显示旋转图标与醒目的加载文案,内容就绪后再显示 iframe。
3168
3664
  */
3169
- const PAPER_ADDRESS_PREFIX = "dsh-resource://litsearch-paper/";
3170
- function paperRefFromAddress(address) {
3171
- if (!address.startsWith("dsh-resource://litsearch-paper/")) return null;
3172
- const rest = address.slice(31);
3173
- const slash = rest.indexOf("/");
3174
- if (slash <= 0) return null;
3175
- const taskPart = rest.slice(0, slash);
3176
- const paperPart = rest.slice(slash + 1);
3177
- if (paperPart === "" || paperPart.includes("/")) return null;
3178
- try {
3179
- return {
3180
- taskId: decodeURIComponent(taskPart),
3181
- paperId: decodeURIComponent(paperPart)
3182
- };
3183
- } catch {
3184
- return null;
3185
- }
3665
+ function PreviewFrame({ t, src, height, onClose }) {
3666
+ const [loadedSrc, setLoadedSrc] = (0, react.useState)("");
3667
+ const loaded = loadedSrc === src;
3668
+ const title = t("pdf.fulltext");
3669
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3670
+ style: WRAP_STYLE$6,
3671
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3672
+ style: HEAD_STYLE$3,
3673
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: title }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3674
+ type: "button",
3675
+ style: CLOSE_STYLE,
3676
+ onClick: onClose,
3677
+ children: t("pdf.closePreview")
3678
+ })]
3679
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3680
+ style: {
3681
+ ...BODY_STYLE$1,
3682
+ height
3683
+ },
3684
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("iframe", {
3685
+ title,
3686
+ src,
3687
+ onLoad: () => setLoadedSrc(src),
3688
+ style: {
3689
+ ...IFRAME_STYLE,
3690
+ opacity: loaded ? 1 : 0
3691
+ }
3692
+ }, src), !loaded && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3693
+ style: OVERLAY_STYLE,
3694
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Spinner, {}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3695
+ style: LOADING_TEXT_STYLE,
3696
+ children: t("pdf.htmlLoading")
3697
+ })]
3698
+ })]
3699
+ })]
3700
+ });
3186
3701
  }
3187
- const SCREENINGS$3 = [
3188
- "unread",
3189
- "read",
3190
- "included",
3191
- "excluded"
3192
- ];
3193
- const PDF_COLOR$1 = {
3194
- unknown: "rgba(127,127,127,0.8)",
3195
- available: "#508cff",
3196
- downloaded: "#2e9e5b",
3197
- "html-only": "#b26a00",
3198
- paywalled: "#b26a00",
3199
- blocked: "#d64545",
3200
- failed: "#d64545"
3201
- };
3202
- function messageOf$3(error) {
3203
- return error instanceof Error ? error.message : String(error);
3702
+ /** 不依赖 CSS keyframes 的旋转图标(SMIL),避免注入 <style> 受 CSP 影响。 */
3703
+ function Spinner() {
3704
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
3705
+ width: 30,
3706
+ height: 30,
3707
+ viewBox: "0 0 24 24",
3708
+ "aria-hidden": "true",
3709
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
3710
+ cx: "12",
3711
+ cy: "12",
3712
+ r: "9",
3713
+ fill: "none",
3714
+ stroke: "rgba(80,140,255,0.22)",
3715
+ strokeWidth: "3"
3716
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
3717
+ cx: "12",
3718
+ cy: "12",
3719
+ r: "9",
3720
+ fill: "none",
3721
+ stroke: ACCENT,
3722
+ strokeWidth: "3",
3723
+ strokeLinecap: "round",
3724
+ strokeDasharray: "56",
3725
+ strokeDashoffset: "42",
3726
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("animateTransform", {
3727
+ attributeName: "transform",
3728
+ type: "rotate",
3729
+ from: "0 12 12",
3730
+ to: "360 12 12",
3731
+ dur: "0.9s",
3732
+ repeatCount: "indefinite"
3733
+ })
3734
+ })]
3735
+ });
3204
3736
  }
3205
- function PaperPane({ t, useTabInfo, inputActions }) {
3206
- const info = useTabInfo();
3207
- const ref = paperRefFromAddress(info.tab.navigation?.address || info.tab.contentId || "");
3208
- const [detail, setDetail] = (0, react.useState)(null);
3209
- const [loading, setLoading] = (0, react.useState)(true);
3210
- const [error, setError] = (0, react.useState)(null);
3211
- const [busy, setBusy] = (0, react.useState)(false);
3212
- const [nonce, setNonce] = (0, react.useState)(0);
3213
- const [toast, setToast] = (0, react.useState)("");
3214
- const [editing, setEditing] = (0, react.useState)(false);
3215
- const [preview, setPreview] = (0, react.useState)(null);
3216
- const [note, setNote] = (0, react.useState)("");
3217
- const [tags, setTags] = (0, react.useState)("");
3218
- const [order, setOrder] = (0, react.useState)("");
3219
- const [risFallback, setRisFallback] = (0, react.useState)(null);
3220
- const taskId = ref?.taskId ?? "";
3221
- ref?.paperId;
3222
- (0, react.useEffect)(() => {
3223
- if (ref === null) {
3224
- setLoading(false);
3225
- setError(t("paperTab.notFound"));
3226
- return;
3227
- }
3228
- const controller = new AbortController();
3229
- setLoading(true);
3230
- fetchPaper(ref.taskId, ref.paperId, controller.signal).then((next) => {
3231
- if (controller.signal.aborted) return;
3737
+ const WRAP_STYLE$6 = {
3738
+ marginTop: 6,
3739
+ border: "1px solid rgba(127,127,127,0.22)",
3740
+ borderRadius: 6,
3741
+ overflow: "hidden"
3742
+ };
3743
+ const HEAD_STYLE$3 = {
3744
+ display: "flex",
3745
+ alignItems: "center",
3746
+ justifyContent: "space-between",
3747
+ gap: 8,
3748
+ padding: "3px 8px",
3749
+ fontSize: 11,
3750
+ background: "rgba(127,127,127,0.08)"
3751
+ };
3752
+ const CLOSE_STYLE = {
3753
+ padding: 0,
3754
+ border: "none",
3755
+ background: "transparent",
3756
+ color: ACCENT,
3757
+ fontSize: 12.5,
3758
+ cursor: "pointer"
3759
+ };
3760
+ const BODY_STYLE$1 = {
3761
+ position: "relative",
3762
+ background: "#fff",
3763
+ overflow: "hidden"
3764
+ };
3765
+ const IFRAME_STYLE = {
3766
+ display: "block",
3767
+ width: "100%",
3768
+ height: "100%",
3769
+ border: "none",
3770
+ background: "#fff",
3771
+ transition: "opacity 0.15s ease"
3772
+ };
3773
+ const OVERLAY_STYLE = {
3774
+ position: "absolute",
3775
+ inset: 0,
3776
+ display: "flex",
3777
+ flexDirection: "column",
3778
+ alignItems: "center",
3779
+ justifyContent: "center",
3780
+ gap: 10,
3781
+ background: "#fff"
3782
+ };
3783
+ const LOADING_TEXT_STYLE = {
3784
+ fontSize: 13.5,
3785
+ fontWeight: 700,
3786
+ letterSpacing: "0.02em",
3787
+ color: ACCENT
3788
+ };
3789
+ // src/client/ZoteroSyncDialog.tsx
3790
+ /** 批量同步的并发数,与批量下载保持一致。 */
3791
+ const SYNC_CONCURRENCY = 5;
3792
+ const PHASE_COLOR$1 = {
3793
+ pending: "rgba(127,127,127,0.9)",
3794
+ syncing: ACCENT,
3795
+ synced: "#2e9e5b",
3796
+ failed: ERROR
3797
+ };
3798
+ function initialRows(items, states) {
3799
+ return items.map((item) => {
3800
+ const state = states[item.paperId];
3801
+ const phase = state?.zoteroStatus === "synced" ? "synced" : state?.zoteroStatus === "failed" ? "failed" : "pending";
3802
+ return {
3803
+ paperId: item.paperId,
3804
+ title: item.title,
3805
+ phase,
3806
+ ...item.source === void 0 || item.source === "" ? {} : { source: item.source },
3807
+ ...item.meta === void 0 || item.meta === "" ? {} : { meta: item.meta },
3808
+ ...state?.zoteroError === void 0 ? {} : { error: state.zoteroError },
3809
+ ...phase === "synced" && state?.zoteroCollection !== void 0 ? { note: state.zoteroCollection } : {}
3810
+ };
3811
+ });
3812
+ }
3813
+ function targetLabel(t, target, currentId) {
3814
+ const indent = " ".repeat(Math.max(0, target.level));
3815
+ const suffix = target.filesEditable === false ? ` ${t("zotero.dialog.readonly")}` : target.id === currentId ? ` ${t("zotero.dialog.current")}` : "";
3816
+ return `${indent}${target.name}${suffix}`;
3817
+ }
3818
+ function ZoteroSyncDialog({ t, open, taskId, items, states, onClose, onDone, meta }) {
3819
+ const [rows, setRows] = (0, react.useState)(() => initialRows(items, states));
3820
+ const [targets, setTargets] = (0, react.useState)([]);
3821
+ const [currentId, setCurrentId] = (0, react.useState)(void 0);
3822
+ const [targetId, setTargetId] = (0, react.useState)("");
3823
+ const [loading, setLoading] = (0, react.useState)(false);
3824
+ /** 目录列表本轮是否已读完(含失败);自动同步必须等它就绪,避免用到上一批目录。 */
3825
+ const [targetsReady, setTargetsReady] = (0, react.useState)(false);
3826
+ const [loadError, setLoadError] = (0, react.useState)(void 0);
3827
+ const [running, setRunning] = (0, react.useState)(false);
3828
+ const [notice, setNotice] = (0, react.useState)(void 0);
3829
+ const [hasRun, setHasRun] = (0, react.useState)(false);
3830
+ /** 取消后停止派发剩余文献;已发出的请求仍会完成。 */
3831
+ const stopRef = (0, react.useRef)(false);
3832
+ /** 自动同步只跑一次;每次打开/换清单时重置。 */
3833
+ const autoStartRef = (0, react.useRef)(false);
3834
+ const key = items.map((item) => item.paperId).join(",");
3835
+ const desiredId = meta?.initialTarget?.id;
3836
+ const desiredName = meta?.initialTarget?.name ?? "";
3837
+ (0, react.useEffect)(() => {
3838
+ if (!open) return;
3839
+ setRows(initialRows(items, states));
3840
+ setHasRun(false);
3841
+ setNotice(void 0);
3842
+ setLoadError(void 0);
3843
+ setTargetsReady(false);
3844
+ setTargets([]);
3845
+ setTargetId("");
3846
+ autoStartRef.current = false;
3847
+ let cancelled = false;
3848
+ setLoading(true);
3849
+ fetchZoteroTargets().then((result) => {
3850
+ if (cancelled) return;
3851
+ if (!result.ok) {
3852
+ setTargets([]);
3853
+ setLoadError(result.error ?? t("zotero.dialog.unknownError"));
3854
+ return;
3855
+ }
3856
+ setTargets(result.targets);
3857
+ setCurrentId(result.current?.id);
3858
+ const matched = desiredId === void 0 ? void 0 : result.targets.find((entry) => entry.id === desiredId);
3859
+ if (desiredId !== void 0 && matched === void 0) setNotice(tpl(t("zotero.dialog.modelTargetMissing"), { name: desiredName }));
3860
+ setTargetId(matched?.id ?? result.current?.id ?? result.targets[0]?.id ?? "");
3861
+ }).catch((error) => {
3862
+ if (cancelled) return;
3863
+ setTargets([]);
3864
+ setLoadError(error instanceof Error ? error.message : String(error));
3865
+ }).finally(() => {
3866
+ if (cancelled) return;
3867
+ setLoading(false);
3868
+ setTargetsReady(true);
3869
+ });
3870
+ return () => {
3871
+ cancelled = true;
3872
+ };
3873
+ }, [
3874
+ open,
3875
+ key,
3876
+ desiredId
3877
+ ]);
3878
+ const target = targets.find((entry) => entry.id === targetId);
3879
+ const total = rows.length;
3880
+ const synced = rows.filter((row) => row.phase === "synced").length;
3881
+ const failed = rows.filter((row) => row.phase === "failed").length;
3882
+ const pending = rows.filter((row) => row.phase === "pending").length;
3883
+ const syncing = rows.filter((row) => row.phase === "syncing").length;
3884
+ const done = total - pending - syncing;
3885
+ const rerunHint = rows.some((row) => row.phase === "synced");
3886
+ const run = async () => {
3887
+ if (running || target === void 0) return;
3888
+ setRunning(true);
3889
+ setNotice(void 0);
3890
+ setHasRun(true);
3891
+ stopRef.current = false;
3892
+ const list = rows.map((row) => ({
3893
+ ...row,
3894
+ phase: "pending",
3895
+ error: void 0,
3896
+ note: void 0
3897
+ }));
3898
+ setRows(list);
3899
+ const failedIds = [];
3900
+ const runOne = async (row) => {
3901
+ setRows((prev) => prev.map((entry) => entry.paperId === row.paperId ? {
3902
+ ...entry,
3903
+ phase: "syncing",
3904
+ error: void 0
3905
+ } : entry));
3906
+ try {
3907
+ const result = await syncZotero(taskId, [row.paperId], {
3908
+ id: target.id,
3909
+ name: target.name,
3910
+ ...target.filesEditable === void 0 ? {} : { filesEditable: target.filesEditable }
3911
+ });
3912
+ const item = result.items[0];
3913
+ const ok = item?.ok ?? result.ok;
3914
+ const note = item?.ok === true ? result.messages[0] : void 0;
3915
+ if (!ok) failedIds.push(row.paperId);
3916
+ setRows((prev) => prev.map((entry) => entry.paperId === row.paperId ? {
3917
+ ...entry,
3918
+ phase: ok ? "synced" : "failed",
3919
+ ...ok ? note === void 0 ? {} : { note } : { error: item?.error ?? result.messages[0] ?? t("zotero.dialog.failFallback") }
3920
+ } : entry));
3921
+ } catch (error) {
3922
+ const message = error instanceof Error ? error.message : String(error);
3923
+ stopRef.current = true;
3924
+ failedIds.push(row.paperId);
3925
+ setRows((prev) => prev.map((entry) => entry.paperId === row.paperId ? {
3926
+ ...entry,
3927
+ phase: "failed",
3928
+ error: message
3929
+ } : entry));
3930
+ setNotice(message);
3931
+ }
3932
+ };
3933
+ let cursor = 0;
3934
+ const worker = async () => {
3935
+ while (!stopRef.current && cursor < list.length) {
3936
+ const row = list[cursor];
3937
+ cursor += 1;
3938
+ if (row !== void 0) await runOne(row);
3939
+ }
3940
+ };
3941
+ await Promise.all(Array.from({ length: Math.min(SYNC_CONCURRENCY, list.length) }, () => worker()));
3942
+ setRunning(false);
3943
+ onDone(failedIds);
3944
+ };
3945
+ (0, react.useEffect)(() => {
3946
+ if (!open || loading || !targetsReady || meta?.autoStart !== true || autoStartRef.current) return;
3947
+ if (running) return;
3948
+ if (desiredId === void 0) {
3949
+ autoStartRef.current = true;
3950
+ return;
3951
+ }
3952
+ if (target === void 0 || target.id !== desiredId) {
3953
+ if (loadError !== void 0 || targets.length === 0) {
3954
+ autoStartRef.current = true;
3955
+ setNotice(t("zotero.dialog.modelNoAuto"));
3956
+ } else if (!targets.some((entry) => entry.id === desiredId)) autoStartRef.current = true;
3957
+ return;
3958
+ }
3959
+ autoStartRef.current = true;
3960
+ run();
3961
+ }, [
3962
+ open,
3963
+ loading,
3964
+ targetsReady,
3965
+ running,
3966
+ target,
3967
+ targets,
3968
+ loadError,
3969
+ desiredId,
3970
+ meta?.autoStart
3971
+ ]);
3972
+ const summary = running ? t("zotero.dialog.running") : done > 0 && failed === 0 && pending === 0 ? tpl(t("zotero.dialog.done"), { n: synced }) : done > 0 ? tpl(t("zotero.dialog.partial"), {
3973
+ saved: synced,
3974
+ failed
3975
+ }) : void 0;
3976
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
3977
+ open,
3978
+ onClose: () => {
3979
+ if (!running) onClose();
3980
+ },
3981
+ title: meta === void 0 ? t("zotero.dialog.title") : t("zotero.dialog.modelTitle"),
3982
+ description: tpl(meta === void 0 ? t("zotero.dialog.desc") : meta.autoStart ? t("zotero.dialog.modelDescAuto") : t("zotero.dialog.modelDescPick"), { n: total }),
3983
+ className: WIDE_MODAL_CLASS$2,
3984
+ footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3985
+ style: FOOTER_STYLE$1,
3986
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3987
+ style: statusStyle,
3988
+ children: tpl(t("zotero.dialog.progress"), {
3989
+ done,
3990
+ total
3991
+ })
3992
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3993
+ style: ACTIONS_STYLE,
3994
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
3995
+ variant: "outline",
3996
+ size: "sm",
3997
+ type: "button",
3998
+ onClick: () => {
3999
+ if (running) stopRef.current = true;
4000
+ else onClose();
4001
+ },
4002
+ children: running ? t("zotero.dialog.cancel") : t("zotero.dialog.close")
4003
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
4004
+ variant: "primary",
4005
+ size: "sm",
4006
+ type: "button",
4007
+ onClick: run,
4008
+ disabled: running || loading || target === void 0,
4009
+ children: running ? t("zotero.dialog.syncing") : hasRun || rerunHint ? t("zotero.dialog.resync") : t("zotero.dialog.sync")
4010
+ })]
4011
+ })]
4012
+ }),
4013
+ children: [
4014
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: WIDE_MODAL_CSS$2 }),
4015
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4016
+ style: TARGET_BOX_STYLE,
4017
+ children: [
4018
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
4019
+ style: TARGET_LABEL_STYLE,
4020
+ htmlFor: "litsearch-zotero-target",
4021
+ children: t("zotero.dialog.target")
4022
+ }),
4023
+ loading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4024
+ style: MUTED_STYLE,
4025
+ children: t("zotero.dialog.loading")
4026
+ }) : loadError !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4027
+ style: ERROR_TEXT_STYLE,
4028
+ title: loadError,
4029
+ children: tpl(t("zotero.dialog.loadFail"), { message: loadError })
4030
+ }) : targets.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4031
+ style: MUTED_STYLE,
4032
+ children: t("zotero.dialog.noTarget")
4033
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
4034
+ id: "litsearch-zotero-target",
4035
+ style: SELECT_STYLE$4,
4036
+ value: targetId,
4037
+ onChange: (event) => setTargetId(event.target.value),
4038
+ disabled: running,
4039
+ children: targets.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
4040
+ value: entry.id,
4041
+ children: targetLabel(t, entry, currentId)
4042
+ }, entry.id))
4043
+ }),
4044
+ meta?.initialTarget !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4045
+ style: HINT_STYLE$2,
4046
+ children: tpl(t("zotero.dialog.modelTarget"), { name: meta.initialTarget.name })
4047
+ }),
4048
+ rerunHint && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4049
+ style: HINT_STYLE$2,
4050
+ children: t("zotero.dialog.rerunHint")
4051
+ })
4052
+ ]
4053
+ }),
4054
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4055
+ style: OVERVIEW_STYLE$1,
4056
+ role: "status",
4057
+ "aria-live": "polite",
4058
+ children: [
4059
+ meta !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4060
+ style: MODEL_BADGE_STYLE$1,
4061
+ children: [
4062
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4063
+ style: MODEL_TAG_STYLE$1,
4064
+ children: t("zotero.dialog.originModel")
4065
+ }),
4066
+ meta.scopeLabel !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: meta.scopeLabel }),
4067
+ meta.reason !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4068
+ style: REASON_STYLE$1,
4069
+ title: meta.reason,
4070
+ children: meta.reason
4071
+ })
4072
+ ]
4073
+ }),
4074
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4075
+ style: COUNTS_STYLE$1,
4076
+ children: [
4077
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: tpl(t("zotero.dialog.total"), { n: total }) }),
4078
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4079
+ style: { color: PHASE_COLOR$1.synced },
4080
+ children: tpl(t("zotero.dialog.synced"), { n: synced })
4081
+ }),
4082
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4083
+ style: { color: PHASE_COLOR$1.syncing },
4084
+ children: tpl(t("zotero.dialog.syncingCount"), { n: syncing })
4085
+ }),
4086
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: tpl(t("zotero.dialog.pending"), { n: pending }) }),
4087
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4088
+ style: { color: PHASE_COLOR$1.failed },
4089
+ children: tpl(t("zotero.dialog.failed"), { n: failed })
4090
+ })
4091
+ ]
4092
+ }),
4093
+ summary !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4094
+ style: {
4095
+ ...SUMMARY_STYLE$1,
4096
+ color: failed > 0 ? "#d64545" : "#2e9e5b"
4097
+ },
4098
+ children: [running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SpinnerIcon, {}), summary]
4099
+ }),
4100
+ notice !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4101
+ style: ERROR_TEXT_STYLE,
4102
+ title: notice,
4103
+ children: notice
4104
+ })
4105
+ ]
4106
+ }),
4107
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
4108
+ style: LIST_STYLE$4,
4109
+ children: rows.map((row, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
4110
+ style: ROW_STYLE$5,
4111
+ children: [
4112
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4113
+ style: INDEX_STYLE$1,
4114
+ children: index + 1
4115
+ }),
4116
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4117
+ style: ROW_MAIN_STYLE$2,
4118
+ children: [
4119
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4120
+ style: ROW_TITLE_STYLE$2,
4121
+ title: row.title,
4122
+ children: row.title === "" ? t("zotero.dialog.untitled") : row.title
4123
+ }),
4124
+ row.source !== void 0 && row.source !== "" || row.meta !== void 0 && row.meta !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4125
+ style: ROW_META_STYLE$3,
4126
+ children: [row.source === void 0 || row.source === "" ? "" : tpl(t("zotero.dialog.source"), { source: row.source }), row.meta ?? ""].filter((part) => part !== "").join(" · ")
4127
+ }) : null,
4128
+ row.error !== void 0 && row.error !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4129
+ style: ROW_ERROR_STYLE$2,
4130
+ title: row.error,
4131
+ children: row.error
4132
+ }),
4133
+ row.error === void 0 && row.note !== void 0 && row.note !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4134
+ style: ROW_NOTE_STYLE,
4135
+ title: row.note,
4136
+ children: row.note
4137
+ })
4138
+ ]
4139
+ }),
4140
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4141
+ style: {
4142
+ ...STATUS_CHIP_STYLE$1,
4143
+ color: PHASE_COLOR$1[row.phase]
4144
+ },
4145
+ children: [row.phase === "syncing" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SpinnerIcon, {}), t(`zotero.dialog.status.${row.phase}`)]
4146
+ })
4147
+ ]
4148
+ }, row.paperId))
4149
+ })
4150
+ ]
4151
+ });
4152
+ }
4153
+ const WIDE_MODAL_CLASS$2 = "litsearch-zotero-modal";
4154
+ const WIDE_MODAL_CSS$2 = `
4155
+ .${WIDE_MODAL_CLASS$2}{width:min(760px,calc(100vw - 48px))}
4156
+ `;
4157
+ const FOOTER_STYLE$1 = {
4158
+ display: "flex",
4159
+ alignItems: "center",
4160
+ justifyContent: "space-between",
4161
+ gap: 8
4162
+ };
4163
+ const ACTIONS_STYLE = {
4164
+ display: "flex",
4165
+ alignItems: "center",
4166
+ gap: 8
4167
+ };
4168
+ const TARGET_BOX_STYLE = {
4169
+ display: "flex",
4170
+ flexDirection: "column",
4171
+ gap: 6,
4172
+ marginBottom: 10,
4173
+ padding: "8px 10px",
4174
+ borderRadius: 8,
4175
+ border: "1px solid rgba(80,140,255,0.25)",
4176
+ background: "rgba(80,140,255,0.06)"
4177
+ };
4178
+ const TARGET_LABEL_STYLE = {
4179
+ fontSize: 11,
4180
+ fontWeight: 650,
4181
+ opacity: .7
4182
+ };
4183
+ const SELECT_STYLE$4 = {
4184
+ width: "100%",
4185
+ padding: "4px 6px",
4186
+ fontSize: 12.5,
4187
+ borderRadius: 6,
4188
+ border: "1px solid rgba(127,127,127,0.35)",
4189
+ background: "transparent",
4190
+ color: "inherit"
4191
+ };
4192
+ const HINT_STYLE$2 = {
4193
+ fontSize: 11,
4194
+ lineHeight: 1.5,
4195
+ opacity: .7
4196
+ };
4197
+ const OVERVIEW_STYLE$1 = {
4198
+ display: "flex",
4199
+ flexDirection: "column",
4200
+ gap: 6,
4201
+ marginBottom: 8,
4202
+ padding: "8px 10px",
4203
+ borderRadius: 8,
4204
+ border: "1px solid rgba(80,140,255,0.25)",
4205
+ background: "rgba(80,140,255,0.08)"
4206
+ };
4207
+ const COUNTS_STYLE$1 = {
4208
+ display: "flex",
4209
+ flexWrap: "wrap",
4210
+ gap: 10,
4211
+ fontSize: 12,
4212
+ lineHeight: 1.5
4213
+ };
4214
+ const SUMMARY_STYLE$1 = {
4215
+ display: "inline-flex",
4216
+ alignItems: "center",
4217
+ gap: 4,
4218
+ fontSize: 12,
4219
+ fontWeight: 650
4220
+ };
4221
+ const MUTED_STYLE = {
4222
+ fontSize: 12,
4223
+ opacity: .7
4224
+ };
4225
+ const ERROR_TEXT_STYLE = {
4226
+ fontSize: 11.5,
4227
+ lineHeight: 1.5,
4228
+ color: ERROR,
4229
+ overflow: "hidden",
4230
+ display: "-webkit-box",
4231
+ WebkitLineClamp: 2,
4232
+ WebkitBoxOrient: "vertical",
4233
+ wordBreak: "break-word"
4234
+ };
4235
+ const LIST_STYLE$4 = {
4236
+ listStyle: "none",
4237
+ margin: 0,
4238
+ padding: 0,
4239
+ maxHeight: "46vh",
4240
+ overflowY: "auto"
4241
+ };
4242
+ const ROW_STYLE$5 = {
4243
+ display: "flex",
4244
+ alignItems: "flex-start",
4245
+ gap: 8,
4246
+ padding: "7px 0",
4247
+ borderTop: "1px solid rgba(127,127,127,0.18)"
4248
+ };
4249
+ const INDEX_STYLE$1 = {
4250
+ flexShrink: 0,
4251
+ minWidth: 18,
4252
+ fontSize: 11,
4253
+ lineHeight: "18px",
4254
+ opacity: .5,
4255
+ textAlign: "right"
4256
+ };
4257
+ const ROW_MAIN_STYLE$2 = {
4258
+ display: "flex",
4259
+ flexDirection: "column",
4260
+ gap: 2,
4261
+ flex: 1,
4262
+ minWidth: 0
4263
+ };
4264
+ const ROW_TITLE_STYLE$2 = {
4265
+ fontSize: 12.5,
4266
+ lineHeight: 1.45,
4267
+ fontWeight: 600,
4268
+ overflow: "hidden",
4269
+ display: "-webkit-box",
4270
+ WebkitLineClamp: 2,
4271
+ WebkitBoxOrient: "vertical",
4272
+ wordBreak: "break-word"
4273
+ };
4274
+ const ROW_ERROR_STYLE$2 = {
4275
+ fontSize: 11,
4276
+ lineHeight: 1.4,
4277
+ color: ERROR,
4278
+ opacity: .85,
4279
+ overflow: "hidden",
4280
+ display: "-webkit-box",
4281
+ WebkitLineClamp: 2,
4282
+ WebkitBoxOrient: "vertical",
4283
+ wordBreak: "break-word"
4284
+ };
4285
+ const ROW_NOTE_STYLE = {
4286
+ fontSize: 11,
4287
+ lineHeight: 1.4,
4288
+ opacity: .65,
4289
+ overflow: "hidden",
4290
+ display: "-webkit-box",
4291
+ WebkitLineClamp: 1,
4292
+ WebkitBoxOrient: "vertical",
4293
+ wordBreak: "break-word"
4294
+ };
4295
+ const MODEL_BADGE_STYLE$1 = {
4296
+ display: "flex",
4297
+ flexWrap: "wrap",
4298
+ alignItems: "center",
4299
+ gap: 8,
4300
+ fontSize: 12,
4301
+ lineHeight: 1.5,
4302
+ paddingBottom: 4,
4303
+ borderBottom: "1px solid rgba(127,127,127,0.18)"
4304
+ };
4305
+ const MODEL_TAG_STYLE$1 = {
4306
+ flexShrink: 0,
4307
+ padding: "1px 6px",
4308
+ borderRadius: 4,
4309
+ border: `1px solid ${ACCENT}`,
4310
+ color: ACCENT,
4311
+ fontWeight: 650,
4312
+ fontSize: 11
4313
+ };
4314
+ const REASON_STYLE$1 = {
4315
+ opacity: .75,
4316
+ overflow: "hidden",
4317
+ textOverflow: "ellipsis",
4318
+ whiteSpace: "nowrap",
4319
+ maxWidth: "100%"
4320
+ };
4321
+ const ROW_META_STYLE$3 = {
4322
+ fontSize: 11,
4323
+ lineHeight: 1.4,
4324
+ opacity: .65,
4325
+ overflow: "hidden",
4326
+ textOverflow: "ellipsis",
4327
+ whiteSpace: "nowrap"
4328
+ };
4329
+ const STATUS_CHIP_STYLE$1 = {
4330
+ display: "inline-flex",
4331
+ alignItems: "center",
4332
+ gap: 3,
4333
+ flexShrink: 0,
4334
+ padding: "1px 6px",
4335
+ borderRadius: 4,
4336
+ border: "1px solid currentColor",
4337
+ fontSize: 11,
4338
+ lineHeight: 1.5,
4339
+ whiteSpace: "nowrap"
4340
+ };
4341
+ // src/client/PaperPane.tsx
4342
+ /**
4343
+ * 独立论文 tab:`dsh-resource://litsearch-paper/<taskId>/<paperId>`。
4344
+ *
4345
+ * 展示完整摘要、标识符、筛读与分组、全文预览和 PubTator 实体;数据来自
4346
+ * 单篇详情端点,避免为了打开一篇论文拉取整页结果。
4347
+ */
4348
+ const PAPER_ADDRESS_PREFIX = "dsh-resource://litsearch-paper/";
4349
+ function paperRefFromAddress(address) {
4350
+ if (!address.startsWith("dsh-resource://litsearch-paper/")) return null;
4351
+ const rest = address.slice(31);
4352
+ const slash = rest.indexOf("/");
4353
+ if (slash <= 0) return null;
4354
+ const taskPart = rest.slice(0, slash);
4355
+ const paperPart = rest.slice(slash + 1);
4356
+ if (paperPart === "" || paperPart.includes("/")) return null;
4357
+ try {
4358
+ return {
4359
+ taskId: decodeURIComponent(taskPart),
4360
+ paperId: decodeURIComponent(paperPart)
4361
+ };
4362
+ } catch {
4363
+ return null;
4364
+ }
4365
+ }
4366
+ const SCREENINGS$3 = [
4367
+ "unread",
4368
+ "read",
4369
+ "included",
4370
+ "excluded"
4371
+ ];
4372
+ const PDF_COLOR$1 = {
4373
+ unknown: "rgba(127,127,127,0.8)",
4374
+ available: "#508cff",
4375
+ downloaded: "#2e9e5b",
4376
+ missing: "#d64545",
4377
+ "html-only": "#b26a00",
4378
+ paywalled: "#b26a00",
4379
+ blocked: "#d64545",
4380
+ failed: "#d64545"
4381
+ };
4382
+ function messageOf$3(error) {
4383
+ return error instanceof Error ? error.message : String(error);
4384
+ }
4385
+ function PaperPane({ t, useTabInfo, inputActions, onOpenLocal, onRevealLocal }) {
4386
+ const info = useTabInfo();
4387
+ const ref = paperRefFromAddress(info.tab.navigation?.address || info.tab.contentId || "");
4388
+ const [detail, setDetail] = (0, react.useState)(null);
4389
+ const [loading, setLoading] = (0, react.useState)(true);
4390
+ const [error, setError] = (0, react.useState)(null);
4391
+ const [busy, setBusy] = (0, react.useState)(false);
4392
+ const [downloading, setDownloading] = (0, react.useState)(false);
4393
+ const [nonce, setNonce] = (0, react.useState)(0);
4394
+ const [toast, setToastState] = (0, react.useState)("");
4395
+ const [toastTone, setToastTone] = (0, react.useState)("info");
4396
+ const notify = (text, tone = "info") => {
4397
+ setToastState(text);
4398
+ setToastTone(tone);
4399
+ };
4400
+ const [showHtml, setShowHtml] = (0, react.useState)(false);
4401
+ const [zoteroOpen, setZoteroOpen] = (0, react.useState)(false);
4402
+ const [risFallback, setRisFallback] = (0, react.useState)(null);
4403
+ const taskId = ref?.taskId ?? "";
4404
+ ref?.paperId;
4405
+ (0, react.useEffect)(() => {
4406
+ if (ref === null) {
4407
+ setLoading(false);
4408
+ setError(t("paperTab.notFound"));
4409
+ return;
4410
+ }
4411
+ const controller = new AbortController();
4412
+ setLoading(true);
4413
+ fetchPaper(ref.taskId, ref.paperId, controller.signal).then((next) => {
4414
+ if (controller.signal.aborted) return;
3232
4415
  setDetail(next);
3233
4416
  setError(null);
3234
4417
  setLoading(false);
@@ -3245,27 +4428,20 @@ function PaperPane({ t, useTabInfo, inputActions }) {
3245
4428
  nonce,
3246
4429
  t
3247
4430
  ]);
3248
- const state = detail?.state;
3249
- const noteKey = state?.note ?? "";
3250
- const tagsKey = (state?.tags ?? []).join(",");
3251
- const orderKey = state?.shortlistOrder ?? "";
3252
4431
  (0, react.useEffect)(() => {
3253
- setNote(noteKey);
3254
- setTags(tagsKey);
3255
- setOrder(orderKey === "" ? "" : String(orderKey));
3256
- }, [
3257
- noteKey,
3258
- tagsKey,
3259
- orderKey
3260
- ]);
4432
+ if (ref === null) return void 0;
4433
+ return subscribeTaskSummary((summary) => {
4434
+ if (summary.id === ref.taskId) setNonce((value) => value + 1);
4435
+ });
4436
+ }, [ref?.taskId]);
4437
+ const state = detail?.state;
3261
4438
  (0, react.useEffect)(() => {
3262
- setEditing(false);
3263
- setPreview(null);
4439
+ setShowHtml(false);
3264
4440
  setRisFallback(null);
3265
4441
  }, [ref?.taskId, ref?.paperId]);
3266
4442
  (0, react.useEffect)(() => {
3267
4443
  if (toast === "") return void 0;
3268
- const timer = setTimeout(() => setToast(""), 2400);
4444
+ const timer = setTimeout(() => setToastState(""), 4e3);
3269
4445
  return () => clearTimeout(timer);
3270
4446
  }, [toast]);
3271
4447
  const paper = detail?.paper;
@@ -3293,62 +4469,38 @@ function PaperPane({ t, useTabInfo, inputActions }) {
3293
4469
  const annotate = (input) => {
3294
4470
  if (busy) return;
3295
4471
  setBusy(true);
3296
- annotateTask(taskId, [input]).then(() => setNonce((value) => value + 1)).catch((err) => setToast(messageOf$3(err))).finally(() => setBusy(false));
3297
- };
3298
- const saveDetail = () => {
3299
- const trimmedOrder = order.trim();
3300
- const parsedOrder = Number(trimmedOrder);
3301
- const tagList = tags.split(/[,,;;]/u).map((tag) => tag.trim()).filter((tag) => tag !== "");
3302
- annotate({
3303
- paperId: paper.paperId,
3304
- note,
3305
- tags: tagList,
3306
- shortlistOrder: trimmedOrder === "" || !Number.isFinite(parsedOrder) ? null : Math.floor(parsedOrder)
3307
- });
3308
- setEditing(false);
4472
+ annotateTask(taskId, [input]).then(() => setNonce((value) => value + 1)).catch((err) => notify(messageOf$3(err))).finally(() => setBusy(false));
3309
4473
  };
3310
4474
  const insert = () => {
3311
4475
  const outcome = insertQuote(inputActions, paperText(t, paper), paperSummary(t, paper));
3312
- if (outcome === "inserted") setToast(t("result.inserted"));
3313
- else setToast(t(outcome === "no-editor" ? "error.noEditor" : "error.draftChanged"));
4476
+ if (outcome === "inserted") notify(t("result.inserted"));
4477
+ else notify(t(outcome === "no-editor" ? "error.noEditor" : "error.draftChanged"));
3314
4478
  };
3315
4479
  const download = () => {
3316
4480
  setBusy(true);
4481
+ setDownloading(true);
3317
4482
  downloadPaperPdf(taskId, paper.paperId).then((result) => {
3318
- if (result.status === "downloaded") setToast(tpl(t("pdf.downloaded"), { name: result.filename ?? "" }));
3319
- else if (result.status === "html-only") setToast(tpl(t("pdf.htmlFallback"), { message: result.error ?? "" }));
3320
- else setToast(tpl(t("pdf.error"), { message: result.error ?? result.status }));
3321
- }).catch((err) => setToast(tpl(t("pdf.error"), { message: messageOf$3(err) }))).finally(() => {
4483
+ if (result.status === "downloaded") notify(tpl(t("pdf.downloaded"), { name: result.filename ?? "" }));
4484
+ else if (result.status === "html-only") notify(tpl(t("pdf.htmlFallback"), { message: result.error ?? "" }), "error");
4485
+ else notify(tpl(t("pdf.error"), { message: result.error ?? result.status }), "error");
4486
+ }).catch((err) => notify(tpl(t("pdf.error"), { message: messageOf$3(err) }), "error")).finally(() => {
3322
4487
  setBusy(false);
4488
+ setDownloading(false);
3323
4489
  setNonce((value) => value + 1);
3324
4490
  });
3325
4491
  };
3326
4492
  const zotero = () => {
3327
- setBusy(true);
3328
- syncZotero(taskId, [paper.paperId]).then((result) => {
3329
- const message = result.messages[0] ?? "";
3330
- if (result.failed === 0 && message === "") setToast(tpl(t("pdf.zoteroDone"), { n: result.saved }));
3331
- else setToast(tpl(t("pdf.zoteroPartial"), {
3332
- saved: result.saved,
3333
- failed: result.failed,
3334
- message
3335
- }));
3336
- if (result.failed > 0) setRisFallback({
3337
- ids: [paper.paperId],
3338
- message: message || t("pdf.zoteroFailed")
3339
- });
3340
- }).catch((err) => {
3341
- const message = messageOf$3(err);
3342
- setToast(tpl(t("pdf.zoteroFailed"), { message }));
3343
- setRisFallback({
3344
- ids: [paper.paperId],
3345
- message
3346
- });
3347
- }).finally(() => setBusy(false));
4493
+ setZoteroOpen(true);
3348
4494
  };
3349
4495
  const pdfStatus = state?.pdfStatus ?? (paper.pdfUrl === "" ? "unknown" : "available");
3350
4496
  const canPdf = paper.pdfUrl !== "" || paper.ids.pmcid !== "" || paper.ids.arxivId !== "" || paper.doi !== "";
3351
4497
  const canHtml = paper.ids.pmcid !== "";
4498
+ const downloaded = !downloading && state?.pdfStatus === "downloaded";
4499
+ const downloadedPath = downloaded ? state?.pdfFile : void 0;
4500
+ const pdfMissing = !downloading && state?.pdfStatus === "missing";
4501
+ const failedMessage = !downloading && state?.pdfStatus === "failed" ? state.pdfError : void 0;
4502
+ const downloadLabel = downloading ? t("pdf.downloading") : downloaded ? t("pdf.downloadedAction") : pdfMissing ? t("pdf.missingAction") : state?.pdfStatus === "failed" ? t("pdf.retry") : t("pdf.download");
4503
+ const downloadHint = downloading ? t("pdf.downloadingHint") : downloaded ? tpl(t("pdf.downloadedHint"), { path: downloadedPath ?? detail.downloadDir ?? "" }) : pdfMissing ? t("pdf.missingHint") : failedMessage !== void 0 ? tpl(t("pdf.retryHint"), { message: failedMessage }) : tpl(t("pdf.downloadHint"), { path: detail.downloadDir ?? t("pdf.downloadDirUnknown") });
3352
4504
  const meta = [
3353
4505
  paper.authors.length === 0 ? "" : `${paper.authors.slice(0, 6).join(", ")}${paper.authors.length > 6 ? " et al." : ""}`,
3354
4506
  paper.year === null ? "" : String(paper.year),
@@ -3356,7 +4508,10 @@ function PaperPane({ t, useTabInfo, inputActions }) {
3356
4508
  ].filter((part) => part !== "").join(" · ");
3357
4509
  const groupName = detail.groups.find((group) => group.id === state?.groupId)?.name;
3358
4510
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3359
- style: panelStyle,
4511
+ style: {
4512
+ ...panelStyle,
4513
+ position: "relative"
4514
+ },
3360
4515
  children: [
3361
4516
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3362
4517
  style: {
@@ -3392,10 +4547,6 @@ function PaperPane({ t, useTabInfo, inputActions }) {
3392
4547
  style: badgeStyle,
3393
4548
  children: groupName
3394
4549
  }),
3395
- state?.shortlistOrder !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3396
- style: badgeStyle,
3397
- children: tpl(t("annotate.shortlistBadge"), { n: state.shortlistOrder })
3398
- }),
3399
4550
  sources.map((source) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3400
4551
  style: badgeStyle,
3401
4552
  children: t(`source.${source}`)
@@ -3413,111 +4564,44 @@ function PaperPane({ t, useTabInfo, inputActions }) {
3413
4564
  ...badgeStyle,
3414
4565
  color: PDF_COLOR$1[pdfStatus]
3415
4566
  },
3416
- children: t(`pdf.status.${pdfStatus}`)
3417
- })
3418
- ]
3419
- }),
3420
- state?.pdfError !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3421
- style: {
3422
- ...statusStyle,
3423
- color: "#b26a00"
3424
- },
3425
- children: tpl(t("pdf.error"), { message: state.pdfError })
3426
- }),
3427
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3428
- style: STATE_ROW_STYLE$1,
3429
- children: [
3430
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
3431
- style: SELECT_STYLE$3,
3432
- value: state?.screening ?? "unread",
3433
- disabled: busy,
3434
- "aria-label": t("filter.screening"),
3435
- onChange: (event) => annotate({
3436
- paperId: paper.paperId,
3437
- screening: event.target.value
3438
- }),
3439
- children: SCREENINGS$3.map((value) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
3440
- value,
3441
- children: t(`screening.${value}`)
3442
- }, value))
3443
- }),
3444
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
3445
- style: SELECT_STYLE$3,
3446
- value: state?.groupId ?? "",
3447
- disabled: busy,
3448
- "aria-label": t("annotate.group"),
3449
- title: detail.groups.length === 0 ? t("annotate.groupHint") : t("annotate.group"),
3450
- onChange: (event) => annotate({
3451
- paperId: paper.paperId,
3452
- groupId: event.target.value === "" ? null : event.target.value
3453
- }),
3454
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
3455
- value: "",
3456
- children: t("annotate.noGroup")
3457
- }), detail.groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
3458
- value: group.id,
3459
- children: group.name
3460
- }, group.id))]
3461
- }),
3462
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3463
- type: "button",
3464
- style: linkButtonStyle,
3465
- onClick: () => setEditing((value) => !value),
3466
- children: editing ? t("annotate.collapse") : t("annotate.expand")
3467
- })
3468
- ]
3469
- }),
3470
- editing && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3471
- style: EDIT_STYLE$1,
3472
- children: [
3473
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3474
- style: FIELD_STYLE$4,
3475
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("annotate.note") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
3476
- style: NOTE_STYLE$1,
3477
- value: note,
3478
- rows: 3,
3479
- placeholder: t("annotate.notePlaceholder"),
3480
- onChange: (event) => setNote(event.target.value)
3481
- })]
3482
- }),
3483
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3484
- style: FIELD_STYLE$4,
3485
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("annotate.tags") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3486
- style: INPUT_STYLE$3,
3487
- value: tags,
3488
- placeholder: t("annotate.tagsPlaceholder"),
3489
- onChange: (event) => setTags(event.target.value)
3490
- })]
3491
- }),
3492
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3493
- style: {
3494
- ...FIELD_STYLE$4,
3495
- maxWidth: 120
3496
- },
3497
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("annotate.shortlistOrder") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3498
- style: INPUT_STYLE$3,
3499
- inputMode: "numeric",
3500
- value: order,
3501
- placeholder: "—",
3502
- onChange: (event) => setOrder(event.target.value)
3503
- })]
3504
- }),
3505
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3506
- style: {
3507
- display: "flex",
3508
- justifyContent: "flex-end"
3509
- },
3510
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
3511
- variant: "outline",
3512
- size: "sm",
3513
- type: "button",
3514
- disabled: busy,
3515
- onClick: saveDetail,
3516
- children: t("annotate.save")
3517
- })
4567
+ children: t(`pdf.status.${pdfStatus}`)
3518
4568
  })
3519
4569
  ]
3520
4570
  }),
4571
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4572
+ style: STATE_ROW_STYLE$1,
4573
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
4574
+ style: SELECT_STYLE$3,
4575
+ value: state?.screening ?? "unread",
4576
+ disabled: busy,
4577
+ "aria-label": t("filter.screening"),
4578
+ onChange: (event) => annotate({
4579
+ paperId: paper.paperId,
4580
+ screening: event.target.value
4581
+ }),
4582
+ children: SCREENINGS$3.map((value) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
4583
+ value,
4584
+ children: t(`screening.${value}`)
4585
+ }, value))
4586
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
4587
+ style: SELECT_STYLE$3,
4588
+ value: state?.groupId ?? "",
4589
+ disabled: busy,
4590
+ "aria-label": t("annotate.group"),
4591
+ title: detail.groups.length === 0 ? t("annotate.groupHint") : t("annotate.group"),
4592
+ onChange: (event) => annotate({
4593
+ paperId: paper.paperId,
4594
+ groupId: event.target.value === "" ? null : event.target.value
4595
+ }),
4596
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
4597
+ value: "",
4598
+ children: t("annotate.noGroup")
4599
+ }), detail.groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
4600
+ value: group.id,
4601
+ children: group.name
4602
+ }, group.id))]
4603
+ })]
4604
+ }),
3521
4605
  paper.abstract !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
3522
4606
  style: HEAD_STYLE$2,
3523
4607
  children: t("paperTab.abstract")
@@ -3577,46 +4661,64 @@ function PaperPane({ t, useTabInfo, inputActions }) {
3577
4661
  paperId: paper.paperId,
3578
4662
  pmid: paper.ids.pmid
3579
4663
  })] }),
3580
- preview !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3581
- style: PREVIEW_WRAP_STYLE$1,
3582
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3583
- style: PREVIEW_HEAD_STYLE$1,
3584
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: preview === "pdf" ? t("pdf.preview") : t("pdf.fulltext") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3585
- type: "button",
3586
- style: linkButtonStyle,
3587
- onClick: () => setPreview(null),
3588
- children: t("pdf.closePreview")
3589
- })]
3590
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("iframe", {
3591
- title: preview === "pdf" ? t("pdf.preview") : t("pdf.fulltext"),
3592
- src: preview === "pdf" ? paperPdfUrl(taskId, paper.paperId) : paperFullTextUrl(taskId, paper.paperId),
3593
- style: IFRAME_STYLE$1
3594
- })]
4664
+ showHtml && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PreviewFrame, {
4665
+ t,
4666
+ src: paperFullTextUrl(taskId, paper.paperId),
4667
+ height: 420,
4668
+ onClose: () => setShowHtml(false)
3595
4669
  }),
3596
- toast !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3597
- style: toastStyle,
3598
- children: toast
4670
+ (toast !== "" || risFallback !== null) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4671
+ style: toastStackStyle,
4672
+ children: [toast !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4673
+ role: "status",
4674
+ style: {
4675
+ ...toastStyle,
4676
+ ...toastTone === "error" ? toastErrorStyle : null
4677
+ },
4678
+ children: toast
4679
+ }), risFallback !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4680
+ style: FALLBACK_STYLE,
4681
+ children: [
4682
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4683
+ style: { minWidth: 0 },
4684
+ children: tpl(t("pdf.zoteroFallback"), { message: risFallback.message })
4685
+ }),
4686
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
4687
+ href: exportUrl(taskId, "ris", {}, risFallback.ids),
4688
+ download: true,
4689
+ style: FALLBACK_LINK_STYLE,
4690
+ children: t("pdf.zoteroFallbackLink")
4691
+ }),
4692
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4693
+ type: "button",
4694
+ style: FALLBACK_BUTTON_STYLE,
4695
+ onClick: () => setRisFallback(null),
4696
+ children: t("pdf.zoteroFallbackDismiss")
4697
+ })
4698
+ ]
4699
+ })]
3599
4700
  }),
3600
- risFallback !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3601
- style: FALLBACK_STYLE,
3602
- children: [
3603
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3604
- style: { minWidth: 0 },
3605
- children: tpl(t("pdf.zoteroFallback"), { message: risFallback.message })
3606
- }),
3607
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
3608
- href: exportUrl(taskId, "ris", {}, risFallback.ids),
3609
- download: true,
3610
- style: linkStyle,
3611
- children: t("pdf.zoteroFallbackLink")
3612
- }),
3613
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3614
- type: "button",
3615
- style: linkButtonStyle,
3616
- onClick: () => setRisFallback(null),
3617
- children: t("pdf.zoteroFallbackDismiss")
3618
- })
3619
- ]
4701
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoteroSyncDialog, {
4702
+ t,
4703
+ open: zoteroOpen,
4704
+ taskId,
4705
+ items: [{
4706
+ paperId: paper.paperId,
4707
+ title: paper.title
4708
+ }],
4709
+ states: state === void 0 ? {} : { [paper.paperId]: state },
4710
+ onClose: () => {
4711
+ setZoteroOpen(false);
4712
+ setNonce((value) => value + 1);
4713
+ },
4714
+ onDone: (failedIds) => {
4715
+ setNonce((value) => value + 1);
4716
+ if (failedIds.length === 0) return;
4717
+ setRisFallback({
4718
+ ids: failedIds,
4719
+ message: t("zotero.dialog.failFallback")
4720
+ });
4721
+ }
3620
4722
  }),
3621
4723
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3622
4724
  style: ACTION_STYLE,
@@ -3628,31 +4730,38 @@ function PaperPane({ t, useTabInfo, inputActions }) {
3628
4730
  onClick: insert,
3629
4731
  children: t("result.insert")
3630
4732
  }),
3631
- canPdf && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3632
- type: "button",
3633
- style: linkButtonStyle,
3634
- onClick: () => setPreview((value) => value === "pdf" ? null : "pdf"),
3635
- children: t("pdf.preview")
3636
- }),
3637
- canPdf && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3638
- type: "button",
3639
- style: linkButtonStyle,
4733
+ canPdf && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadButton, {
4734
+ t,
4735
+ label: downloadLabel,
4736
+ hint: downloadHint,
4737
+ ...downloadedPath === void 0 ? {} : { downloadedPath },
4738
+ missing: pdfMissing,
4739
+ downloading,
3640
4740
  disabled: busy,
3641
- onClick: download,
3642
- children: t("pdf.download")
4741
+ style: linkButtonStyle,
4742
+ onDownload: download,
4743
+ ...onOpenLocal === void 0 ? {} : { onOpen: (path) => onOpenLocal(taskId, paper.paperId, path) },
4744
+ ...onRevealLocal === void 0 ? {} : { onReveal: onRevealLocal }
3643
4745
  }),
3644
4746
  canHtml && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3645
4747
  type: "button",
3646
4748
  style: linkButtonStyle,
3647
- onClick: () => setPreview((value) => value === "html" ? null : "html"),
4749
+ onClick: () => setShowHtml((value) => !value),
3648
4750
  children: t("pdf.fulltext")
3649
4751
  }),
3650
4752
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3651
4753
  type: "button",
3652
- style: linkButtonStyle,
4754
+ style: state?.zoteroStatus === "synced" ? {
4755
+ ...linkButtonStyle,
4756
+ color: "#2e9e5b"
4757
+ } : state?.zoteroStatus === "failed" ? {
4758
+ ...linkButtonStyle,
4759
+ color: ERROR
4760
+ } : linkButtonStyle,
4761
+ title: state?.zoteroStatus === "synced" ? state.zoteroCollection : state?.zoteroStatus === "failed" ? state.zoteroError : void 0,
3653
4762
  disabled: busy,
3654
4763
  onClick: zotero,
3655
- children: t("pdf.zotero")
4764
+ children: state?.zoteroStatus === "synced" ? t("pdf.zoteroSynced") : state?.zoteroStatus === "failed" ? t("pdf.zoteroRetry") : t("pdf.zotero")
3656
4765
  }),
3657
4766
  paper.pdfUrl !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
3658
4767
  style: linkStyle,
@@ -3701,73 +4810,30 @@ const SELECT_STYLE$3 = {
3701
4810
  color: "inherit",
3702
4811
  maxWidth: 150
3703
4812
  };
3704
- const EDIT_STYLE$1 = {
3705
- display: "flex",
3706
- flexDirection: "column",
3707
- gap: 6,
3708
- padding: "6px 8px",
3709
- borderRadius: 6,
3710
- background: "rgba(127,127,127,0.07)"
3711
- };
3712
- const FIELD_STYLE$4 = {
3713
- display: "flex",
3714
- flexDirection: "column",
3715
- gap: 2,
3716
- fontSize: 11,
3717
- opacity: .9
3718
- };
3719
- const INPUT_STYLE$3 = {
3720
- fontSize: 12,
3721
- padding: "3px 5px",
3722
- borderRadius: 4,
3723
- border: "1px solid rgba(127,127,127,0.28)",
3724
- background: "transparent",
3725
- color: "inherit",
3726
- minWidth: 0
3727
- };
3728
- const NOTE_STYLE$1 = {
3729
- ...INPUT_STYLE$3,
3730
- resize: "vertical",
3731
- fontFamily: "inherit",
3732
- lineHeight: 1.5
3733
- };
3734
4813
  const IDS_STYLE = {
3735
4814
  display: "flex",
3736
4815
  flexDirection: "column",
3737
4816
  gap: 3,
3738
4817
  fontSize: 12
3739
4818
  };
3740
- const PREVIEW_WRAP_STYLE$1 = {
3741
- border: "1px solid rgba(127,127,127,0.22)",
3742
- borderRadius: 6,
3743
- overflow: "hidden"
3744
- };
3745
- const PREVIEW_HEAD_STYLE$1 = {
3746
- display: "flex",
3747
- alignItems: "center",
3748
- justifyContent: "space-between",
3749
- gap: 8,
3750
- padding: "3px 8px",
3751
- fontSize: 11,
3752
- background: "rgba(127,127,127,0.08)"
3753
- };
3754
- const IFRAME_STYLE$1 = {
3755
- width: "100%",
3756
- height: 420,
3757
- border: "none",
3758
- background: "#fff"
3759
- };
3760
4819
  const FALLBACK_STYLE = {
4820
+ ...toastStyle,
4821
+ pointerEvents: "auto",
3761
4822
  display: "flex",
3762
4823
  alignItems: "center",
3763
4824
  flexWrap: "wrap",
3764
4825
  gap: 8,
3765
- padding: "5px 8px",
3766
- borderRadius: 6,
3767
- border: "1px solid rgba(178,106,0,0.4)",
3768
- background: "rgba(178,106,0,0.08)",
3769
- fontSize: 11,
3770
- lineHeight: 1.5
4826
+ borderLeft: "3px solid #b26a00"
4827
+ };
4828
+ const FALLBACK_LINK_STYLE = {
4829
+ color: "#fff",
4830
+ fontSize: 12,
4831
+ textDecoration: "underline"
4832
+ };
4833
+ const FALLBACK_BUTTON_STYLE = {
4834
+ ...linkButtonStyle,
4835
+ color: "#fff",
4836
+ textDecoration: "underline"
3771
4837
  };
3772
4838
  const ACTION_STYLE = {
3773
4839
  display: "flex",
@@ -4971,7 +6037,7 @@ function PaperRow({ paper }) {
4971
6037
  paper.venue
4972
6038
  ].filter((part) => part !== "").join(" · ");
4973
6039
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4974
- style: ROW_STYLE$3,
6040
+ style: ROW_STYLE$4,
4975
6041
  children: [
4976
6042
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4977
6043
  style: {
@@ -4981,7 +6047,7 @@ function PaperRow({ paper }) {
4981
6047
  children: paper.title
4982
6048
  }),
4983
6049
  meta !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4984
- style: ROW_META_STYLE$1,
6050
+ style: ROW_META_STYLE$2,
4985
6051
  children: meta
4986
6052
  }),
4987
6053
  paper.doi !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
@@ -5038,14 +6104,14 @@ const LIST_STYLE$3 = {
5038
6104
  display: "flex",
5039
6105
  flexDirection: "column"
5040
6106
  };
5041
- const ROW_STYLE$3 = {
6107
+ const ROW_STYLE$4 = {
5042
6108
  display: "flex",
5043
6109
  flexDirection: "column",
5044
6110
  gap: 1,
5045
6111
  padding: "5px 0",
5046
6112
  borderTop: "1px solid rgba(127,127,127,0.14)"
5047
6113
  };
5048
- const ROW_META_STYLE$1 = {
6114
+ const ROW_META_STYLE$2 = {
5049
6115
  fontSize: 11,
5050
6116
  opacity: .7
5051
6117
  };
@@ -5149,7 +6215,7 @@ function ConditionEditor({ t, detail, onSaved, onCancel }) {
5149
6215
  open: true,
5150
6216
  onClose: onCancel,
5151
6217
  title: t("condition.title"),
5152
- className: WIDE_MODAL_CLASS,
6218
+ className: WIDE_MODAL_CLASS$1,
5153
6219
  footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5154
6220
  style: {
5155
6221
  display: "flex",
@@ -5171,7 +6237,7 @@ function ConditionEditor({ t, detail, onSaved, onCancel }) {
5171
6237
  children: saving ? t("condition.saving") : t("condition.save")
5172
6238
  })]
5173
6239
  }),
5174
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: WIDE_MODAL_CSS }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6240
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: WIDE_MODAL_CSS$1 }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5175
6241
  style: BODY_STYLE,
5176
6242
  children: [
5177
6243
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -5213,7 +6279,7 @@ function ConditionEditor({ t, detail, onSaved, onCancel }) {
5213
6279
  infos: health?.sources
5214
6280
  }),
5215
6281
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5216
- style: ROW_STYLE$2,
6282
+ style: ROW_STYLE$3,
5217
6283
  children: [
5218
6284
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
5219
6285
  style: FIELD_STYLE$2,
@@ -5258,7 +6324,7 @@ function ConditionEditor({ t, detail, onSaved, onCancel }) {
5258
6324
  ]
5259
6325
  }),
5260
6326
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5261
- style: ROW_STYLE$2,
6327
+ style: ROW_STYLE$3,
5262
6328
  children: [
5263
6329
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
5264
6330
  style: CHECK_STYLE$2,
@@ -5334,48 +6400,371 @@ const BODY_STYLE = {
5334
6400
  overflowY: "auto"
5335
6401
  };
5336
6402
  /** 宿主 Modal 默认宽度 min(380px,100%),条件编辑器字段较多,单独放宽。 */
5337
- const WIDE_MODAL_CLASS = "litsearch-condition-modal";
5338
- const WIDE_MODAL_CSS = `
5339
- .${WIDE_MODAL_CLASS}{width:min(680px,calc(100vw - 48px))}
6403
+ const WIDE_MODAL_CLASS$1 = "litsearch-condition-modal";
6404
+ const WIDE_MODAL_CSS$1 = `
6405
+ .${WIDE_MODAL_CLASS$1}{width:min(680px,calc(100vw - 48px))}
5340
6406
  `;
5341
6407
  const QUERY_LIST_STYLE = {
5342
6408
  display: "flex",
5343
6409
  flexDirection: "column",
5344
- gap: 6
6410
+ gap: 6
6411
+ };
6412
+ const ROW_STYLE$3 = {
6413
+ display: "flex",
6414
+ alignItems: "center",
6415
+ flexWrap: "wrap",
6416
+ gap: 8
6417
+ };
6418
+ const FIELD_STYLE$2 = {
6419
+ display: "flex",
6420
+ alignItems: "center",
6421
+ gap: 4
6422
+ };
6423
+ const FIELD_LABEL_STYLE = {
6424
+ fontSize: 11,
6425
+ opacity: .68,
6426
+ whiteSpace: "nowrap"
6427
+ };
6428
+ const NUMBER_INPUT_STYLE = {
6429
+ width: 64,
6430
+ fontSize: 11.5
6431
+ };
6432
+ const SELECT_STYLE$1 = {
6433
+ fontSize: 11.5,
6434
+ padding: "2px 4px",
6435
+ borderRadius: 4,
6436
+ border: "1px solid rgba(127,127,127,0.28)",
6437
+ background: "transparent",
6438
+ color: "inherit"
6439
+ };
6440
+ const CHECK_STYLE$2 = {
6441
+ display: "flex",
6442
+ alignItems: "center",
6443
+ gap: 4,
6444
+ fontSize: 12
6445
+ };
6446
+ // src/client/DownloadDialog.tsx
6447
+ const PHASE_COLOR = {
6448
+ pending: "rgba(127,127,127,0.9)",
6449
+ downloading: ACCENT,
6450
+ downloaded: "#2e9e5b",
6451
+ manual: "#b26a00",
6452
+ failed: ERROR
6453
+ };
6454
+ /** 单篇的展示状态:即时状态优先,其次回落到 Host 记录的文献状态。 */
6455
+ function phaseOf(paperId, states, downloads) {
6456
+ const runtime = downloads[paperId];
6457
+ if (runtime?.state === "failed" && isManualPdfFailure(runtime.reason)) return "manual";
6458
+ if (runtime?.state !== void 0) return runtime.state;
6459
+ const status = states[paperId]?.pdfStatus ?? "unknown";
6460
+ if (status === "downloaded") return "downloaded";
6461
+ if (status === "blocked" || status === "paywalled" || status === "html-only") return "manual";
6462
+ if (status === "failed" || status === "missing") return "failed";
6463
+ return "pending";
6464
+ }
6465
+ function errorOf(paperId, states, downloads) {
6466
+ return downloads[paperId]?.error ?? states[paperId]?.pdfError;
6467
+ }
6468
+ function DownloadDialog({ t, open, items, states, downloads, origin = "panel", scopeLabel, reason, cancelled = false, onCancel, onClose }) {
6469
+ const phases = items.map((item) => phaseOf(item.paperId, states, downloads));
6470
+ const downloaded = phases.filter((phase) => phase === "downloaded").length;
6471
+ const downloading = phases.filter((phase) => phase === "downloading").length;
6472
+ const manual = phases.filter((phase) => phase === "manual").length;
6473
+ const failed = phases.filter((phase) => phase === "failed").length;
6474
+ const pending = phases.filter((phase) => phase === "pending").length;
6475
+ const total = items.length;
6476
+ const done = total - pending - downloading;
6477
+ const rate = total === 0 ? 0 : Math.round(done / total * 100);
6478
+ const complete = total > 0 && pending === 0 && downloading === 0;
6479
+ const allSkipped = complete && downloaded === total;
6480
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
6481
+ open,
6482
+ onClose,
6483
+ title: origin === "model" ? t("batch.modelTitle") : t("batch.title"),
6484
+ description: tpl(origin === "model" ? t("batch.modelDesc") : t("batch.desc"), { n: total }),
6485
+ className: WIDE_MODAL_CLASS,
6486
+ footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6487
+ style: FOOTER_STYLE,
6488
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6489
+ style: statusStyle,
6490
+ children: tpl(t("batch.progress"), {
6491
+ done,
6492
+ total
6493
+ })
6494
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6495
+ style: FOOTER_ACTIONS_STYLE,
6496
+ children: [onCancel !== void 0 && !cancelled && !complete && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
6497
+ variant: "outline",
6498
+ size: "sm",
6499
+ type: "button",
6500
+ onClick: onCancel,
6501
+ children: t("batch.cancel")
6502
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
6503
+ variant: "outline",
6504
+ size: "sm",
6505
+ type: "button",
6506
+ onClick: onClose,
6507
+ children: t("batch.close")
6508
+ })]
6509
+ })]
6510
+ }),
6511
+ children: [
6512
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: WIDE_MODAL_CSS }),
6513
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6514
+ style: OVERVIEW_STYLE,
6515
+ role: "status",
6516
+ "aria-live": "polite",
6517
+ children: [
6518
+ origin === "model" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6519
+ style: MODEL_BADGE_STYLE,
6520
+ children: [
6521
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6522
+ style: MODEL_TAG_STYLE,
6523
+ children: t("batch.originModel")
6524
+ }),
6525
+ scopeLabel !== void 0 && scopeLabel !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: scopeLabel }),
6526
+ reason !== void 0 && reason !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6527
+ style: REASON_STYLE,
6528
+ title: reason,
6529
+ children: reason
6530
+ })
6531
+ ]
6532
+ }),
6533
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6534
+ style: COUNTS_STYLE,
6535
+ children: [
6536
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: tpl(t("batch.total"), { n: total }) }),
6537
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6538
+ style: { color: PHASE_COLOR.downloaded },
6539
+ children: tpl(t("batch.downloaded"), { n: downloaded })
6540
+ }),
6541
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6542
+ style: { color: PHASE_COLOR.downloading },
6543
+ children: tpl(t("batch.downloading"), { n: downloading })
6544
+ }),
6545
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: tpl(t("batch.pending"), { n: pending }) }),
6546
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6547
+ style: { color: PHASE_COLOR.manual },
6548
+ children: tpl(t("batch.manual"), { n: manual })
6549
+ }),
6550
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6551
+ style: { color: PHASE_COLOR.failed },
6552
+ children: tpl(t("batch.failed"), { n: failed })
6553
+ })
6554
+ ]
6555
+ }),
6556
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6557
+ style: BAR_TRACK_STYLE,
6558
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { style: {
6559
+ ...BAR_FILL_STYLE$1,
6560
+ width: `${rate}%`
6561
+ } })
6562
+ }),
6563
+ complete && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6564
+ style: COMPLETE_STYLE,
6565
+ children: allSkipped ? t("batch.allSkipped") : t("batch.complete")
6566
+ }),
6567
+ cancelled && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6568
+ style: CANCELLED_STYLE,
6569
+ children: t("batch.cancelled")
6570
+ })
6571
+ ]
6572
+ }),
6573
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
6574
+ style: LIST_STYLE$2,
6575
+ children: items.map((item, index) => {
6576
+ const phase = phases[index] ?? "pending";
6577
+ const error = phase === "failed" || phase === "manual" ? errorOf(item.paperId, states, downloads) : void 0;
6578
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
6579
+ style: ROW_STYLE$2,
6580
+ children: [
6581
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6582
+ style: INDEX_STYLE,
6583
+ children: index + 1
6584
+ }),
6585
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6586
+ style: ROW_MAIN_STYLE$1,
6587
+ children: [
6588
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6589
+ style: ROW_TITLE_STYLE$1,
6590
+ title: item.title,
6591
+ children: item.title === "" ? t("batch.untitled") : item.title
6592
+ }),
6593
+ item.source !== void 0 && item.source !== "" || item.meta !== void 0 && item.meta !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6594
+ style: ROW_META_STYLE$1,
6595
+ children: [item.source === void 0 || item.source === "" ? "" : tpl(t("batch.source"), { source: item.source }), item.meta ?? ""].filter((part) => part !== "").join(" · ")
6596
+ }) : null,
6597
+ error !== void 0 && error !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6598
+ style: ROW_ERROR_STYLE$1,
6599
+ title: error,
6600
+ children: error
6601
+ })
6602
+ ]
6603
+ }),
6604
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
6605
+ style: {
6606
+ ...STATUS_CHIP_STYLE,
6607
+ color: PHASE_COLOR[phase]
6608
+ },
6609
+ children: [phase === "downloading" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SpinnerIcon, {}), t(`batch.status.${phase}`)]
6610
+ })
6611
+ ]
6612
+ }, item.paperId);
6613
+ })
6614
+ })
6615
+ ]
6616
+ });
6617
+ }
6618
+ /** 批量下载弹窗的宽度约为默认弹窗的两倍(默认约 540px)。 */
6619
+ const WIDE_MODAL_CLASS = "litsearch-download-modal";
6620
+ const WIDE_MODAL_CSS = `
6621
+ .${WIDE_MODAL_CLASS}{width:min(1080px,calc(100vw - 48px))}
6622
+ `;
6623
+ const FOOTER_STYLE = {
6624
+ display: "flex",
6625
+ alignItems: "center",
6626
+ justifyContent: "space-between",
6627
+ gap: 8
6628
+ };
6629
+ const OVERVIEW_STYLE = {
6630
+ display: "flex",
6631
+ flexDirection: "column",
6632
+ gap: 6,
6633
+ marginBottom: 8,
6634
+ padding: "8px 10px",
6635
+ borderRadius: 8,
6636
+ border: "1px solid rgba(80,140,255,0.25)",
6637
+ background: "rgba(80,140,255,0.08)"
6638
+ };
6639
+ const COUNTS_STYLE = {
6640
+ display: "flex",
6641
+ flexWrap: "wrap",
6642
+ gap: 10,
6643
+ fontSize: 12,
6644
+ lineHeight: 1.5
6645
+ };
6646
+ const BAR_TRACK_STYLE = {
6647
+ height: 4,
6648
+ borderRadius: 2,
6649
+ background: "rgba(127,127,127,0.22)",
6650
+ overflow: "hidden"
6651
+ };
6652
+ const BAR_FILL_STYLE$1 = {
6653
+ height: "100%",
6654
+ borderRadius: 2,
6655
+ background: ACCENT,
6656
+ transition: "width 0.25s ease"
6657
+ };
6658
+ const COMPLETE_STYLE = {
6659
+ fontSize: 12,
6660
+ fontWeight: 650,
6661
+ color: "#2e9e5b"
6662
+ };
6663
+ const LIST_STYLE$2 = {
6664
+ listStyle: "none",
6665
+ margin: 0,
6666
+ padding: 0,
6667
+ maxHeight: "50vh",
6668
+ overflowY: "auto"
6669
+ };
6670
+ const ROW_STYLE$2 = {
6671
+ display: "flex",
6672
+ alignItems: "flex-start",
6673
+ gap: 8,
6674
+ padding: "7px 0",
6675
+ borderTop: "1px solid rgba(127,127,127,0.18)"
6676
+ };
6677
+ const INDEX_STYLE = {
6678
+ flexShrink: 0,
6679
+ minWidth: 18,
6680
+ fontSize: 11,
6681
+ lineHeight: "18px",
6682
+ opacity: .5,
6683
+ textAlign: "right"
6684
+ };
6685
+ const ROW_MAIN_STYLE$1 = {
6686
+ display: "flex",
6687
+ flexDirection: "column",
6688
+ gap: 2,
6689
+ flex: 1,
6690
+ minWidth: 0
5345
6691
  };
5346
- const ROW_STYLE$2 = {
6692
+ const ROW_TITLE_STYLE$1 = {
6693
+ fontSize: 12.5,
6694
+ lineHeight: 1.45,
6695
+ fontWeight: 600,
6696
+ overflow: "hidden",
6697
+ display: "-webkit-box",
6698
+ WebkitLineClamp: 2,
6699
+ WebkitBoxOrient: "vertical",
6700
+ wordBreak: "break-word"
6701
+ };
6702
+ const ROW_ERROR_STYLE$1 = {
6703
+ fontSize: 11,
6704
+ lineHeight: 1.4,
6705
+ color: ERROR,
6706
+ opacity: .85,
6707
+ overflow: "hidden",
6708
+ display: "-webkit-box",
6709
+ WebkitLineClamp: 2,
6710
+ WebkitBoxOrient: "vertical",
6711
+ wordBreak: "break-word"
6712
+ };
6713
+ const STATUS_CHIP_STYLE = {
6714
+ display: "inline-flex",
6715
+ alignItems: "center",
6716
+ gap: 3,
6717
+ flexShrink: 0,
6718
+ padding: "1px 6px",
6719
+ borderRadius: 4,
6720
+ border: "1px solid currentColor",
6721
+ fontSize: 11,
6722
+ lineHeight: 1.5,
6723
+ whiteSpace: "nowrap"
6724
+ };
6725
+ const FOOTER_ACTIONS_STYLE = {
5347
6726
  display: "flex",
5348
6727
  alignItems: "center",
5349
- flexWrap: "wrap",
5350
- gap: 8
6728
+ gap: 6
5351
6729
  };
5352
- const FIELD_STYLE$2 = {
6730
+ const MODEL_BADGE_STYLE = {
5353
6731
  display: "flex",
6732
+ flexWrap: "wrap",
5354
6733
  alignItems: "center",
5355
- gap: 4
6734
+ gap: 8,
6735
+ fontSize: 12,
6736
+ lineHeight: 1.5,
6737
+ paddingBottom: 4,
6738
+ borderBottom: "1px solid rgba(127,127,127,0.18)"
5356
6739
  };
5357
- const FIELD_LABEL_STYLE = {
5358
- fontSize: 11,
5359
- opacity: .68,
5360
- whiteSpace: "nowrap"
6740
+ const MODEL_TAG_STYLE = {
6741
+ flexShrink: 0,
6742
+ padding: "1px 6px",
6743
+ borderRadius: 4,
6744
+ border: `1px solid ${ACCENT}`,
6745
+ color: ACCENT,
6746
+ fontWeight: 650,
6747
+ fontSize: 11
5361
6748
  };
5362
- const NUMBER_INPUT_STYLE = {
5363
- width: 64,
5364
- fontSize: 11.5
6749
+ const REASON_STYLE = {
6750
+ opacity: .75,
6751
+ overflow: "hidden",
6752
+ textOverflow: "ellipsis",
6753
+ whiteSpace: "nowrap",
6754
+ maxWidth: "100%"
5365
6755
  };
5366
- const SELECT_STYLE$1 = {
5367
- fontSize: 11.5,
5368
- padding: "2px 4px",
5369
- borderRadius: 4,
5370
- border: "1px solid rgba(127,127,127,0.28)",
5371
- background: "transparent",
5372
- color: "inherit"
6756
+ const ROW_META_STYLE$1 = {
6757
+ fontSize: 11,
6758
+ lineHeight: 1.4,
6759
+ opacity: .65,
6760
+ overflow: "hidden",
6761
+ textOverflow: "ellipsis",
6762
+ whiteSpace: "nowrap"
5373
6763
  };
5374
- const CHECK_STYLE$2 = {
5375
- display: "flex",
5376
- alignItems: "center",
5377
- gap: 4,
5378
- fontSize: 12
6764
+ const CANCELLED_STYLE = {
6765
+ fontSize: 12,
6766
+ fontWeight: 650,
6767
+ color: ERROR
5379
6768
  };
5380
6769
  // src/client/FullTextTab.tsx
5381
6770
  /**
@@ -5405,7 +6794,7 @@ const EMPTY_LIST = {
5405
6794
  function viewQuery(view) {
5406
6795
  return view === "missing" ? { missingFullText: true } : { pdfStatus: view };
5407
6796
  }
5408
- function FullTextTab({ t, taskId, rev, states, stats, onDownload, onZotero, onBatchDownload, onBatchZotero, onShowInResults }) {
6797
+ function FullTextTab({ t, taskId, rev, states, stats, onDownload, onOpenLocal, onRevealLocal, onZotero, onBatchDownload, onBatchZotero, onShowInResults }) {
5409
6798
  const [view, setView] = (0, react.useState)("missing");
5410
6799
  const [list, setList] = (0, react.useState)(EMPTY_LIST);
5411
6800
  const [selected, setSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
@@ -5484,7 +6873,7 @@ function FullTextTab({ t, taskId, rev, states, stats, onDownload, onZotero, onBa
5484
6873
  });
5485
6874
  const coverage = stats?.fullText;
5486
6875
  const availableCount = coverage === void 0 ? 0 : coverage.available + coverage.downloaded + coverage.htmlOnly;
5487
- const missingCount = coverage === void 0 ? 0 : coverage.unknown + coverage.paywalled + coverage.blocked + coverage.failed;
6876
+ const missingCount = coverage === void 0 ? 0 : coverage.unknown + coverage.missing + coverage.paywalled + coverage.blocked + coverage.failed;
5488
6877
  const selectedPapers = list.papers.filter((paper) => selected.has(paper.paperId));
5489
6878
  const viewLabel = view === "missing" ? t("fulltext.missing") : t(`pdf.status.${view}`);
5490
6879
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -5555,15 +6944,18 @@ function FullTextTab({ t, taskId, rev, states, stats, onDownload, onZotero, onBa
5555
6944
  style: TEXT_STYLE$1,
5556
6945
  children: t("fulltext.none")
5557
6946
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
5558
- style: LIST_STYLE$2,
6947
+ style: LIST_STYLE$1,
5559
6948
  children: list.papers.map((paper) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FullTextRow, {
5560
6949
  t,
5561
6950
  paper,
5562
6951
  selected: selected.has(paper.paperId),
5563
6952
  status: paperStatus(states, paper),
5564
6953
  error: states[paper.paperId]?.pdfError,
6954
+ downloadedPath: states[paper.paperId]?.pdfFile,
5565
6955
  onToggleSelect: toggleSelect,
5566
6956
  onDownload,
6957
+ ...onOpenLocal === void 0 ? {} : { onOpenLocal: (path) => onOpenLocal(taskId, paper.paperId, path) },
6958
+ ...onRevealLocal === void 0 ? {} : { onRevealLocal },
5567
6959
  onZotero
5568
6960
  }, paper.paperId))
5569
6961
  }),
@@ -5615,7 +7007,9 @@ function BulkBar$1({ t, count, onSelectPage, onClear, onDownload, onZotero }) {
5615
7007
  ]
5616
7008
  });
5617
7009
  }
5618
- function FullTextRow({ t, paper, selected, status, error, onToggleSelect, onDownload, onZotero }) {
7010
+ function FullTextRow({ t, paper, selected, status, error, downloadedPath, onToggleSelect, onDownload, onOpenLocal, onRevealLocal, onZotero }) {
7011
+ const downloaded = status === "downloaded" && downloadedPath !== void 0;
7012
+ const pdfMissing = status === "missing";
5619
7013
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
5620
7014
  style: ROW_STYLE$1,
5621
7015
  children: [
@@ -5659,11 +7053,18 @@ function FullTextRow({ t, paper, selected, status, error, onToggleSelect, onDown
5659
7053
  }),
5660
7054
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5661
7055
  style: ROW_ACTIONS_STYLE,
5662
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5663
- type: "button",
7056
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadButton, {
7057
+ t,
7058
+ label: downloaded ? t("pdf.downloadedAction") : pdfMissing ? t("pdf.missingAction") : t("pdf.download"),
7059
+ ...downloaded ? {
7060
+ downloadedPath,
7061
+ hint: tpl(t("pdf.downloadedHint"), { path: downloadedPath ?? "" })
7062
+ } : pdfMissing ? { hint: t("pdf.missingHint") } : {},
7063
+ missing: pdfMissing,
5664
7064
  style: LINK_STYLE,
5665
- onClick: () => onDownload(paper),
5666
- children: t("pdf.download")
7065
+ onDownload: () => onDownload(paper),
7066
+ ...onOpenLocal === void 0 ? {} : { onOpen: onOpenLocal },
7067
+ ...onRevealLocal === void 0 ? {} : { onReveal: onRevealLocal }
5667
7068
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5668
7069
  type: "button",
5669
7070
  style: LINK_STYLE,
@@ -5754,7 +7155,7 @@ const MORE_STYLE = {
5754
7155
  fontSize: 11,
5755
7156
  cursor: "pointer"
5756
7157
  };
5757
- const LIST_STYLE$2 = {
7158
+ const LIST_STYLE$1 = {
5758
7159
  margin: 0,
5759
7160
  padding: 0,
5760
7161
  listStyle: "none"
@@ -5802,19 +7203,18 @@ const ROW_ACTIONS_STYLE = {
5802
7203
  /**
5803
7204
  * 任务「分组」tab:维护「为什么读」的分类,并把文献归入其中。
5804
7205
  *
5805
- * 分组是任务内概念;归组在结果卡片的标注区完成,这里负责增删改名、排序。
7206
+ * 分组是任务内概念;归组在结果卡片的分组下拉里完成,这里负责增删改名、排序。
5806
7207
  * 增删与排序立即保存,名称和说明在输入框失焦时保存——切到结果页或别处不会丢改动。
5807
7208
  */
5808
7209
  function draftOf(groups) {
5809
7210
  return groups.map((group) => ({ ...group }));
5810
7211
  }
5811
- function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
7212
+ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup, onNotify }) {
5812
7213
  const [draft, setDraft] = (0, react.useState)(() => draftOf(detail.groups));
5813
7214
  const [name, setName] = (0, react.useState)("");
5814
7215
  const [description, setDescription] = (0, react.useState)("");
5815
7216
  const [busy, setBusy] = (0, react.useState)(false);
5816
7217
  const [error, setError] = (0, react.useState)(null);
5817
- const [toast, setToast] = (0, react.useState)("");
5818
7218
  const latest = (0, react.useRef)(draft);
5819
7219
  const queued = (0, react.useRef)(false);
5820
7220
  const saving = (0, react.useRef)(false);
@@ -5836,14 +7236,13 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5836
7236
  saving.current = true;
5837
7237
  setBusy(true);
5838
7238
  setError(null);
5839
- setToast("");
5840
7239
  (async () => {
5841
7240
  try {
5842
7241
  while (queued.current) {
5843
7242
  queued.current = false;
5844
7243
  await updateTask(taskId, { groups: latest.current.map((group) => ({ ...group })) });
5845
7244
  }
5846
- setToast(t("groups.saved"));
7245
+ onNotify(t("groups.saved"));
5847
7246
  onSaved();
5848
7247
  } catch (reason) {
5849
7248
  setError(reason instanceof Error ? reason.message : String(reason));
@@ -5855,7 +7254,8 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5855
7254
  }, [
5856
7255
  taskId,
5857
7256
  t,
5858
- onSaved
7257
+ onSaved,
7258
+ onNotify
5859
7259
  ]);
5860
7260
  const add = () => {
5861
7261
  const trimmed = name.trim();
@@ -5908,7 +7308,7 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5908
7308
  style: LABEL_STYLE,
5909
7309
  children: t("groups.name")
5910
7310
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5911
- style: INPUT_STYLE$2,
7311
+ style: INPUT_STYLE$1,
5912
7312
  value: name,
5913
7313
  placeholder: t("groups.namePlaceholder"),
5914
7314
  "aria-label": t("groups.name"),
@@ -5927,7 +7327,7 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5927
7327
  style: LABEL_STYLE,
5928
7328
  children: t("groups.desc")
5929
7329
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5930
- style: INPUT_STYLE$2,
7330
+ style: INPUT_STYLE$1,
5931
7331
  value: description,
5932
7332
  placeholder: t("groups.descPlaceholder"),
5933
7333
  "aria-label": t("groups.desc"),
@@ -5953,7 +7353,7 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5953
7353
  style: statusStyle,
5954
7354
  children: t("groups.empty")
5955
7355
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5956
- style: LIST_STYLE$1,
7356
+ style: LIST_STYLE,
5957
7357
  children: draft.map((group, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5958
7358
  style: GROUP_ROW_STYLE,
5959
7359
  children: [
@@ -5961,7 +7361,7 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5961
7361
  style: GROUP_HEAD_STYLE,
5962
7362
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5963
7363
  style: {
5964
- ...INPUT_STYLE$2,
7364
+ ...INPUT_STYLE$1,
5965
7365
  flex: 1,
5966
7366
  fontWeight: 600
5967
7367
  },
@@ -6024,15 +7424,12 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
6024
7424
  ]
6025
7425
  }, group.id))
6026
7426
  }),
6027
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7427
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6028
7428
  style: ACTION_ROW_STYLE$1,
6029
- children: [toast !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6030
- style: TOAST_STYLE$1,
6031
- children: toast
6032
- }), busy && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7429
+ children: busy && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6033
7430
  style: statusStyle,
6034
7431
  children: t("groups.saving")
6035
- })]
7432
+ })
6036
7433
  }),
6037
7434
  error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6038
7435
  style: {
@@ -6071,7 +7468,7 @@ const LABEL_STYLE = {
6071
7468
  opacity: .7,
6072
7469
  whiteSpace: "nowrap"
6073
7470
  };
6074
- const INPUT_STYLE$2 = {
7471
+ const INPUT_STYLE$1 = {
6075
7472
  fontSize: 12,
6076
7473
  padding: "3px 5px",
6077
7474
  borderRadius: 4,
@@ -6081,13 +7478,13 @@ const INPUT_STYLE$2 = {
6081
7478
  minWidth: 0,
6082
7479
  flex: "1 1 120px"
6083
7480
  };
6084
- const LIST_STYLE$1 = {
7481
+ const LIST_STYLE = {
6085
7482
  display: "flex",
6086
7483
  flexDirection: "column",
6087
7484
  gap: 6
6088
7485
  };
6089
7486
  const ROW_INPUT_STYLE = {
6090
- ...INPUT_STYLE$2,
7487
+ ...INPUT_STYLE$1,
6091
7488
  flex: "0 0 auto"
6092
7489
  };
6093
7490
  const GROUP_ROW_STYLE = {
@@ -6113,55 +7510,25 @@ const ACTION_ROW_STYLE$1 = {
6113
7510
  gap: 10,
6114
7511
  flexWrap: "wrap"
6115
7512
  };
6116
- const TOAST_STYLE$1 = {
6117
- fontSize: 12,
6118
- color: "#2e9e5b"
6119
- };
6120
7513
  // src/client/NotesTab.tsx
6121
7514
  /**
6122
- * 任务「笔记」tab:任务级研究笔记 + 逐篇备注/标签汇总。
7515
+ * 任务「笔记」tab:任务级研究笔记。
6123
7516
  *
6124
- * 逐篇备注通过 /results 取回当前任务前 200 篇的标题做映射;任务笔记直接写回
6125
- * `updateTask({notes})`,与模型工具看到的是同一份数据。
7517
+ * 任务笔记直接写回 `updateTask({notes})`,与模型工具看到的是同一份数据。
6126
7518
  */
6127
- const NOTE_PAGE_LIMIT = 200;
6128
- function NotesTab({ t, taskId, detail, onSaved }) {
7519
+ function NotesTab({ t, taskId, detail, onSaved, onNotify }) {
6129
7520
  const [notes, setNotes] = (0, react.useState)(detail.notes);
6130
7521
  const [busy, setBusy] = (0, react.useState)(false);
6131
- const [toast, setToast] = (0, react.useState)("");
6132
7522
  const [error, setError] = (0, react.useState)(null);
6133
- const [titles, setTitles] = (0, react.useState)({});
6134
- const [loadingTitles, setLoadingTitles] = (0, react.useState)(true);
6135
7523
  (0, react.useEffect)(() => {
6136
7524
  setNotes(detail.notes);
6137
7525
  }, [detail.rev]);
6138
- (0, react.useEffect)(() => {
6139
- const controller = new AbortController();
6140
- setLoadingTitles(true);
6141
- fetchResults(taskId, { limit: NOTE_PAGE_LIMIT }, controller.signal).then((page) => {
6142
- if (controller.signal.aborted) return;
6143
- const map = {};
6144
- for (const paper of page.papers) map[paper.paperId] = paper.title;
6145
- setTitles(map);
6146
- }).catch(() => void 0).finally(() => {
6147
- if (!controller.signal.aborted) setLoadingTitles(false);
6148
- });
6149
- return () => {
6150
- controller.abort();
6151
- };
6152
- }, [taskId, detail.rev]);
6153
- const entries = (0, react.useMemo)(() => {
6154
- return Object.entries(detail.states).filter(([, state]) => (state.note ?? "") !== "" || (state.tags ?? []).length > 0).map(([paperId, state]) => ({
6155
- paperId,
6156
- state
6157
- }));
6158
- }, [detail.states]);
6159
7526
  const save = () => {
6160
7527
  if (busy) return;
6161
7528
  setBusy(true);
6162
7529
  setError(null);
6163
7530
  updateTask(taskId, { notes }).then(() => {
6164
- setToast(t("notes.saved"));
7531
+ onNotify(t("notes.saved"));
6165
7532
  onSaved();
6166
7533
  }).catch((reason) => {
6167
7534
  setError(reason instanceof Error ? reason.message : String(reason));
@@ -6169,9 +7536,9 @@ function NotesTab({ t, taskId, detail, onSaved }) {
6169
7536
  setBusy(false);
6170
7537
  });
6171
7538
  };
6172
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7539
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6173
7540
  style: WRAP_STYLE$2,
6174
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [
7541
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [
6175
7542
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
6176
7543
  style: HEAD_STYLE,
6177
7544
  children: t("notes.title")
@@ -6183,19 +7550,16 @@ function NotesTab({ t, taskId, detail, onSaved }) {
6183
7550
  placeholder: t("notes.taskPlaceholder"),
6184
7551
  onChange: (event) => setNotes(event.target.value)
6185
7552
  }),
6186
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7553
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6187
7554
  style: ACTION_ROW_STYLE,
6188
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
7555
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
6189
7556
  variant: "outline",
6190
7557
  size: "sm",
6191
7558
  type: "button",
6192
7559
  disabled: busy || notes === detail.notes,
6193
7560
  onClick: save,
6194
7561
  children: t("notes.save")
6195
- }), toast !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6196
- style: TOAST_STYLE,
6197
- children: toast
6198
- })]
7562
+ })
6199
7563
  }),
6200
7564
  error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6201
7565
  style: {
@@ -6204,42 +7568,7 @@ function NotesTab({ t, taskId, detail, onSaved }) {
6204
7568
  },
6205
7569
  children: error
6206
7570
  })
6207
- ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [
6208
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
6209
- style: HEAD_STYLE,
6210
- children: t("notes.paperSection")
6211
- }),
6212
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6213
- style: statusStyle,
6214
- children: t("notes.hint")
6215
- }),
6216
- loadingTitles && entries.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6217
- style: statusStyle,
6218
- children: t("notes.loading")
6219
- }) : entries.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6220
- style: statusStyle,
6221
- children: t("notes.empty")
6222
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6223
- style: LIST_STYLE,
6224
- children: entries.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6225
- style: ENTRY_STYLE,
6226
- children: [
6227
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6228
- style: ENTRY_TITLE_STYLE,
6229
- children: titles[entry.paperId] ?? entry.paperId
6230
- }),
6231
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6232
- style: ENTRY_META_STYLE,
6233
- children: [t(`screening.${entry.state.screening}`), entry.state.tags === void 0 || entry.state.tags.length === 0 ? "" : ` · ${tpl(t("notes.tags"), { value: entry.state.tags.join(", ") })}`]
6234
- }),
6235
- entry.state.note !== void 0 && entry.state.note !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6236
- style: ENTRY_NOTE_STYLE,
6237
- children: entry.state.note
6238
- })
6239
- ]
6240
- }, entry.paperId))
6241
- })
6242
- ] })]
7571
+ ] })
6243
7572
  });
6244
7573
  }
6245
7574
  const WRAP_STYLE$2 = {
@@ -6272,35 +7601,6 @@ const ACTION_ROW_STYLE = {
6272
7601
  gap: 8,
6273
7602
  marginTop: 6
6274
7603
  };
6275
- const LIST_STYLE = {
6276
- display: "flex",
6277
- flexDirection: "column"
6278
- };
6279
- const ENTRY_STYLE = {
6280
- display: "flex",
6281
- flexDirection: "column",
6282
- gap: 2,
6283
- padding: "6px 0",
6284
- borderTop: "1px solid rgba(127,127,127,0.14)"
6285
- };
6286
- const ENTRY_TITLE_STYLE = {
6287
- fontSize: 12.5,
6288
- fontWeight: 650,
6289
- lineHeight: 1.45
6290
- };
6291
- const ENTRY_META_STYLE = {
6292
- fontSize: 11,
6293
- opacity: .7
6294
- };
6295
- const ENTRY_NOTE_STYLE = {
6296
- fontSize: 12,
6297
- lineHeight: 1.55,
6298
- opacity: .85
6299
- };
6300
- const TOAST_STYLE = {
6301
- fontSize: 12,
6302
- color: "#2e9e5b"
6303
- };
6304
7604
  // src/client/OverviewTab.tsx
6305
7605
  /**
6306
7606
  * 任务「概览」tab:一眼看清本轮检索发生了什么。
@@ -6349,6 +7649,11 @@ const FULLTEXT_STATUSES = [
6349
7649
  key: "downloaded",
6350
7650
  color: C.green
6351
7651
  },
7652
+ {
7653
+ status: "missing",
7654
+ key: "missing",
7655
+ color: "#d97706"
7656
+ },
6352
7657
  {
6353
7658
  status: "html-only",
6354
7659
  key: "htmlOnly",
@@ -7685,8 +8990,7 @@ const QUICK_FILTER_STYLE$1 = {
7685
8990
  /**
7686
8991
  * 任务结果里的单篇文献卡片。
7687
8992
  *
7688
- * 标题可点外链;徽章展示命中来源、被引、开放获取。筛读状态与分组就地修改,
7689
- * 备注 / 标签 / 必读顺序收在「标注」里,避免窄栏里堆满输入框。
8993
+ * 标题可点外链;徽章展示命中来源、被引、开放获取。筛读状态与分组就地修改。
7690
8994
  */
7691
8995
  const ABSTRACT_PREVIEW = 260;
7692
8996
  /** 分组下拉里「去新建分组」的哨兵值,不会与真实 group id 冲突。 */
@@ -7697,32 +9001,18 @@ const SCREENINGS$2 = [
7697
9001
  "included",
7698
9002
  "excluded"
7699
9003
  ];
9004
+ /** 出版商明确要求订阅 / 拒绝访问时,不再提供「下载 PDF」,避免必然失败的操作。 */
9005
+ const NO_PDF_STATUSES = /* @__PURE__ */ new Set(["paywalled", "blocked"]);
7700
9006
  const SCREENING_COLOR = {
7701
9007
  unread: "rgba(127,127,127,0.9)",
7702
9008
  read: "#508cff",
7703
9009
  included: "#2e9e5b",
7704
9010
  excluded: "#d64545"
7705
9011
  };
7706
- function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, onToggleSelect, onInsert, onAnnotate, onDownload, onZotero, onOpenPaper, onManageGroups }) {
9012
+ function PaperCard({ paper, t, taskId, state, groups, busy, downloadDir, download, onOpenLocal, onRevealLocal, selected = false, onToggleSelect, onInsert, onAnnotate, onDownload, onZotero, onOpenPaper, onManageGroups }) {
7707
9013
  const [expanded, setExpanded] = (0, react.useState)(false);
7708
- const [editing, setEditing] = (0, react.useState)(false);
7709
9014
  const [entities, setEntities] = (0, react.useState)(false);
7710
- const [preview, setPreview] = (0, react.useState)(null);
7711
- const [note, setNote] = (0, react.useState)(state?.note ?? "");
7712
- const [tags, setTags] = (0, react.useState)((state?.tags ?? []).join(", "));
7713
- const [order, setOrder] = (0, react.useState)(state?.shortlistOrder === void 0 ? "" : String(state.shortlistOrder));
7714
- const noteKey = state?.note ?? "";
7715
- const tagsKey = (state?.tags ?? []).join(",");
7716
- const orderKey = state?.shortlistOrder ?? "";
7717
- (0, react.useEffect)(() => {
7718
- setNote(noteKey);
7719
- setTags(tagsKey);
7720
- setOrder(orderKey === "" ? "" : String(orderKey));
7721
- }, [
7722
- noteKey,
7723
- tagsKey,
7724
- orderKey
7725
- ]);
9015
+ const [showHtml, setShowHtml] = (0, react.useState)(false);
7726
9016
  const meta = [
7727
9017
  paper.authors.length === 0 ? "" : `${paper.authors.slice(0, 3).join(", ")}${paper.authors.length > 3 ? " et al." : ""}`,
7728
9018
  paper.year === null ? "" : String(paper.year),
@@ -7733,22 +9023,19 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
7733
9023
  const abstract = expanded || !canCollapse ? paper.abstract : `${paper.abstract.slice(0, ABSTRACT_PREVIEW)}…`;
7734
9024
  const screening = state?.screening ?? "unread";
7735
9025
  const groupName = groups.find((group) => group.id === state?.groupId)?.name;
7736
- const hasNote = (state?.note ?? "") !== "" || (state?.tags ?? []).length > 0;
7737
9026
  const pdfStatus = state?.pdfStatus ?? (paper.pdfUrl === "" ? "unknown" : "available");
7738
- const canPdf = paper.pdfUrl !== "" || paper.ids.pmcid !== "" || paper.ids.arxivId !== "" || paper.doi !== "";
9027
+ const canPdf = !NO_PDF_STATUSES.has(pdfStatus) && (paper.pdfUrl !== "" || paper.ids.pmcid !== "" || paper.ids.arxivId !== "" || paper.doi !== "");
7739
9028
  const canHtml = paper.ids.pmcid !== "";
7740
- const saveDetail = () => {
7741
- const trimmedOrder = order.trim();
7742
- const parsedOrder = Number(trimmedOrder);
7743
- const tagList = tags.split(/[,,;;]/u).map((tag) => tag.trim()).filter((tag) => tag !== "");
7744
- onAnnotate({
7745
- paperId: paper.paperId,
7746
- note,
7747
- tags: tagList,
7748
- shortlistOrder: trimmedOrder === "" || !Number.isFinite(parsedOrder) ? null : Math.floor(parsedOrder)
7749
- });
7750
- setEditing(false);
7751
- };
9029
+ const downloading = download?.state === "downloading";
9030
+ const pdfMissing = !downloading && pdfStatus === "missing";
9031
+ const downloaded = !pdfMissing && (download?.state === "downloaded" || pdfStatus === "downloaded" && !downloading);
9032
+ const downloadedPath = downloaded ? download?.path ?? state?.pdfFile : void 0;
9033
+ const failedMessage = download?.state === "failed" ? download.error : pdfStatus === "failed" ? state?.pdfError : void 0;
9034
+ const pdfLabel = downloading ? t("pdf.downloading") : downloaded ? t("pdf.downloadedAction") : pdfMissing ? t("pdf.missingAction") : pdfStatus === "failed" ? t("pdf.retry") : t("pdf.download");
9035
+ const pdfHint = downloading ? t("pdf.downloadingHint") : downloaded ? tpl(t("pdf.downloadedHint"), { path: downloadedPath ?? downloadDir ?? "" }) : pdfMissing ? t("pdf.missingHint") : failedMessage !== void 0 ? tpl(t("pdf.retryHint"), { message: failedMessage }) : tpl(t("pdf.downloadHint"), { path: downloadDir ?? t("pdf.downloadDirUnknown") });
9036
+ const zoteroLabel = state?.zoteroStatus === "synced" ? t("pdf.zoteroSynced") : state?.zoteroStatus === "failed" ? t("pdf.zoteroRetry") : t("pdf.zotero");
9037
+ const zoteroHint = state?.zoteroStatus === "synced" ? state.zoteroCollection : state?.zoteroStatus === "failed" ? state.zoteroError : void 0;
9038
+ const zoteroColor = state?.zoteroStatus === "synced" ? "#2e9e5b" : state?.zoteroStatus === "failed" ? ERROR : void 0;
7752
9039
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("article", {
7753
9040
  style: cardStyle,
7754
9041
  children: [
@@ -7807,63 +9094,6 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
7807
9094
  children: t("annotate.createGroup")
7808
9095
  })
7809
9096
  ]
7810
- }),
7811
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
7812
- type: "button",
7813
- style: linkButtonStyle,
7814
- onClick: () => setEditing((value) => !value),
7815
- children: [editing ? t("annotate.collapse") : t("annotate.expand"), hasNote ? " •" : ""]
7816
- })
7817
- ]
7818
- }),
7819
- editing && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7820
- style: EDIT_STYLE,
7821
- children: [
7822
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
7823
- style: EDIT_FIELD_STYLE,
7824
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("annotate.note") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
7825
- style: NOTE_STYLE,
7826
- value: note,
7827
- rows: 2,
7828
- placeholder: t("annotate.notePlaceholder"),
7829
- onChange: (event) => setNote(event.target.value)
7830
- })]
7831
- }),
7832
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
7833
- style: EDIT_FIELD_STYLE,
7834
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("annotate.tags") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
7835
- style: INPUT_STYLE$1,
7836
- value: tags,
7837
- placeholder: t("annotate.tagsPlaceholder"),
7838
- onChange: (event) => setTags(event.target.value)
7839
- })]
7840
- }),
7841
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
7842
- style: {
7843
- ...EDIT_FIELD_STYLE,
7844
- maxWidth: 120
7845
- },
7846
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("annotate.shortlistOrder") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
7847
- style: INPUT_STYLE$1,
7848
- inputMode: "numeric",
7849
- value: order,
7850
- placeholder: "—",
7851
- onChange: (event) => setOrder(event.target.value)
7852
- })]
7853
- }),
7854
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7855
- style: {
7856
- display: "flex",
7857
- justifyContent: "flex-end"
7858
- },
7859
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
7860
- variant: "outline",
7861
- size: "sm",
7862
- type: "button",
7863
- disabled: busy,
7864
- onClick: saveDetail,
7865
- children: t("annotate.save")
7866
- })
7867
9097
  })
7868
9098
  ]
7869
9099
  }),
@@ -7931,10 +9161,6 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
7931
9161
  })] })]
7932
9162
  })]
7933
9163
  }),
7934
- state?.pdfError !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7935
- style: PDF_ERROR_STYLE,
7936
- children: tpl(t("pdf.error"), { message: state.pdfError })
7937
- }),
7938
9164
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7939
9165
  style: CARD_ROW_STYLE,
7940
9166
  title: t("result.infoHint"),
@@ -7959,10 +9185,6 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
7959
9185
  style: INFO_BADGE_STYLE,
7960
9186
  children: groupName
7961
9187
  }),
7962
- state?.shortlistOrder !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7963
- style: INFO_BADGE_STYLE,
7964
- children: tpl(t("annotate.shortlistBadge"), { n: state.shortlistOrder })
7965
- }),
7966
9188
  sources.map((source) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7967
9189
  style: INFO_BADGE_STYLE,
7968
9190
  children: t(`source.${source}`)
@@ -7980,6 +9202,7 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
7980
9202
  ...INFO_BADGE_STYLE,
7981
9203
  color: PDF_COLOR[pdfStatus]
7982
9204
  },
9205
+ title: state?.pdfError,
7983
9206
  children: t(`pdf.status.${pdfStatus}`)
7984
9207
  })
7985
9208
  ]
@@ -8020,31 +9243,35 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
8020
9243
  onClick: () => setEntities((value) => !value),
8021
9244
  children: t("entity.title")
8022
9245
  }),
8023
- canPdf && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
8024
- type: "button",
8025
- style: linkButtonStyle,
8026
- onClick: () => setPreview((value) => value === "pdf" ? null : "pdf"),
8027
- children: t("pdf.preview")
8028
- }),
8029
- canPdf && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
8030
- type: "button",
8031
- style: linkButtonStyle,
9246
+ canPdf && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadButton, {
9247
+ t,
9248
+ label: pdfLabel,
9249
+ hint: pdfHint,
9250
+ ...downloadedPath === void 0 ? {} : { downloadedPath },
9251
+ missing: pdfMissing,
9252
+ downloading,
8032
9253
  disabled: busy,
8033
- onClick: () => onDownload(paper),
8034
- children: t("pdf.download")
9254
+ style: linkButtonStyle,
9255
+ onDownload: () => onDownload(paper),
9256
+ ...onOpenLocal === void 0 ? {} : { onOpen: (path) => onOpenLocal(taskId, paper.paperId, path) },
9257
+ ...onRevealLocal === void 0 ? {} : { onReveal: onRevealLocal }
8035
9258
  }),
8036
9259
  canHtml && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
8037
9260
  type: "button",
8038
9261
  style: linkButtonStyle,
8039
- onClick: () => setPreview((value) => value === "html" ? null : "html"),
9262
+ onClick: () => setShowHtml((value) => !value),
8040
9263
  children: t("pdf.fulltext")
8041
9264
  }),
8042
9265
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
8043
9266
  type: "button",
8044
- style: linkButtonStyle,
9267
+ style: zoteroColor === void 0 ? linkButtonStyle : {
9268
+ ...linkButtonStyle,
9269
+ color: zoteroColor
9270
+ },
8045
9271
  disabled: busy,
9272
+ title: zoteroHint,
8046
9273
  onClick: () => onZotero(paper),
8047
- children: t("pdf.zotero")
9274
+ children: zoteroLabel
8048
9275
  }),
8049
9276
  paper.pdfUrl !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
8050
9277
  style: linkStyle,
@@ -8069,21 +9296,11 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
8069
9296
  paperId: paper.paperId,
8070
9297
  pmid: paper.ids.pmid
8071
9298
  }),
8072
- preview !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
8073
- style: PREVIEW_WRAP_STYLE,
8074
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
8075
- style: PREVIEW_HEAD_STYLE,
8076
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: preview === "pdf" ? t("pdf.preview") : t("pdf.fulltext") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
8077
- type: "button",
8078
- style: linkButtonStyle,
8079
- onClick: () => setPreview(null),
8080
- children: t("pdf.closePreview")
8081
- })]
8082
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("iframe", {
8083
- title: preview === "pdf" ? t("pdf.preview") : t("pdf.fulltext"),
8084
- src: preview === "pdf" ? paperPdfUrl(taskId, paper.paperId) : paperFullTextUrl(taskId, paper.paperId),
8085
- style: IFRAME_STYLE
8086
- })]
9299
+ showHtml && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PreviewFrame, {
9300
+ t,
9301
+ src: paperFullTextUrl(taskId, paper.paperId),
9302
+ height: 360,
9303
+ onClose: () => setShowHtml(false)
8087
9304
  })
8088
9305
  ]
8089
9306
  });
@@ -8092,39 +9309,13 @@ const PDF_COLOR = {
8092
9309
  unknown: "rgba(127,127,127,0.8)",
8093
9310
  available: "#508cff",
8094
9311
  downloaded: "#2e9e5b",
9312
+ missing: "#d64545",
8095
9313
  "html-only": "#b26a00",
8096
9314
  paywalled: "#b26a00",
8097
9315
  blocked: "#d64545",
8098
9316
  failed: "#d64545"
8099
9317
  };
8100
- const PDF_ERROR_STYLE = {
8101
- fontSize: 11,
8102
- lineHeight: 1.5,
8103
- color: "#b26a00",
8104
- marginTop: 2
8105
- };
8106
- const PREVIEW_WRAP_STYLE = {
8107
- marginTop: 6,
8108
- border: "1px solid rgba(127,127,127,0.22)",
8109
- borderRadius: 6,
8110
- overflow: "hidden"
8111
- };
8112
- const PREVIEW_HEAD_STYLE = {
8113
- display: "flex",
8114
- alignItems: "center",
8115
- justifyContent: "space-between",
8116
- gap: 8,
8117
- padding: "3px 8px",
8118
- fontSize: 11,
8119
- background: "rgba(127,127,127,0.08)"
8120
- };
8121
- const IFRAME_STYLE = {
8122
- width: "100%",
8123
- height: 360,
8124
- border: "none",
8125
- background: "#fff"
8126
- };
8127
- /** 卡片顶部工具行:勾选 / 筛读 / 分组 / 标注。 */
9318
+ /** 卡片顶部工具行:勾选 / 筛读 / 分组。 */
8128
9319
  const STATE_ROW_STYLE = {
8129
9320
  display: "flex",
8130
9321
  alignItems: "center",
@@ -8187,44 +9378,13 @@ const INFO_BADGE_STYLE = {
8187
9378
  border: "1px solid rgba(18,131,108,0.45)"
8188
9379
  };
8189
9380
  const SELECT_STYLE = {
8190
- fontSize: 11,
8191
- padding: "2px 4px",
8192
- borderRadius: 4,
8193
- border: "1px solid rgba(127,127,127,0.28)",
8194
- background: "transparent",
8195
- color: "inherit",
8196
- maxWidth: 130
8197
- };
8198
- const EDIT_STYLE = {
8199
- display: "flex",
8200
- flexDirection: "column",
8201
- gap: 6,
8202
- marginBottom: 8,
8203
- padding: "6px 8px",
8204
- borderRadius: 6,
8205
- background: "rgba(127,127,127,0.07)"
8206
- };
8207
- const EDIT_FIELD_STYLE = {
8208
- display: "flex",
8209
- flexDirection: "column",
8210
- gap: 2,
8211
- fontSize: 11,
8212
- opacity: .9
8213
- };
8214
- const INPUT_STYLE$1 = {
8215
- fontSize: 12,
8216
- padding: "3px 5px",
9381
+ fontSize: 11,
9382
+ padding: "2px 4px",
8217
9383
  borderRadius: 4,
8218
9384
  border: "1px solid rgba(127,127,127,0.28)",
8219
9385
  background: "transparent",
8220
9386
  color: "inherit",
8221
- minWidth: 0
8222
- };
8223
- const NOTE_STYLE = {
8224
- ...INPUT_STYLE$1,
8225
- resize: "vertical",
8226
- fontFamily: "inherit",
8227
- lineHeight: 1.5
9387
+ maxWidth: 130
8228
9388
  };
8229
9389
  // src/client/RoundsDialog.tsx
8230
9390
  /**
@@ -9014,6 +10174,13 @@ const CHIP_REMOVE_STYLE = {
9014
10174
  * 避免多个 tab 空转。重跑 / 取消都走同一套刷新。
9015
10175
  */
9016
10176
  const POLL_MS = 1500;
10177
+ /**
10178
+ * 结果页每页篇数(与检索条件的「每源条数」无关,只影响面板展示)。
10179
+ *
10180
+ * 与 Host 侧「第 N 页」共用一个常量:模型说「第 2 页」时,宿主就是按这个
10181
+ * 粒度切片的;两处各写一份会静默错位。
10182
+ */
10183
+ const RESULT_PAGE_SIZE = 20;
9017
10184
  const INITIAL = {
9018
10185
  detail: null,
9019
10186
  page: null,
@@ -9026,7 +10193,7 @@ function isActive(status) {
9026
10193
  function errorMessage(error) {
9027
10194
  return error instanceof Error ? error.message : String(error);
9028
10195
  }
9029
- function useTask(id, signal, pageSize = 20, filters = {}) {
10196
+ function useTask(id, signal, pageSize = RESULT_PAGE_SIZE, filters = {}) {
9030
10197
  const [state, setState] = (0, react.useState)(INITIAL);
9031
10198
  const [nonce, setNonce] = (0, react.useState)(0);
9032
10199
  const [busy, setBusy] = (0, react.useState)(false);
@@ -9250,15 +10417,55 @@ function formatDate(value) {
9250
10417
  const date = new Date(value);
9251
10418
  return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
9252
10419
  }
9253
- function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onCloseTask }) {
10420
+ /** `ResultsQuery` 的扁平投影:只保留已生效、可序列化的筛选值。 */
10421
+ function snapshotFilters(filters) {
10422
+ const out = {};
10423
+ for (const [key, value] of Object.entries(filters)) {
10424
+ if (key === "offset" || key === "limit") continue;
10425
+ if (value === void 0 || value === null || value === "" || value === false) continue;
10426
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") out[key] = value;
10427
+ }
10428
+ return out;
10429
+ }
10430
+ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onCloseTask, onOpenLocal, onRevealLocal }) {
9254
10431
  const info = useTabInfo();
9255
10432
  const taskId = parseTaskId(info);
9256
10433
  const [filters, setFilters] = (0, react.useState)({});
9257
10434
  const [tab, setTab] = (0, react.useState)("results");
9258
- const view = useTask(taskId, info.tab.signal, 20, filters);
10435
+ const view = useTask(taskId, info.tab.signal, RESULT_PAGE_SIZE, filters);
9259
10436
  const bodyRef = (0, react.useRef)(null);
9260
- const [toast, setToast] = (0, react.useState)("");
10437
+ const [toast, setToastState] = (0, react.useState)("");
10438
+ const [toastTone, setToastTone] = (0, react.useState)("info");
10439
+ const [toastMs, setToastMs] = (0, react.useState)(4e3);
10440
+ /** 统一的底部浮层提示:tone 区分普通结果与下载失败。 */
10441
+ const notify = (text, tone = "info", ms = 4e3) => {
10442
+ setToastState(text);
10443
+ setToastTone(tone);
10444
+ setToastMs(ms);
10445
+ };
10446
+ /** paperId → 下载状态;刷新任务后仍保留「下载中 / 已下载」的即时反馈。 */
10447
+ const [downloads, setDownloads] = (0, react.useState)({});
10448
+ /** 批量下载弹窗:只集中展示,真实状态仍写在 downloads / 任务详情的 states 里。 */
10449
+ const [batchItems, setBatchItems] = (0, react.useState)([]);
10450
+ const [batchOpen, setBatchOpen] = (0, react.useState)(false);
10451
+ /** Zotero 同步弹窗:单篇与批量共用,先选目标目录再同步,并展示逐篇结果。 */
10452
+ const [zoteroItems, setZoteroItems] = (0, react.useState)([]);
10453
+ const [zoteroOpen, setZoteroOpen] = (0, react.useState)(false);
10454
+ /** 模型发起的同步:范围说明、原话与目标目录;手动操作时为 undefined。 */
10455
+ const [zoteroMeta, setZoteroMeta] = (0, react.useState)(void 0);
10456
+ const [batchRunning, setBatchRunning] = (0, react.useState)(false);
10457
+ const batchRunningRef = (0, react.useRef)(false);
10458
+ /** 模型发起的下载:弹窗标明来源/范围,并提供独立的「取消下载」。 */
10459
+ const [batchMeta, setBatchMeta] = (0, react.useState)({ origin: "panel" });
10460
+ const [batchCancelled, setBatchCancelled] = (0, react.useState)(false);
10461
+ const batchAbortRef = (0, react.useRef)(null);
10462
+ /** 待消费的模型指令;tab 未挂载时由模块级 store 暂存,focus 拉起后再消费。 */
10463
+ const pendingCommands = usePendingDownloadCommands(taskId);
10464
+ const pendingZoteroCommands = usePendingZoteroCommands(taskId);
9261
10465
  const [selected, setSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
10466
+ /** 结果页里见过的文献:跨页选中的条目做「加入对话」时要能拿到论文详情。 */
10467
+ const papersByIdRef = (0, react.useRef)(/* @__PURE__ */ new Map());
10468
+ const ids = [...selected];
9262
10469
  const [editingConditions, setEditingConditions] = (0, react.useState)(false);
9263
10470
  const [risFallback, setRisFallback] = (0, react.useState)(null);
9264
10471
  const [showRounds, setShowRounds] = (0, react.useState)(false);
@@ -9267,10 +10474,28 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9267
10474
  const [overview, setOverview] = (0, react.useState)(null);
9268
10475
  const detail = view.detail;
9269
10476
  const filterActive = (0, react.useMemo)(() => activeFilterCount(filters) > 0, [filters]);
9270
- const filterKey = JSON.stringify(filters);
10477
+ const selectionScope = JSON.stringify({
10478
+ ...filters,
10479
+ sort: void 0
10480
+ });
10481
+ const downloadsRef = (0, react.useRef)(downloads);
10482
+ downloadsRef.current = downloads;
10483
+ const statesRef = (0, react.useRef)({});
10484
+ statesRef.current = detail?.states ?? {};
9271
10485
  (0, react.useEffect)(() => {
9272
10486
  setSelected(/* @__PURE__ */ new Set());
9273
- }, [taskId, filterKey]);
10487
+ }, [taskId, selectionScope]);
10488
+ (0, react.useEffect)(() => {
10489
+ papersByIdRef.current.clear();
10490
+ }, [taskId]);
10491
+ (0, react.useEffect)(() => {
10492
+ for (const paper of view.page?.papers ?? []) papersByIdRef.current.set(paper.paperId, paper);
10493
+ }, [view.page]);
10494
+ (0, react.useEffect)(() => {
10495
+ setDownloads({});
10496
+ setBatchItems([]);
10497
+ setBatchOpen(false);
10498
+ }, [taskId]);
9274
10499
  (0, react.useEffect)(() => {
9275
10500
  setEditingConditions(false);
9276
10501
  setRisFallback(null);
@@ -9293,64 +10518,100 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9293
10518
  }, [taskId]);
9294
10519
  (0, react.useEffect)(() => {
9295
10520
  if (toast === "") return void 0;
9296
- const timer = setTimeout(() => setToast(""), 2400);
10521
+ const timer = setTimeout(() => notify(""), toastMs);
9297
10522
  return () => clearTimeout(timer);
9298
- }, [toast]);
10523
+ }, [toast, toastMs]);
9299
10524
  (0, react.useEffect)(() => {
9300
10525
  bodyRef.current?.scrollTo({ top: 0 });
9301
10526
  }, [view.pageIndex, tab]);
9302
10527
  const insert = (paper) => {
9303
10528
  const outcome = insertQuote(inputActions, paperText(t, paper), paperSummary(t, paper));
9304
- if (outcome === "inserted") setToast(t("result.inserted"));
9305
- else setToast(t(outcome === "no-editor" ? "error.noEditor" : "error.draftChanged"));
10529
+ if (outcome === "inserted") notify(t("result.inserted"));
10530
+ else notify(t(outcome === "no-editor" ? "error.noEditor" : "error.draftChanged"));
9306
10531
  };
10532
+ /** 跨页选中也要整批插入:优先用翻页时缓存的论文,缺的再按 id 补详情。 */
9307
10533
  const insertMany = () => {
9308
- const loaded = view.page;
9309
- if (loaded === null) return;
9310
- const papers = loaded.papers.filter((paper) => selected.has(paper.paperId));
9311
- if (papers.length === 0) return;
9312
- const outcome = insertQuote(inputActions, papersText(t, papers), papersSummary(t, papers));
9313
- if (outcome === "inserted") setToast(tpl(t("bulk.inserted"), { n: papers.length }));
9314
- else setToast(t(outcome === "no-editor" ? "error.noEditor" : "error.draftChanged"));
10534
+ if (taskId === null || ids.length === 0) return;
10535
+ const known = papersByIdRef.current;
10536
+ const finish = () => {
10537
+ const papers = ids.map((paperId) => known.get(paperId)).filter((paper) => paper !== void 0);
10538
+ if (papers.length === 0) return;
10539
+ const outcome = insertQuote(inputActions, papersText(t, papers), papersSummary(t, papers));
10540
+ if (outcome === "inserted") notify(tpl(t("bulk.inserted"), { n: papers.length }));
10541
+ else notify(t(outcome === "no-editor" ? "error.noEditor" : "error.draftChanged"));
10542
+ };
10543
+ const missing = ids.filter((paperId) => !known.has(paperId));
10544
+ if (missing.length === 0) {
10545
+ finish();
10546
+ return;
10547
+ }
10548
+ Promise.all(missing.map((paperId) => fetchPaper(taskId, paperId).then((loaded) => {
10549
+ known.set(paperId, loaded.paper);
10550
+ }).catch(() => void 0))).then(finish);
9315
10551
  };
9316
10552
  /** 把任务 id 与当前条件带入会话草稿,新会话也能续接同一任务。 */
9317
10553
  const insertTask = () => {
9318
10554
  if (detail === null) return;
9319
10555
  const outcome = insertQuote(inputActions, taskText(t, detail), taskSummary(t, detail));
9320
- if (outcome === "inserted") setToast(t("task.inserted"));
9321
- else setToast(t(outcome === "no-editor" ? "error.noEditor" : "error.draftChanged"));
10556
+ if (outcome === "inserted") notify(t("task.inserted"));
10557
+ else notify(t(outcome === "no-editor" ? "error.noEditor" : "error.draftChanged"));
9322
10558
  };
9323
10559
  const downloadPaper = (paper) => {
9324
10560
  if (taskId === null) return;
10561
+ const paperId = paper.paperId;
10562
+ setDownloads((previous) => ({
10563
+ ...previous,
10564
+ [paperId]: { state: "downloading" }
10565
+ }));
9325
10566
  (async () => {
9326
10567
  try {
9327
- const result = await downloadPaperPdf(taskId, paper.paperId);
9328
- if (result.status === "downloaded") setToast(tpl(t("pdf.downloaded"), { name: result.filename ?? "" }));
9329
- else if (result.status === "html-only") setToast(tpl(t("pdf.htmlFallback"), { message: result.error ?? "" }));
9330
- else setToast(tpl(t("pdf.error"), { message: result.error ?? result.status }));
10568
+ const result = await downloadPaperPdf(taskId, paperId);
10569
+ if (result.status === "downloaded") {
10570
+ setDownloads((previous) => ({
10571
+ ...previous,
10572
+ [paperId]: {
10573
+ state: "downloaded",
10574
+ ...result.path === void 0 ? {} : { path: result.path }
10575
+ }
10576
+ }));
10577
+ notify(tpl(t("pdf.downloaded"), { name: result.filename ?? "" }), "info", 6e3);
10578
+ } else {
10579
+ setDownloads((previous) => ({
10580
+ ...previous,
10581
+ [paperId]: {
10582
+ state: "failed",
10583
+ error: result.error ?? result.status,
10584
+ ...result.reason === void 0 ? {} : { reason: result.reason }
10585
+ }
10586
+ }));
10587
+ notify(result.status === "html-only" ? tpl(t("pdf.htmlFallback"), { message: result.error ?? "" }) : tpl(t("pdf.error"), { message: result.error ?? result.status }), "error", 8e3);
10588
+ }
9331
10589
  } catch (error) {
9332
- setToast(tpl(t("pdf.error"), { message: messageOf(error) }));
10590
+ setDownloads((previous) => ({
10591
+ ...previous,
10592
+ [paperId]: {
10593
+ state: "failed",
10594
+ error: messageOf(error)
10595
+ }
10596
+ }));
10597
+ notify(tpl(t("pdf.error"), { message: messageOf(error) }), "error", 8e3);
9333
10598
  } finally {
9334
10599
  view.reload();
9335
10600
  }
9336
10601
  })();
9337
10602
  };
10603
+ /** 单篇 / 批量 / 模型指令共用一个弹窗:先选 Zotero 目录,再逐篇同步。 */
10604
+ const openZotero = (nextItems, meta) => {
10605
+ if (taskId === null || nextItems.length === 0) return;
10606
+ setZoteroItems(nextItems);
10607
+ setZoteroMeta(meta);
10608
+ setZoteroOpen(true);
10609
+ };
9338
10610
  const zoteroPaper = (paper) => {
9339
- if (taskId === null) return;
9340
- syncZotero(taskId, [paper.paperId]).then((result) => {
9341
- setToast(zoteroToast(t, result.saved, result.failed, result.messages));
9342
- if (result.failed > 0) setRisFallback({
9343
- ids: [paper.paperId],
9344
- message: result.messages[0] ?? t("pdf.zoteroFailed")
9345
- });
9346
- }).catch((error) => {
9347
- const message = messageOf(error);
9348
- setToast(tpl(t("pdf.zoteroFailed"), { message }));
9349
- setRisFallback({
9350
- ids: [paper.paperId],
9351
- message
9352
- });
9353
- });
10611
+ openZotero([{
10612
+ paperId: paper.paperId,
10613
+ title: paper.title
10614
+ }]);
9354
10615
  };
9355
10616
  const toggleSelect = (paperId) => {
9356
10617
  setSelected((previous) => {
@@ -9360,7 +10621,17 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9360
10621
  return next;
9361
10622
  });
9362
10623
  };
9363
- const ids = [...selected];
10624
+ /** 本页全选用并集:翻页可累加,不会把其它页已选的文献冲掉。 */
10625
+ const selectPage = (pageIds) => {
10626
+ if (pageIds.length === 0) return;
10627
+ setSelected((previous) => {
10628
+ const next = new Set(previous);
10629
+ for (const paperId of pageIds) next.add(paperId);
10630
+ return next;
10631
+ });
10632
+ };
10633
+ /** 取消全选:清空所有已选(含其它页)。 */
10634
+ const clearSelection = () => setSelected(/* @__PURE__ */ new Set());
9364
10635
  const annotateMany = (patch) => {
9365
10636
  if (ids.length === 0) return;
9366
10637
  view.annotate(ids.map((paperId) => ({
@@ -9380,44 +10651,230 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9380
10651
  })));
9381
10652
  })();
9382
10653
  };
9383
- /** 结果页与全文页共用同一套批量下载 / Zotero 逻辑。 */
9384
- const downloadIds = (paperIds) => {
9385
- if (taskId === null || paperIds.length === 0) return;
10654
+ /**
10655
+ * 批量下载:打开进度弹窗后按最多 5 篇并发下载。
10656
+ *
10657
+ * 已下载(含文件已删除以外的正常已下载)的文献直接略过,不再重复请求;
10658
+ * 每篇的结果实时写入 downloads,弹窗只做集中展示。批量未结束时再次触发
10659
+ * 只重新打开弹窗,避免两轮循环重复下载同一批文献。
10660
+ */
10661
+ const startBatchDownload = (entries, meta = { origin: "panel" }) => {
10662
+ if (taskId === null || entries.length === 0) return;
10663
+ if (batchRunningRef.current) {
10664
+ setBatchOpen(true);
10665
+ return;
10666
+ }
10667
+ const controller = new AbortController();
10668
+ batchAbortRef.current = controller;
10669
+ setBatchCancelled(false);
10670
+ setBatchMeta(meta);
10671
+ setBatchItems(entries.map((entry) => ({ ...entry })));
10672
+ setBatchOpen(true);
10673
+ batchRunningRef.current = true;
10674
+ setBatchRunning(true);
9386
10675
  (async () => {
9387
10676
  let ok = 0;
9388
- let failed = 0;
9389
- for (const paperId of paperIds) try {
9390
- const result = await downloadPaperPdf(taskId, paperId);
9391
- if (result.status === "downloaded" || result.status === "html-only") ok += 1;
9392
- else failed += 1;
9393
- } catch {
9394
- failed += 1;
10677
+ let manualCount = 0;
10678
+ let failedCount = 0;
10679
+ let changed = false;
10680
+ /** 单篇:跳过已下载的,下载结果实时写回 downloads。 */
10681
+ const runOne = async (paperId) => {
10682
+ const runtime = downloadsRef.current[paperId];
10683
+ if (runtime?.state === "downloading") return;
10684
+ if (runtime?.state === "downloaded" || statesRef.current[paperId]?.pdfStatus === "downloaded") return;
10685
+ changed = true;
10686
+ setDownloads((previous) => ({
10687
+ ...previous,
10688
+ [paperId]: { state: "downloading" }
10689
+ }));
10690
+ try {
10691
+ const result = await downloadPaperPdf(taskId, paperId, controller.signal);
10692
+ if (result.status === "downloaded") {
10693
+ ok += 1;
10694
+ setDownloads((previous) => ({
10695
+ ...previous,
10696
+ [paperId]: {
10697
+ state: "downloaded",
10698
+ ...result.path === void 0 ? {} : { path: result.path }
10699
+ }
10700
+ }));
10701
+ } else {
10702
+ if (isManualPdfFailure(result.reason)) manualCount += 1;
10703
+ else failedCount += 1;
10704
+ setDownloads((previous) => ({
10705
+ ...previous,
10706
+ [paperId]: {
10707
+ state: "failed",
10708
+ error: result.error ?? result.status,
10709
+ ...result.reason === void 0 ? {} : { reason: result.reason }
10710
+ }
10711
+ }));
10712
+ }
10713
+ } catch (error) {
10714
+ if (controller.signal.aborted) {
10715
+ setDownloads((previous) => {
10716
+ const next = { ...previous };
10717
+ delete next[paperId];
10718
+ return next;
10719
+ });
10720
+ return;
10721
+ }
10722
+ failedCount += 1;
10723
+ setDownloads((previous) => ({
10724
+ ...previous,
10725
+ [paperId]: {
10726
+ state: "failed",
10727
+ error: messageOf(error)
10728
+ }
10729
+ }));
10730
+ }
10731
+ };
10732
+ let cursor = 0;
10733
+ const worker = async () => {
10734
+ while (cursor < entries.length) {
10735
+ if (controller.signal.aborted) return;
10736
+ const entry = entries[cursor];
10737
+ cursor += 1;
10738
+ if (entry !== void 0) await runOne(entry.paperId);
10739
+ }
10740
+ };
10741
+ try {
10742
+ await Promise.all(Array.from({ length: Math.min(BATCH_CONCURRENCY, entries.length) }, () => worker()));
10743
+ } finally {
10744
+ batchRunningRef.current = false;
10745
+ batchAbortRef.current = null;
10746
+ setBatchRunning(false);
10747
+ if (changed) view.reload();
9395
10748
  }
9396
- setToast(tpl(t("bulk.downloadDone"), {
10749
+ if (changed && !controller.signal.aborted) notify(tpl(t("bulk.downloadDone"), {
9397
10750
  ok,
9398
- failed
9399
- }));
9400
- view.reload();
10751
+ manual: manualCount,
10752
+ failed: failedCount
10753
+ }), failedCount > 0 ? "error" : "info", 8e3);
9401
10754
  })();
9402
10755
  };
10756
+ /** 结果页选中项:标题优先取当前页,跨页选中的文献再异步补详情。 */
10757
+ const downloadMany = () => {
10758
+ if (taskId === null || ids.length === 0) return;
10759
+ const known = /* @__PURE__ */ new Map();
10760
+ for (const paper of view.page?.papers ?? []) known.set(paper.paperId, paper.title);
10761
+ startBatchDownload(ids.map((paperId) => ({
10762
+ paperId,
10763
+ title: known.get(paperId) ?? ""
10764
+ })));
10765
+ const missing = ids.filter((paperId) => !known.has(paperId));
10766
+ if (missing.length === 0) return;
10767
+ Promise.all(missing.map((paperId) => fetchPaper(taskId, paperId).then((loaded) => {
10768
+ setBatchItems((previous) => previous.map((item) => item.paperId === paperId ? {
10769
+ ...item,
10770
+ title: loaded.paper.title
10771
+ } : item));
10772
+ }).catch(() => void 0)));
10773
+ };
10774
+ /** 只有显式的「取消下载」才中止;关闭弹窗仍只隐藏,后台继续跑完。 */
10775
+ const cancelBatch = () => {
10776
+ batchAbortRef.current?.abort();
10777
+ setBatchCancelled(true);
10778
+ };
10779
+ (0, react.useEffect)(() => {
10780
+ if (taskId === null || view.detail === null || view.page === null) return void 0;
10781
+ const body = {
10782
+ taskId,
10783
+ pageIndex: view.pageIndex,
10784
+ pageSize: view.pageSize,
10785
+ total: view.page.total,
10786
+ ...filters.sort === void 0 ? {} : { sort: filters.sort },
10787
+ filters: snapshotFilters(filters),
10788
+ pagePaperIds: view.page.papers.map((paper) => paper.paperId),
10789
+ selectedIds: [...selected],
10790
+ rev: view.page.rev
10791
+ };
10792
+ const timer = setTimeout(() => {
10793
+ if (document.hidden) return;
10794
+ postViewSnapshot(body).catch(() => void 0);
10795
+ }, 400);
10796
+ return () => clearTimeout(timer);
10797
+ }, [
10798
+ taskId,
10799
+ view.detail,
10800
+ view.page,
10801
+ view.pageIndex,
10802
+ view.pageSize,
10803
+ filters,
10804
+ selected
10805
+ ]);
10806
+ (0, react.useEffect)(() => {
10807
+ if (taskId === null || batchRunningRef.current || batchRunning) return;
10808
+ const command = pendingCommands[0];
10809
+ if (command === void 0) return;
10810
+ if (command.paperIds.length === 0) {
10811
+ markConsumed(command.commandId);
10812
+ notify(t("batch.empty"));
10813
+ return;
10814
+ }
10815
+ const byId = new Map(command.items.map((item) => [item.paperId, item]));
10816
+ const items = command.paperIds.map((paperId) => {
10817
+ const item = byId.get(paperId);
10818
+ return {
10819
+ paperId,
10820
+ title: item?.title ?? papersByIdRef.current.get(paperId)?.title ?? "",
10821
+ ...item?.source === void 0 ? {} : { source: item.source },
10822
+ ...item?.meta === void 0 ? {} : { meta: item.meta }
10823
+ };
10824
+ });
10825
+ markConsumed(command.commandId);
10826
+ startBatchDownload(items, {
10827
+ origin: "model",
10828
+ scopeLabel: command.scopeLabel,
10829
+ reason: command.reason
10830
+ });
10831
+ }, [
10832
+ taskId,
10833
+ pendingCommands,
10834
+ batchRunning
10835
+ ]);
10836
+ (0, react.useEffect)(() => {
10837
+ if (taskId === null || zoteroOpen) return;
10838
+ const command = pendingZoteroCommands[0];
10839
+ if (command === void 0) return;
10840
+ if (command.paperIds.length === 0) {
10841
+ markZoteroConsumed(command.commandId);
10842
+ notify(t("zotero.dialog.modelEmpty"));
10843
+ return;
10844
+ }
10845
+ const byId = new Map(command.items.map((item) => [item.paperId, item]));
10846
+ const items = command.paperIds.map((paperId) => {
10847
+ const item = byId.get(paperId);
10848
+ return {
10849
+ paperId,
10850
+ title: item?.title ?? papersByIdRef.current.get(paperId)?.title ?? "",
10851
+ ...item?.source === void 0 ? {} : { source: item.source },
10852
+ ...item?.meta === void 0 ? {} : { meta: item.meta }
10853
+ };
10854
+ });
10855
+ markZoteroConsumed(command.commandId);
10856
+ openZotero(items, {
10857
+ origin: "model",
10858
+ scopeLabel: command.scopeLabel,
10859
+ reason: command.reason,
10860
+ ...command.target === void 0 ? {} : { initialTarget: command.target },
10861
+ autoStart: command.autoStart
10862
+ });
10863
+ }, [
10864
+ taskId,
10865
+ pendingZoteroCommands,
10866
+ zoteroOpen
10867
+ ]);
9403
10868
  const zoteroIds = (paperIds) => {
9404
10869
  if (taskId === null || paperIds.length === 0) return;
9405
- syncZotero(taskId, paperIds).then((result) => {
9406
- setToast(zoteroToast(t, result.saved, result.failed, result.messages));
9407
- if (result.failed > 0) setRisFallback({
9408
- ids: [...paperIds],
9409
- message: result.messages[0] ?? t("pdf.zoteroFailed")
9410
- });
9411
- }).catch((error) => {
9412
- const message = messageOf(error);
9413
- setToast(tpl(t("pdf.zoteroFailed"), { message }));
9414
- setRisFallback({
9415
- ids: [...paperIds],
9416
- message
9417
- });
9418
- });
10870
+ const known = /* @__PURE__ */ new Map();
10871
+ for (const paper of view.page?.papers ?? []) known.set(paper.paperId, paper.title);
10872
+ for (const paper of papersByIdRef.current.values()) if (!known.has(paper.paperId)) known.set(paper.paperId, paper.title);
10873
+ openZotero(paperIds.map((paperId) => ({
10874
+ paperId,
10875
+ title: known.get(paperId) ?? ""
10876
+ })));
9419
10877
  };
9420
- const downloadMany = () => downloadIds(ids);
9421
10878
  const zoteroMany = () => zoteroIds(ids);
9422
10879
  if (taskId === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
9423
10880
  style: panelStyle,
@@ -9447,11 +10904,12 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9447
10904
  const failed = detail.result.sources.filter((source) => !source.ok && !source.skipped);
9448
10905
  const skipped = detail.result.sources.filter((source) => source.skipped);
9449
10906
  const page = view.page;
10907
+ const pageIds = page?.papers.map((paper) => paper.paperId) ?? [];
9450
10908
  /** 当前生效的排序:面板选择优先,未选择时跟随任务条件。 */
9451
10909
  const sortValue = filters.sort ?? detail.conditions.sort;
9452
10910
  const coverage = overview?.fullText;
9453
10911
  const overviewAvailable = coverage === void 0 ? 0 : coverage.available + coverage.downloaded + coverage.htmlOnly;
9454
- const overviewMissing = coverage === void 0 ? 0 : coverage.unknown + coverage.paywalled + coverage.blocked + coverage.failed;
10912
+ const overviewMissing = coverage === void 0 ? 0 : coverage.unknown + coverage.missing + coverage.paywalled + coverage.blocked + coverage.failed;
9455
10913
  const overviewRate = overview === null || overview.loaded === 0 ? 0 : Math.round(overviewAvailable / overview.loaded * 100);
9456
10914
  const conditionMeta = [tpl(t("task.pageSize"), { n: detail.conditions.pageSize })];
9457
10915
  if (detail.conditions.yearFrom !== void 0 || detail.conditions.yearTo !== void 0) conditionMeta.push(tpl(t("task.conditionYear"), { value: `${detail.conditions.yearFrom ?? ""}–${detail.conditions.yearTo ?? ""}` }));
@@ -9723,34 +11181,40 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9723
11181
  onCancel: () => setEditingConditions(false),
9724
11182
  onSaved: () => {
9725
11183
  setEditingConditions(false);
9726
- setToast(t("condition.saved"));
11184
+ notify(t("condition.saved"));
9727
11185
  view.reload();
9728
11186
  }
9729
11187
  }),
9730
- toast !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
9731
- style: toastStyle,
9732
- children: toast
9733
- }),
9734
- risFallback !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
9735
- style: RIS_FALLBACK_STYLE,
9736
- children: [
9737
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
9738
- style: { minWidth: 0 },
9739
- children: tpl(t("pdf.zoteroFallback"), { message: risFallback.message })
9740
- }),
9741
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
9742
- href: exportUrl(taskId, "ris", {}, risFallback.ids),
9743
- download: true,
9744
- style: linkStyle,
9745
- children: t("pdf.zoteroFallbackLink")
9746
- }),
9747
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
9748
- type: "button",
9749
- style: linkButtonStyle,
9750
- onClick: () => setRisFallback(null),
9751
- children: t("pdf.zoteroFallbackDismiss")
9752
- })
9753
- ]
11188
+ (toast !== "" || risFallback !== null) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11189
+ style: toastStackStyle,
11190
+ children: [toast !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
11191
+ role: "status",
11192
+ style: {
11193
+ ...toastStyle,
11194
+ ...toastTone === "error" ? toastErrorStyle : null
11195
+ },
11196
+ children: toast
11197
+ }), risFallback !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11198
+ style: RIS_FALLBACK_STYLE,
11199
+ children: [
11200
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11201
+ style: { minWidth: 0 },
11202
+ children: tpl(t("pdf.zoteroFallback"), { message: risFallback.message })
11203
+ }),
11204
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
11205
+ href: exportUrl(taskId, "ris", {}, risFallback.ids),
11206
+ download: true,
11207
+ style: RIS_FALLBACK_LINK_STYLE,
11208
+ children: t("pdf.zoteroFallbackLink")
11209
+ }),
11210
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11211
+ type: "button",
11212
+ style: RIS_FALLBACK_BUTTON_STYLE,
11213
+ onClick: () => setRisFallback(null),
11214
+ children: t("pdf.zoteroFallbackDismiss")
11215
+ })
11216
+ ]
11217
+ })]
9754
11218
  }),
9755
11219
  view.error !== null && detail.result.totalLoaded > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
9756
11220
  style: {
@@ -9846,23 +11310,48 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9846
11310
  }),
9847
11311
  page !== null && page.total > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
9848
11312
  style: PAGE_TOOLBAR_ROW_STYLE,
9849
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
9850
- style: PAGE_SORT_LABEL_STYLE,
9851
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("filter.sort") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
9852
- style: PAGE_SORT_SELECT_STYLE,
9853
- value: sortValue,
9854
- onChange: (event) => {
9855
- const next = event.target.value;
9856
- setFilters((previous) => ({
9857
- ...previous,
9858
- sort: next === detail.conditions.sort ? void 0 : next
9859
- }));
9860
- },
9861
- children: SORT_MODES.map((mode) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
9862
- value: mode,
9863
- children: t(`sort.${mode}`)
9864
- }, mode))
9865
- })]
11313
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
11314
+ style: PAGE_TOOLBAR_LEFT_STYLE,
11315
+ children: [
11316
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
11317
+ style: PAGE_SORT_LABEL_STYLE,
11318
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("filter.sort") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
11319
+ style: PAGE_SORT_SELECT_STYLE,
11320
+ value: sortValue,
11321
+ onChange: (event) => {
11322
+ const next = event.target.value;
11323
+ setFilters((previous) => ({
11324
+ ...previous,
11325
+ sort: next === detail.conditions.sort ? void 0 : next
11326
+ }));
11327
+ },
11328
+ children: SORT_MODES.map((mode) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
11329
+ value: mode,
11330
+ children: t(`sort.${mode}`)
11331
+ }, mode))
11332
+ })]
11333
+ }),
11334
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11335
+ type: "button",
11336
+ style: PAGE_TOOLBAR_ACTION_STYLE,
11337
+ title: t("bulk.selectPageHint"),
11338
+ onClick: () => selectPage(pageIds),
11339
+ children: t("bulk.selectPage")
11340
+ }),
11341
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11342
+ type: "button",
11343
+ style: selected.size === 0 ? PAGE_TOOLBAR_ACTION_DISABLED_STYLE : PAGE_TOOLBAR_ACTION_STYLE,
11344
+ disabled: selected.size === 0,
11345
+ title: t("bulk.unselectAllHint"),
11346
+ onClick: clearSelection,
11347
+ children: t("bulk.unselectAll")
11348
+ }),
11349
+ selected.size > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11350
+ style: PAGE_TOOLBAR_COUNT_STYLE,
11351
+ title: t("bulk.keepHint"),
11352
+ children: tpl(t("bulk.count"), { n: selected.size })
11353
+ })
11354
+ ]
9866
11355
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pager, {
9867
11356
  t,
9868
11357
  index: view.pageIndex,
@@ -9876,16 +11365,21 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9876
11365
  taskId,
9877
11366
  count: selected.size,
9878
11367
  ids,
9879
- pageIds: page?.papers.map((paper) => paper.paperId) ?? [],
11368
+ pageIds,
9880
11369
  groups: detail.groups,
9881
11370
  busy: view.busy,
9882
11371
  filters,
9883
- onClear: () => setSelected(/* @__PURE__ */ new Set()),
9884
- onSelectPage: (pageIds) => setSelected(new Set(pageIds)),
11372
+ onClear: clearSelection,
11373
+ onSelectPage: selectPage,
9885
11374
  onAnnotate: annotateMany,
9886
11375
  onInsert: insertMany,
9887
11376
  onDownload: downloadMany,
9888
- onZotero: zoteroMany
11377
+ onZotero: zoteroMany,
11378
+ batchRunning,
11379
+ ...detail.downloadDir === void 0 || onRevealLocal === void 0 ? {} : {
11380
+ downloadDir: detail.downloadDir,
11381
+ onRevealDir: onRevealLocal
11382
+ }
9889
11383
  }),
9890
11384
  page !== null && page.papers.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
9891
11385
  style: listStyle$1,
@@ -9896,6 +11390,10 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9896
11390
  state: detail.states[paper.paperId],
9897
11391
  groups: detail.groups,
9898
11392
  busy: view.busy,
11393
+ ...detail.downloadDir === void 0 ? {} : { downloadDir: detail.downloadDir },
11394
+ ...downloads[paper.paperId] === void 0 ? {} : { download: downloads[paper.paperId] },
11395
+ ...onOpenLocal === void 0 ? {} : { onOpenLocal },
11396
+ ...onRevealLocal === void 0 ? {} : { onRevealLocal },
9899
11397
  selected: selected.has(paper.paperId),
9900
11398
  onToggleSelect: toggleSelect,
9901
11399
  onInsert: insert,
@@ -9946,8 +11444,13 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9946
11444
  states: detail.states,
9947
11445
  stats: overview,
9948
11446
  onDownload: downloadPaper,
11447
+ ...onOpenLocal === void 0 ? {} : { onOpenLocal },
11448
+ ...onRevealLocal === void 0 ? {} : { onRevealLocal },
9949
11449
  onZotero: zoteroPaper,
9950
- onBatchDownload: (papers) => downloadIds(papers.map((paper) => paper.paperId)),
11450
+ onBatchDownload: (papers) => startBatchDownload(papers.map((paper) => ({
11451
+ paperId: paper.paperId,
11452
+ title: paper.title
11453
+ }))),
9951
11454
  onBatchZotero: (papers) => zoteroIds(papers.map((paper) => paper.paperId)),
9952
11455
  onShowInResults: (viewKey) => {
9953
11456
  setFilters(viewKey === "missing" ? { missingFullText: true } : { pdfStatus: viewKey });
@@ -9958,6 +11461,7 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9958
11461
  taskId,
9959
11462
  detail,
9960
11463
  onSaved: view.reload,
11464
+ onNotify: notify,
9961
11465
  onPickGroup: (groupId) => {
9962
11466
  setFilters((previous) => ({
9963
11467
  ...previous,
@@ -9969,7 +11473,8 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9969
11473
  t,
9970
11474
  taskId,
9971
11475
  detail,
9972
- onSaved: view.reload
11476
+ onSaved: view.reload,
11477
+ onNotify: notify
9973
11478
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CompareTab, {
9974
11479
  t,
9975
11480
  taskId,
@@ -9986,6 +11491,42 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9986
11491
  }
9987
11492
  })
9988
11493
  }),
11494
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadDialog, {
11495
+ t,
11496
+ open: batchOpen,
11497
+ items: batchItems,
11498
+ states: detail.states,
11499
+ downloads,
11500
+ origin: batchMeta.origin,
11501
+ scopeLabel: batchMeta.scopeLabel,
11502
+ reason: batchMeta.reason,
11503
+ cancelled: batchCancelled,
11504
+ onCancel: batchMeta.origin === "model" ? cancelBatch : void 0,
11505
+ onClose: () => {
11506
+ setBatchOpen(false);
11507
+ view.reload();
11508
+ }
11509
+ }),
11510
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoteroSyncDialog, {
11511
+ t,
11512
+ open: zoteroOpen,
11513
+ taskId,
11514
+ items: zoteroItems,
11515
+ states: detail.states,
11516
+ meta: zoteroMeta,
11517
+ onClose: () => {
11518
+ setZoteroOpen(false);
11519
+ view.reload();
11520
+ },
11521
+ onDone: (failedIds) => {
11522
+ view.reload();
11523
+ if (failedIds.length === 0) return;
11524
+ setRisFallback({
11525
+ ids: failedIds,
11526
+ message: t("zotero.dialog.failFallback")
11527
+ });
11528
+ }
11529
+ }),
9989
11530
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RoundsDialog, {
9990
11531
  t,
9991
11532
  open: showRounds,
@@ -9995,7 +11536,7 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9995
11536
  busy: active,
9996
11537
  onRerun: () => {
9997
11538
  view.reload();
9998
- setToast(t("rounds.rerunStarted"));
11539
+ notify(t("rounds.rerunStarted"));
9999
11540
  }
10000
11541
  }),
10001
11542
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskListDialog, {
@@ -10154,6 +11695,8 @@ const EXPORT_FORMATS = [
10154
11695
  "bibtex",
10155
11696
  "md"
10156
11697
  ];
11698
+ /** 批量下载的并发上限:同时下载 5 篇。 */
11699
+ const BATCH_CONCURRENCY = 5;
10157
11700
  /** 筛读计数(点击筛选):与「筛选」按钮同排,接在筛选条最前面。 */
10158
11701
  function ScreeningPills({ t, detail, filters, onChange }) {
10159
11702
  const counts = detail.stats.screening;
@@ -10186,7 +11729,7 @@ function ScreeningPills({ t, detail, filters, onChange }) {
10186
11729
  });
10187
11730
  }
10188
11731
  /** 选中多篇后的批量操作条:筛读、分组、下载、Zotero、按选中导出。 */
10189
- function BulkBar({ t, taskId, count, ids, pageIds, groups, busy, filters, onClear, onSelectPage, onAnnotate, onInsert, onDownload, onZotero }) {
11732
+ function BulkBar({ t, taskId, count, ids, pageIds, groups, busy, batchRunning, downloadDir, onRevealDir, filters, onClear, onSelectPage, onAnnotate, onInsert, onDownload, onZotero }) {
10190
11733
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10191
11734
  style: BULK_STYLE,
10192
11735
  children: [
@@ -10261,11 +11804,15 @@ function BulkBar({ t, taskId, count, ids, pageIds, groups, busy, filters, onClea
10261
11804
  label: t("bulk.insert"),
10262
11805
  onClick: onInsert
10263
11806
  }),
10264
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BulkActionButton, {
10265
- icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadIcon, {}),
11807
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BulkDownloadButton, {
11808
+ t,
10266
11809
  label: t("bulk.download"),
10267
11810
  onClick: onDownload,
10268
- disabled: busy
11811
+ disabled: busy || batchRunning,
11812
+ ...downloadDir === void 0 || onRevealDir === void 0 ? {} : {
11813
+ downloadDir,
11814
+ onReveal: onRevealDir
11815
+ }
10269
11816
  }),
10270
11817
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BulkActionButton, {
10271
11818
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoteroIcon, {}),
@@ -10317,6 +11864,73 @@ function BulkActionButton({ icon, label, onClick, disabled = false }) {
10317
11864
  children: [icon, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label })]
10318
11865
  });
10319
11866
  }
11867
+ /**
11868
+ * 批量下载按钮 + 下三角:点主体打开下载进度弹窗;点箭头展开菜单,
11869
+ * 在系统文件管理器中打开下载目录(与条目上「查看已下载 ▾」的菜单一致)。
11870
+ */
11871
+ function BulkDownloadButton({ t, label, onClick, disabled, downloadDir, onReveal }) {
11872
+ const [open, setOpen] = (0, react.useState)(false);
11873
+ const groupRef = (0, react.useRef)(null);
11874
+ (0, react.useEffect)(() => {
11875
+ if (!open) return void 0;
11876
+ const closeOnOutside = (event) => {
11877
+ if (groupRef.current !== null && !groupRef.current.contains(event.target)) setOpen(false);
11878
+ };
11879
+ const closeOnEscape = (event) => {
11880
+ if (event.key === "Escape") setOpen(false);
11881
+ };
11882
+ document.addEventListener("mousedown", closeOnOutside);
11883
+ document.addEventListener("keydown", closeOnEscape);
11884
+ return () => {
11885
+ document.removeEventListener("mousedown", closeOnOutside);
11886
+ document.removeEventListener("keydown", closeOnEscape);
11887
+ };
11888
+ }, [open]);
11889
+ const revealable = downloadDir !== void 0 && onReveal !== void 0;
11890
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
11891
+ ref: groupRef,
11892
+ style: BULK_DOWNLOAD_GROUP_STYLE,
11893
+ children: [
11894
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BulkActionButton, {
11895
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadIcon, {}),
11896
+ label,
11897
+ onClick,
11898
+ disabled
11899
+ }),
11900
+ revealable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11901
+ type: "button",
11902
+ style: {
11903
+ ...BULK_ARROW_STYLE,
11904
+ ...disabled ? {
11905
+ opacity: .45,
11906
+ cursor: "default"
11907
+ } : null
11908
+ },
11909
+ "aria-haspopup": "menu",
11910
+ "aria-expanded": open,
11911
+ "aria-label": t("bulk.revealDir"),
11912
+ title: t("bulk.revealDir"),
11913
+ disabled,
11914
+ onClick: () => setOpen((value) => !value),
11915
+ children: "▾"
11916
+ }),
11917
+ open && revealable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
11918
+ role: "menu",
11919
+ style: BULK_MENU_STYLE,
11920
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
11921
+ type: "button",
11922
+ role: "menuitem",
11923
+ style: BULK_MENU_ITEM_STYLE,
11924
+ onClick: () => {
11925
+ setOpen(false);
11926
+ if (downloadDir !== void 0) onReveal?.(downloadDir);
11927
+ },
11928
+ children: t("bulk.revealDir")
11929
+ })
11930
+ })
11931
+ ]
11932
+ });
11933
+ }
10320
11934
  /** 结果页翻页条:首页 / 上一页 / 页码 / 下一页 / 末页。单页时不渲染。 */
10321
11935
  function Pager({ t, index, count, total, onChange }) {
10322
11936
  if (count <= 1) return null;
@@ -10370,15 +11984,6 @@ function Pager({ t, index, count, total, onChange }) {
10370
11984
  function messageOf(error) {
10371
11985
  return error instanceof Error ? error.message : String(error);
10372
11986
  }
10373
- function zoteroToast(t, saved, failed, messages) {
10374
- const detail = messages[0] ?? "";
10375
- if (failed === 0 && detail === "") return tpl(t("pdf.zoteroDone"), { n: saved });
10376
- return tpl(t("pdf.zoteroPartial"), {
10377
- saved,
10378
- failed,
10379
- message: detail
10380
- });
10381
- }
10382
11987
  /** 结果页「检索还在进行」的提示条:蓝色高亮块 + 旋转图标。 */
10383
11988
  const ROUND_RESULTS_HINT_STYLE = {
10384
11989
  display: "flex",
@@ -10399,16 +12004,23 @@ const ROUND_RESULTS_ICON_STYLE = {
10399
12004
  flexShrink: 0
10400
12005
  };
10401
12006
  const RIS_FALLBACK_STYLE = {
12007
+ ...toastStyle,
12008
+ pointerEvents: "auto",
10402
12009
  display: "flex",
10403
12010
  alignItems: "center",
10404
12011
  flexWrap: "wrap",
10405
12012
  gap: 8,
10406
- padding: "5px 8px",
10407
- borderRadius: 6,
10408
- border: "1px solid rgba(178,106,0,0.4)",
10409
- background: "rgba(178,106,0,0.08)",
10410
- fontSize: 11,
10411
- lineHeight: 1.5
12013
+ borderLeft: "3px solid #b26a00"
12014
+ };
12015
+ const RIS_FALLBACK_LINK_STYLE = {
12016
+ color: "#fff",
12017
+ fontSize: 12,
12018
+ textDecoration: "underline"
12019
+ };
12020
+ const RIS_FALLBACK_BUTTON_STYLE = {
12021
+ ...linkButtonStyle,
12022
+ color: "#fff",
12023
+ textDecoration: "underline"
10412
12024
  };
10413
12025
  const TAB_ROW_STYLE = {
10414
12026
  display: "flex",
@@ -10517,6 +12129,50 @@ const BULK_BUTTON_STYLE = {
10517
12129
  lineHeight: 1.4,
10518
12130
  cursor: "pointer"
10519
12131
  };
12132
+ /** 批量下载 + 下三角的组合:箭头紧贴按钮右侧,菜单相对整体定位。 */
12133
+ const BULK_DOWNLOAD_GROUP_STYLE = {
12134
+ position: "relative",
12135
+ display: "inline-flex",
12136
+ alignItems: "center",
12137
+ gap: 2
12138
+ };
12139
+ const BULK_ARROW_STYLE = {
12140
+ padding: "2px 3px",
12141
+ borderRadius: 5,
12142
+ border: "1px solid rgba(80,140,255,0.4)",
12143
+ background: "transparent",
12144
+ color: ACCENT,
12145
+ fontSize: 12,
12146
+ lineHeight: 1.4,
12147
+ cursor: "pointer"
12148
+ };
12149
+ const BULK_MENU_STYLE = {
12150
+ position: "absolute",
12151
+ top: "100%",
12152
+ left: 0,
12153
+ marginTop: 3,
12154
+ zIndex: 40,
12155
+ display: "flex",
12156
+ flexDirection: "column",
12157
+ minWidth: 132,
12158
+ padding: 2,
12159
+ borderRadius: 6,
12160
+ border: "1px solid rgba(127,127,127,0.3)",
12161
+ background: "#fff",
12162
+ boxShadow: "0 6px 20px rgba(0,0,0,0.18)"
12163
+ };
12164
+ const BULK_MENU_ITEM_STYLE = {
12165
+ padding: "5px 8px",
12166
+ border: "none",
12167
+ borderRadius: 4,
12168
+ background: "transparent",
12169
+ color: "#1f2328",
12170
+ fontSize: 12,
12171
+ lineHeight: 1.4,
12172
+ textAlign: "left",
12173
+ whiteSpace: "nowrap",
12174
+ cursor: "pointer"
12175
+ };
10520
12176
  const COVERAGE_WRAP_STYLE = {
10521
12177
  display: "flex",
10522
12178
  flexDirection: "column",
@@ -10550,6 +12206,7 @@ const QUICK_FILTER_ACTIVE_STYLE = {
10550
12206
  };
10551
12207
  const TASK_PANEL_STYLE = {
10552
12208
  ...panelStyle,
12209
+ position: "relative",
10553
12210
  height: "100%",
10554
12211
  minHeight: 0,
10555
12212
  boxSizing: "border-box"
@@ -10563,7 +12220,7 @@ const SCROLL_BODY_STYLE = {
10563
12220
  overflowY: "auto",
10564
12221
  overflowX: "hidden"
10565
12222
  };
10566
- /** 排序与翻页同一行:排序靠左,翻页(含总篇数)靠右。 */
12223
+ /** 排序(含本页全选 / 取消全选)与翻页同一行:操作靠左,翻页(含总篇数)靠右。 */
10567
12224
  const PAGE_TOOLBAR_ROW_STYLE = {
10568
12225
  display: "flex",
10569
12226
  alignItems: "center",
@@ -10572,6 +12229,27 @@ const PAGE_TOOLBAR_ROW_STYLE = {
10572
12229
  gap: 8,
10573
12230
  padding: "2px 0"
10574
12231
  };
12232
+ const PAGE_TOOLBAR_LEFT_STYLE = {
12233
+ display: "flex",
12234
+ alignItems: "center",
12235
+ flexWrap: "wrap",
12236
+ gap: 8
12237
+ };
12238
+ const PAGE_TOOLBAR_ACTION_STYLE = {
12239
+ ...linkButtonStyle,
12240
+ fontSize: 11.5,
12241
+ whiteSpace: "nowrap"
12242
+ };
12243
+ const PAGE_TOOLBAR_ACTION_DISABLED_STYLE = {
12244
+ ...PAGE_TOOLBAR_ACTION_STYLE,
12245
+ opacity: .4,
12246
+ cursor: "default"
12247
+ };
12248
+ const PAGE_TOOLBAR_COUNT_STYLE = {
12249
+ fontSize: 11.5,
12250
+ opacity: .66,
12251
+ whiteSpace: "nowrap"
12252
+ };
10575
12253
  const PAGE_SORT_LABEL_STYLE = {
10576
12254
  display: "flex",
10577
12255
  alignItems: "center",
@@ -10636,6 +12314,12 @@ const PAPER_TAB_ID = "litsearch-paper";
10636
12314
  /** 设置对话框里这一页的 section id。 */
10637
12315
  const SETTINGS_SECTION_ID = "litsearch";
10638
12316
  const HEALTH_REFRESH_MS = 3e4;
12317
+ /** 文件资源地址:与 DSH 文档预览 tab 使用的 `dsh-resource://file/session/...` 保持一致。 */
12318
+ function localFileAddress(sessionId, path) {
12319
+ const encode = (segment) => encodeURIComponent(segment).replace(/%3A/gi, ":");
12320
+ const normalized = path.replace(/\\/g, "/").replace(/^(?:\.\/)+/, "");
12321
+ return `dsh-resource://file/session/${encode(sessionId)}/${normalized.split("/").map(encode).join("/")}`;
12322
+ }
10639
12323
  const name = "dsh-plugin-litsearch";
10640
12324
  const inject = [
10641
12325
  "slots",
@@ -10710,13 +12394,33 @@ function apply(ctx) {
10710
12394
  const address = `${PAPER_ADDRESS_PREFIX}${encodeURIComponent(taskId)}/${encodeURIComponent(paperId)}`;
10711
12395
  ctx.sidebarRight.openResource(address, title === "" ? void 0 : { params: { title } });
10712
12396
  };
12397
+ /**
12398
+ * 在右侧栏用 DSH 文档预览打开已下载的本地 PDF。
12399
+ * 页面加载后文件可能被删除,先让 Host 复核一次;缺失时状态会广播为
12400
+ * `missing`,按钮随即变成「下载文件已删除点击重新下载」。
12401
+ */
12402
+ const openPdfPreview = (taskId, paperId, path) => {
12403
+ recheckPaperPdf(taskId, paperId).then((result) => result.exists).catch(() => true).then((exists) => {
12404
+ if (!exists || path === "") return;
12405
+ const sessionId = ctx.sidebarRight.mounted.getSnapshot();
12406
+ if (sessionId === void 0) return;
12407
+ ctx.sidebarRight.openResource(localFileAddress(sessionId, path), { revealIfOpened: true });
12408
+ });
12409
+ };
12410
+ /** 在系统文件管理器中打开下载目录并选中该 PDF(失败时静默,避免打断阅读)。 */
12411
+ const revealPdf = (path) => {
12412
+ if (path === "") return;
12413
+ revealLocalFile(path).catch(() => void 0);
12414
+ };
10713
12415
  const TaskBody = (props) => (0, react.createElement)(TaskPane, {
10714
12416
  t,
10715
12417
  useTabInfo: props.useTabInfo,
10716
12418
  inputActions: props.inputActions,
10717
12419
  onOpenPaper: openPaper,
10718
12420
  onOpenTask: (id, title) => openTask(id, title),
10719
- onCloseTask: (id) => closeTask(id)
12421
+ onCloseTask: (id) => closeTask(id),
12422
+ onOpenLocal: openPdfPreview,
12423
+ onRevealLocal: revealPdf
10720
12424
  });
10721
12425
  /** tab 标题:按地址里的任务 id 读缓存,缺了补取一次详情。 */
10722
12426
  const TaskTitle = (props) => {
@@ -10750,7 +12454,9 @@ function apply(ctx) {
10750
12454
  const PaperBody = (props) => (0, react.createElement)(PaperPane, {
10751
12455
  t,
10752
12456
  useTabInfo: props.useTabInfo,
10753
- inputActions: props.inputActions
12457
+ inputActions: props.inputActions,
12458
+ onOpenLocal: openPdfPreview,
12459
+ onRevealLocal: revealPdf
10754
12460
  });
10755
12461
  const PaperTitle = (props) => {
10756
12462
  const params = props.useTabInfo().tab.navigation?.params;