dsh-plugin-litsearch 0.3.0 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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";
@@ -188,7 +192,7 @@ function probeSource(source, signal) {
188
192
  signal
189
193
  });
190
194
  }
191
- /** 批量写入筛读 / 分组 / 必读顺序 / 备注 / 标签。 */
195
+ /** 批量写入筛读 / 分组。 */
192
196
  function annotateTask(id, papers, rev, signal) {
193
197
  const body = { papers };
194
198
  if (rev !== void 0) body.rev = rev;
@@ -229,13 +233,13 @@ function downloadPaperPdf(taskId, paperId, signal) {
229
233
  function syncZotero(taskId, paperIds) {
230
234
  return request(taskPath(taskId, "/zotero"), writeInit("POST", { paperIds }));
231
235
  }
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}`}`;
236
+ /** 点击「查看已下载」前确认本地文件仍在(页面加载后可能被删除)。 */
237
+ function recheckPaperPdf(taskId, paperId) {
238
+ return request(taskPath(taskId, `/papers/${encodeURIComponent(paperId)}/recheck`), writeInit("POST", {}));
239
+ }
240
+ /** 在系统文件管理器中定位已下载的 PDF(打开所在文件夹并选中文件)。 */
241
+ function revealLocalFile(path) {
242
+ return request("/files/reveal", writeInit("POST", { path }));
239
243
  }
240
244
  /** Europe PMC 全文 HTML 地址(用于 iframe)。 */
241
245
  function paperFullTextUrl(taskId, paperId) {
@@ -941,18 +945,30 @@ const dicts = {
941
945
  "pdf.status.unknown": "未获取全文",
942
946
  "pdf.status.available": "有全文直链",
943
947
  "pdf.status.downloaded": "已下载",
948
+ "pdf.status.missing": "文件已删除",
944
949
  "pdf.status.html-only": "仅 HTML 全文",
945
950
  "pdf.status.paywalled": "需订阅",
946
951
  "pdf.status.blocked": "被出版商拒绝",
947
952
  "pdf.status.failed": "获取失败",
948
- "pdf.preview": "预览",
953
+ "pdf.htmlLoading": "HTML 全文加载中…",
949
954
  "pdf.closePreview": "收起",
950
- "pdf.download": "下载 PDF",
955
+ "pdf.download": "下载",
956
+ "pdf.downloading": "下载中…",
957
+ "pdf.downloadedAction": "查看已下载",
958
+ "pdf.missingAction": "下载文件已删除点击重新下载",
959
+ "pdf.retry": "重试下载",
960
+ "pdf.downloadHint": "下载 PDF 并保存到:{path}",
961
+ "pdf.downloadedHint": "已保存到:{path}\n点击在右侧面板打开该 PDF",
962
+ "pdf.missingHint": "本地 PDF 已被删除或移走,点击重新下载",
963
+ "pdf.revealInFolder": "在文件系统中打开",
964
+ "pdf.downloadingHint": "正在下载 PDF,请稍候…",
965
+ "pdf.retryHint": "上次下载失败:{message}\n点击重试",
966
+ "pdf.downloadDirUnknown": "工作目录下的 dsh-litsearch 文件夹",
951
967
  "pdf.fulltext": "HTML 全文",
952
968
  "pdf.zotero": "存入 Zotero",
953
969
  "pdf.error": "全文获取失败:{message}",
954
970
  "pdf.downloaded": "已保存:{name}",
955
- "pdf.htmlFallback": "没有可下载的 PDF,已标记为 HTML 全文可读({message})",
971
+ "pdf.htmlFallback": "未找到可下载的 PDF,已切换为 HTML 全文;可点击 PDF 链接在浏览器打开。",
956
972
  "pdf.zoteroDone": "已存入 Zotero:{n} 篇",
957
973
  "pdf.zoteroPartial": "Zotero:成功 {saved} 篇,失败 {failed} 篇;{message}",
958
974
  "pdf.zoteroFailed": "Zotero 同步失败:{message}",
@@ -961,7 +977,11 @@ const dicts = {
961
977
  "pdf.zoteroFallbackDismiss": "关闭",
962
978
  "bulk.count": "已选 {n} 篇",
963
979
  "bulk.select": "选择这篇文献",
964
- "bulk.selectPage": "全选本页",
980
+ "bulk.selectPage": "本页全选",
981
+ "bulk.selectPageHint": "把当前页的文献加入已选,其他页的选中不受影响",
982
+ "bulk.unselectAll": "取消全选",
983
+ "bulk.unselectAllHint": "清空所有已选文献(含其他页)",
984
+ "bulk.keepHint": "已选文献跨页保留,翻页不会丢失",
965
985
  "bulk.clear": "清空",
966
986
  "bulk.setScreening": "设置筛读状态",
967
987
  "bulk.setGroup": "归入分组",
@@ -971,7 +991,25 @@ const dicts = {
971
991
  "bulk.insert": "加入对话",
972
992
  "bulk.inserted": "已作为引用插入 {n} 篇(编号列表)",
973
993
  "bulk.export": "按选中导出",
974
- "bulk.downloadDone": "批量下载:成功 {ok} 篇,失败 {failed} 篇",
994
+ "bulk.downloadDone": "批量下载:成功 {ok} 篇,需手动获取 {manual} 篇,失败 {failed} 篇",
995
+ "bulk.revealDir": "打开下载目录",
996
+ "batch.title": "批量下载",
997
+ "batch.desc": "共 {n} 篇;已下载的文献会自动跳过,状态实时更新。",
998
+ "batch.total": "共 {n} 篇",
999
+ "batch.downloaded": "已下载 {n}",
1000
+ "batch.downloading": "下载中 {n}",
1001
+ "batch.pending": "待下载 {n}",
1002
+ "batch.manual": "需手动获取 {n}",
1003
+ "batch.failed": "失败 {n}",
1004
+ "batch.progress": "进度 {done}/{total}",
1005
+ "batch.complete": "下载任务完成",
1006
+ "batch.untitled": "(未获取标题)",
1007
+ "batch.close": "关闭",
1008
+ "batch.status.pending": "未下载",
1009
+ "batch.status.downloading": "下载中…",
1010
+ "batch.status.downloaded": "已下载",
1011
+ "batch.status.manual": "需手动获取",
1012
+ "batch.status.failed": "失败",
975
1013
  "fulltext.loading": "统计全文覆盖…",
976
1014
  "fulltext.hint": "统计基于全部 {total} 篇,与概览同口径,不受当前筛选影响;点状态可切换清单,列表分页加载。",
977
1015
  "fulltext.hintFallback": "统计暂不可用,仍可按状态查看文献清单。",
@@ -987,15 +1025,6 @@ const dicts = {
987
1025
  "annotate.noGroup": "未分组",
988
1026
  "annotate.createGroup": "+ 新建分组…",
989
1027
  "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
1028
  "entity.title": "实体",
1000
1029
  "entity.count": "{n} 个实体",
1001
1030
  "entity.hint": "来自 PubTator3,按标准标识符去重",
@@ -1036,7 +1065,7 @@ const dicts = {
1036
1065
  "groups.empty": "还没有分组。先建一个「为什么读」的类别,再逐篇归入。",
1037
1066
  "groups.namePlaceholder": "如:核心方法",
1038
1067
  "groups.descPlaceholder": "如:用于方法对比",
1039
- "groups.deleteConfirm": "删除分组「{name}」?组内文献会回到未分组,标注不受影响。",
1068
+ "groups.deleteConfirm": "删除分组「{name}」?组内文献会回到未分组,筛读状态不受影响。",
1040
1069
  "groups.saved": "分组已保存",
1041
1070
  "groups.saving": "正在保存…",
1042
1071
  "groups.retry": "重试保存",
@@ -1044,11 +1073,6 @@ const dicts = {
1044
1073
  "notes.taskPlaceholder": "记录检索策略、纳入排除标准、阶段性结论…",
1045
1074
  "notes.save": "保存笔记",
1046
1075
  "notes.saved": "笔记已保存",
1047
- "notes.paperSection": "逐篇备注与标签",
1048
- "notes.empty": "还没有逐篇备注。可在结果卡片的「标注」里写。",
1049
- "notes.loading": "读取逐篇备注…",
1050
- "notes.hint": "逐篇备注按当前任务已加载的前 200 篇统计。",
1051
- "notes.tags": "标签:{value}",
1052
1076
  "compare.title": "与旧任务对比",
1053
1077
  "compare.base": "基准任务",
1054
1078
  "compare.pick": "选择一个旧任务",
@@ -1171,7 +1195,7 @@ const dicts = {
1171
1195
  "result.infoLabel": "状态与来源",
1172
1196
  "result.infoHint": "筛读状态、命中的数据库、被引次数、开放获取与全文状态",
1173
1197
  "result.actionLabel": "快捷操作",
1174
- "result.actionHint": "对这篇文献执行操作:加入对话、打开详情、预览或下载全文、存入 Zotero",
1198
+ "result.actionHint": "对这篇文献执行操作:加入对话、打开详情、下载 PDF 或 HTML 全文、存入 Zotero",
1175
1199
  "error.noEditor": "当前面板不在会话上下文中,无法插入到输入框",
1176
1200
  "error.draftChanged": "输入框草稿已变化,未插入;请避免在插入期间编辑草稿",
1177
1201
  "error.sourceFailed": "数据源 {source} 检索失败:{message}",
@@ -1192,7 +1216,7 @@ const dicts = {
1192
1216
  "group.zotero": "Zotero",
1193
1217
  "group.zoteroHint": "通过 Zotero 桌面端 connector 保存条目与 PDF;只连接本机 127.0.0.1。",
1194
1218
  "field.contactEmail": "联系邮箱",
1195
- "field.contactEmailHint": "作为 OpenAlex / Crossref / NCBI 的 polite pool 标识,可留空;填写后限流更宽松",
1219
+ "field.contactEmailHint": "作为 OpenAlex / Crossref / NCBI 的 polite pool 标识;填写后还会启用 Unpaywall,按 DOI 补全开放获取 PDF 地址,提升批量下载成功率",
1196
1220
  "field.ncbiApiKey": "NCBI API Key",
1197
1221
  "field.ncbiApiKeyHint": "可选;配置后 PubMed E-utilities 额度从 3 次/秒提升到 10 次/秒",
1198
1222
  "field.s2ApiKey": "Semantic Scholar Key",
@@ -1472,18 +1496,30 @@ const dicts = {
1472
1496
  "pdf.status.unknown": "No full text yet",
1473
1497
  "pdf.status.available": "Full-text link",
1474
1498
  "pdf.status.downloaded": "Downloaded",
1499
+ "pdf.status.missing": "File deleted",
1475
1500
  "pdf.status.html-only": "HTML full text",
1476
1501
  "pdf.status.paywalled": "Paywalled",
1477
1502
  "pdf.status.blocked": "Blocked by publisher",
1478
1503
  "pdf.status.failed": "Fetch failed",
1479
- "pdf.preview": "Preview",
1504
+ "pdf.htmlLoading": "Loading full text…",
1480
1505
  "pdf.closePreview": "Close",
1481
- "pdf.download": "Download PDF",
1506
+ "pdf.download": "Download",
1507
+ "pdf.downloading": "Downloading…",
1508
+ "pdf.downloadedAction": "View downloaded",
1509
+ "pdf.missingAction": "Downloaded file deleted — click to re-download",
1510
+ "pdf.retry": "Retry download",
1511
+ "pdf.downloadHint": "Download the PDF to: {path}",
1512
+ "pdf.downloadedHint": "Saved to: {path}\nClick to open the PDF in the right panel",
1513
+ "pdf.missingHint": "The local PDF was deleted or moved. Click to download again.",
1514
+ "pdf.revealInFolder": "Show in file manager",
1515
+ "pdf.downloadingHint": "Downloading PDF, please wait…",
1516
+ "pdf.retryHint": "Last download failed: {message}\nClick to retry",
1517
+ "pdf.downloadDirUnknown": "the dsh-litsearch folder under the workspace",
1482
1518
  "pdf.fulltext": "HTML full text",
1483
1519
  "pdf.zotero": "Save to Zotero",
1484
1520
  "pdf.error": "Full text failed: {message}",
1485
1521
  "pdf.downloaded": "Saved: {name}",
1486
- "pdf.htmlFallback": "No downloadable PDF; marked as HTML full text ({message})",
1522
+ "pdf.htmlFallback": "No downloadable PDF found; switched to HTML full text. Open the PDF link in a browser if needed.",
1487
1523
  "pdf.zoteroDone": "Saved to Zotero: {n}",
1488
1524
  "pdf.zoteroPartial": "Zotero: {saved} saved, {failed} failed; {message}",
1489
1525
  "pdf.zoteroFailed": "Zotero sync failed: {message}",
@@ -1492,7 +1528,11 @@ const dicts = {
1492
1528
  "pdf.zoteroFallbackDismiss": "Dismiss",
1493
1529
  "bulk.count": "{n} selected",
1494
1530
  "bulk.select": "Select this paper",
1495
- "bulk.selectPage": "Select page",
1531
+ "bulk.selectPage": "Select this page",
1532
+ "bulk.selectPageHint": "Add this page to the selection; other pages stay selected",
1533
+ "bulk.unselectAll": "Clear all",
1534
+ "bulk.unselectAllHint": "Clear the whole selection, including other pages",
1535
+ "bulk.keepHint": "Selection is kept when you change pages",
1496
1536
  "bulk.clear": "Clear",
1497
1537
  "bulk.setScreening": "Set screening",
1498
1538
  "bulk.setGroup": "Assign group",
@@ -1502,7 +1542,25 @@ const dicts = {
1502
1542
  "bulk.insert": "Add to chat",
1503
1543
  "bulk.inserted": "Inserted {n} papers as a quoted numbered list",
1504
1544
  "bulk.export": "Export selected",
1505
- "bulk.downloadDone": "Download: {ok} succeeded, {failed} failed",
1545
+ "bulk.downloadDone": "Download: {ok} succeeded, {manual} need manual save, {failed} failed",
1546
+ "bulk.revealDir": "Open download folder",
1547
+ "batch.title": "Batch download",
1548
+ "batch.desc": "Downloading {n} papers; already downloaded ones are skipped. Status updates live.",
1549
+ "batch.total": "{n} total",
1550
+ "batch.downloaded": "{n} downloaded",
1551
+ "batch.downloading": "{n} downloading",
1552
+ "batch.pending": "{n} pending",
1553
+ "batch.manual": "{n} need manual save",
1554
+ "batch.failed": "{n} failed",
1555
+ "batch.progress": "Progress {done}/{total}",
1556
+ "batch.complete": "Download task complete",
1557
+ "batch.untitled": "(title unavailable)",
1558
+ "batch.close": "Close",
1559
+ "batch.status.pending": "Not downloaded",
1560
+ "batch.status.downloading": "Downloading…",
1561
+ "batch.status.downloaded": "Downloaded",
1562
+ "batch.status.manual": "Manual save",
1563
+ "batch.status.failed": "Failed",
1506
1564
  "fulltext.loading": "Computing full-text coverage…",
1507
1565
  "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
1566
  "fulltext.hintFallback": "Coverage is unavailable; you can still browse papers by status.",
@@ -1518,15 +1576,6 @@ const dicts = {
1518
1576
  "annotate.noGroup": "Ungrouped",
1519
1577
  "annotate.createGroup": "+ New group…",
1520
1578
  "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
1579
  "entity.title": "Entities",
1531
1580
  "entity.count": "{n} entities",
1532
1581
  "entity.hint": "From PubTator3, deduped by standard identifier",
@@ -1575,11 +1624,6 @@ const dicts = {
1575
1624
  "notes.taskPlaceholder": "Search strategy, inclusion criteria, interim conclusions…",
1576
1625
  "notes.save": "Save notes",
1577
1626
  "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
1627
  "compare.title": "Compare with an older task",
1584
1628
  "compare.base": "Base task",
1585
1629
  "compare.pick": "Pick an older task",
@@ -1702,7 +1746,7 @@ const dicts = {
1702
1746
  "result.infoLabel": "Status & sources",
1703
1747
  "result.infoHint": "Screening status, matched databases, citations, open access and full-text status",
1704
1748
  "result.actionLabel": "Actions",
1705
- "result.actionHint": "Actions on this paper: insert into chat, open details, preview or download full text, save to Zotero",
1749
+ "result.actionHint": "Actions on this paper: insert into chat, open details, download PDF or HTML full text, save to Zotero",
1706
1750
  "error.noEditor": "This panel is not in a conversation context; cannot insert into the composer",
1707
1751
  "error.draftChanged": "The draft changed; nothing was inserted. Avoid editing while inserting.",
1708
1752
  "error.sourceFailed": "Source {source} failed: {message}",
@@ -1723,7 +1767,7 @@ const dicts = {
1723
1767
  "group.zotero": "Zotero",
1724
1768
  "group.zoteroHint": "Saves items and PDFs through the Zotero desktop connector; only 127.0.0.1 is allowed.",
1725
1769
  "field.contactEmail": "Contact email",
1726
- "field.contactEmailHint": "Used as the polite-pool identifier for OpenAlex / Crossref / NCBI; optional, but it raises rate limits",
1770
+ "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
1771
  "field.ncbiApiKey": "NCBI API key",
1728
1772
  "field.ncbiApiKeyHint": "Optional; raises PubMed E-utilities quota from 3 req/s to 10 req/s",
1729
1773
  "field.s2ApiKey": "Semantic Scholar key",
@@ -1992,11 +2036,32 @@ const actionRowStyle = {
1992
2036
  gap: 10,
1993
2037
  marginTop: 8
1994
2038
  };
1995
- /** Toast。 */
2039
+ /** 提醒浮层容器:贴面板底部堆叠,不遮挡操作按钮的点击。 */
2040
+ const toastStackStyle = {
2041
+ position: "absolute",
2042
+ left: 15,
2043
+ right: 15,
2044
+ bottom: 14,
2045
+ zIndex: 30,
2046
+ display: "flex",
2047
+ flexDirection: "column",
2048
+ gap: 6,
2049
+ pointerEvents: "none"
2050
+ };
2051
+ /** 底部自动消失的提醒:黑底白字。 */
1996
2052
  const toastStyle = {
2053
+ padding: "8px 12px",
2054
+ borderRadius: 8,
2055
+ border: "1px solid rgba(255,255,255,0.16)",
2056
+ background: "rgba(0,0,0,0.92)",
2057
+ color: "#fff",
1997
2058
  fontSize: 12,
1998
- color: ACCENT
2059
+ lineHeight: 1.5,
2060
+ boxShadow: "0 6px 20px rgba(0,0,0,0.28)",
2061
+ pointerEvents: "none"
1999
2062
  };
2063
+ /** 下载失败等错误提醒:同样黑底白字,左侧红条区分。 */
2064
+ const toastErrorStyle = { borderLeft: `3px solid ${ERROR}` };
2000
2065
  // src/client/TaskListDialog.tsx
2001
2066
  /**
2002
2067
  * 右上角「搜索任务列表」弹窗:列出全部任务,可打开、重命名、保留、删除。
@@ -2655,7 +2720,10 @@ function LitsearchPage({ t, onOpenTask }) {
2655
2720
  };
2656
2721
  const pageSize = health?.settings.defaultPageSize ?? 20;
2657
2722
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2658
- style: panelStyle,
2723
+ style: {
2724
+ ...panelStyle,
2725
+ position: "relative"
2726
+ },
2659
2727
  children: [
2660
2728
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2661
2729
  style: pageHeaderStyle,
@@ -2747,15 +2815,20 @@ function LitsearchPage({ t, onOpenTask }) {
2747
2815
  },
2748
2816
  children: error
2749
2817
  }),
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
- })]
2818
+ pending.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2819
+ style: toastStackStyle,
2820
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2821
+ style: bannerStyle,
2822
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2823
+ style: { minWidth: 0 },
2824
+ children: tpl(t("pending.title"), { n: pending.length })
2825
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2826
+ type: "button",
2827
+ style: bannerButtonStyle,
2828
+ onClick: openPending,
2829
+ children: t("pending.open")
2830
+ })]
2831
+ })
2759
2832
  }),
2760
2833
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
2761
2834
  style: sectionTitleStyle,
@@ -2884,14 +2957,18 @@ const historyMetaStyle = {
2884
2957
  opacity: .66
2885
2958
  };
2886
2959
  const bannerStyle = {
2960
+ ...toastStyle,
2961
+ pointerEvents: "auto",
2887
2962
  display: "flex",
2888
2963
  alignItems: "center",
2889
2964
  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
2965
+ gap: 8
2966
+ };
2967
+ const bannerButtonStyle = {
2968
+ ...linkButtonStyle,
2969
+ color: "#fff",
2970
+ textDecoration: "underline",
2971
+ flexShrink: 0
2895
2972
  };
2896
2973
  // src/client/compose.ts
2897
2974
  /** 插入时摘要最多保留多少字符,避免草稿过长。 */
@@ -2986,6 +3063,133 @@ function taskConditionsText(t, detail) {
2986
3063
  function truncate(value, max) {
2987
3064
  return value.length <= max ? value : `${value.slice(0, max)}…`;
2988
3065
  }
3066
+ // src/client/DownloadButton.tsx
3067
+ /**
3068
+ * 「下载 PDF / 查看已下载」动作按钮。
3069
+ *
3070
+ * 未下载时是单个下载按钮;已下载时变为「查看已下载 ▾」拆分按钮:点文字在右侧
3071
+ * 预览本地 PDF,点箭头展开菜单,可在系统文件管理器中定位该文件。文件被删除后
3072
+ * 回到普通按钮并提示点击重新下载。
3073
+ */
3074
+ /** 已下载状态的绿色,与徽章 / 状态色一致。 */
3075
+ const DOWNLOADED_COLOR = "#2e9e5b";
3076
+ /** 本地文件已被删除的警示色。 */
3077
+ const MISSING_COLOR = "#d64545";
3078
+ function DownloadButton({ t, label, hint, downloadedPath, missing = false, downloading = false, disabled = false, style, onDownload, onOpen, onReveal }) {
3079
+ const [menuOpen, setMenuOpen] = (0, react.useState)(false);
3080
+ const groupRef = (0, react.useRef)(null);
3081
+ const path = downloadedPath ?? "";
3082
+ const downloaded = !missing && path !== "" && onOpen !== void 0;
3083
+ const color = missing ? MISSING_COLOR : downloaded ? DOWNLOADED_COLOR : void 0;
3084
+ (0, react.useEffect)(() => {
3085
+ if (!menuOpen) return;
3086
+ const closeOnOutside = (event) => {
3087
+ if (groupRef.current !== null && !groupRef.current.contains(event.target)) setMenuOpen(false);
3088
+ };
3089
+ const closeOnEscape = (event) => {
3090
+ if (event.key === "Escape") setMenuOpen(false);
3091
+ };
3092
+ document.addEventListener("mousedown", closeOnOutside);
3093
+ document.addEventListener("keydown", closeOnEscape);
3094
+ return () => {
3095
+ document.removeEventListener("mousedown", closeOnOutside);
3096
+ document.removeEventListener("keydown", closeOnEscape);
3097
+ };
3098
+ }, [menuOpen]);
3099
+ const main = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
3100
+ type: "button",
3101
+ style: {
3102
+ ...style,
3103
+ display: "inline-flex",
3104
+ alignItems: "center",
3105
+ gap: 4,
3106
+ ...color === void 0 ? null : { color }
3107
+ },
3108
+ disabled: disabled || downloading,
3109
+ title: hint,
3110
+ "aria-busy": downloading,
3111
+ onClick: () => {
3112
+ if (downloaded) onOpen?.(path);
3113
+ else onDownload();
3114
+ },
3115
+ children: [downloading && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SpinnerIcon, {}), label]
3116
+ });
3117
+ if (!downloaded || onReveal === void 0) return main;
3118
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3119
+ ref: groupRef,
3120
+ style: GROUP_STYLE$2,
3121
+ children: [
3122
+ main,
3123
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3124
+ type: "button",
3125
+ style: {
3126
+ ...style,
3127
+ ...ARROW_STYLE,
3128
+ color: DOWNLOADED_COLOR
3129
+ },
3130
+ "aria-haspopup": "menu",
3131
+ "aria-expanded": menuOpen,
3132
+ "aria-label": t("pdf.revealInFolder"),
3133
+ title: t("pdf.revealInFolder"),
3134
+ disabled,
3135
+ onClick: () => setMenuOpen((value) => !value),
3136
+ children: "▾"
3137
+ }),
3138
+ menuOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3139
+ role: "menu",
3140
+ style: MENU_STYLE,
3141
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3142
+ type: "button",
3143
+ role: "menuitem",
3144
+ style: MENU_ITEM_STYLE,
3145
+ onClick: () => {
3146
+ setMenuOpen(false);
3147
+ onReveal?.(path);
3148
+ },
3149
+ children: t("pdf.revealInFolder")
3150
+ })
3151
+ })
3152
+ ]
3153
+ });
3154
+ }
3155
+ const GROUP_STYLE$2 = {
3156
+ position: "relative",
3157
+ display: "inline-flex",
3158
+ alignItems: "center",
3159
+ gap: 2
3160
+ };
3161
+ const ARROW_STYLE = {
3162
+ padding: "0 1px",
3163
+ lineHeight: 1,
3164
+ fontSize: 20
3165
+ };
3166
+ const MENU_STYLE = {
3167
+ position: "absolute",
3168
+ top: "100%",
3169
+ right: 0,
3170
+ marginTop: 3,
3171
+ zIndex: 40,
3172
+ display: "flex",
3173
+ flexDirection: "column",
3174
+ minWidth: 132,
3175
+ padding: 2,
3176
+ borderRadius: 6,
3177
+ border: "1px solid rgba(127,127,127,0.3)",
3178
+ background: "#fff",
3179
+ boxShadow: "0 6px 20px rgba(0,0,0,0.18)"
3180
+ };
3181
+ const MENU_ITEM_STYLE = {
3182
+ padding: "5px 8px",
3183
+ border: "none",
3184
+ borderRadius: 4,
3185
+ background: "transparent",
3186
+ color: "#1f2328",
3187
+ fontSize: 12,
3188
+ lineHeight: 1.4,
3189
+ textAlign: "left",
3190
+ whiteSpace: "nowrap",
3191
+ cursor: "pointer"
3192
+ };
2989
3193
  // src/client/Entities.tsx
2990
3194
  /**
2991
3195
  * PubTator3 实体标注面板。
@@ -3059,7 +3263,7 @@ function EntityPanel({ t, taskId, paperId, pmid }) {
3059
3263
  });
3060
3264
  const groups = groupByType(entities);
3061
3265
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3062
- style: WRAP_STYLE$6,
3266
+ style: WRAP_STYLE$7,
3063
3267
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3064
3268
  style: HINT_STYLE$2,
3065
3269
  children: [
@@ -3104,7 +3308,7 @@ function entityTitle(entity) {
3104
3308
  entity.identifier
3105
3309
  ].filter((part) => part !== "").join(" · ");
3106
3310
  }
3107
- const WRAP_STYLE$6 = {
3311
+ const WRAP_STYLE$7 = {
3108
3312
  display: "flex",
3109
3313
  flexDirection: "column",
3110
3314
  gap: 5,
@@ -3159,11 +3363,141 @@ function insertQuote(actions, text, summary) {
3159
3363
  const span = actions.captureInsertion();
3160
3364
  return actions.insertText(text, span) ? "inserted" : "draft-changed";
3161
3365
  }
3366
+ // src/client/PreviewFrame.tsx
3367
+ /**
3368
+ * 内联 HTML 全文预览框。
3369
+ *
3370
+ * 打开后先在框内显示旋转图标与醒目的加载文案,内容就绪后再显示 iframe。
3371
+ */
3372
+ function PreviewFrame({ t, src, height, onClose }) {
3373
+ const [loadedSrc, setLoadedSrc] = (0, react.useState)("");
3374
+ const loaded = loadedSrc === src;
3375
+ const title = t("pdf.fulltext");
3376
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3377
+ style: WRAP_STYLE$6,
3378
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3379
+ style: HEAD_STYLE$3,
3380
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: title }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3381
+ type: "button",
3382
+ style: CLOSE_STYLE,
3383
+ onClick: onClose,
3384
+ children: t("pdf.closePreview")
3385
+ })]
3386
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3387
+ style: {
3388
+ ...BODY_STYLE$1,
3389
+ height
3390
+ },
3391
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("iframe", {
3392
+ title,
3393
+ src,
3394
+ onLoad: () => setLoadedSrc(src),
3395
+ style: {
3396
+ ...IFRAME_STYLE,
3397
+ opacity: loaded ? 1 : 0
3398
+ }
3399
+ }, src), !loaded && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3400
+ style: OVERLAY_STYLE,
3401
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Spinner, {}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3402
+ style: LOADING_TEXT_STYLE,
3403
+ children: t("pdf.htmlLoading")
3404
+ })]
3405
+ })]
3406
+ })]
3407
+ });
3408
+ }
3409
+ /** 不依赖 CSS keyframes 的旋转图标(SMIL),避免注入 <style> 受 CSP 影响。 */
3410
+ function Spinner() {
3411
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
3412
+ width: 30,
3413
+ height: 30,
3414
+ viewBox: "0 0 24 24",
3415
+ "aria-hidden": "true",
3416
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
3417
+ cx: "12",
3418
+ cy: "12",
3419
+ r: "9",
3420
+ fill: "none",
3421
+ stroke: "rgba(80,140,255,0.22)",
3422
+ strokeWidth: "3"
3423
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
3424
+ cx: "12",
3425
+ cy: "12",
3426
+ r: "9",
3427
+ fill: "none",
3428
+ stroke: ACCENT,
3429
+ strokeWidth: "3",
3430
+ strokeLinecap: "round",
3431
+ strokeDasharray: "56",
3432
+ strokeDashoffset: "42",
3433
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("animateTransform", {
3434
+ attributeName: "transform",
3435
+ type: "rotate",
3436
+ from: "0 12 12",
3437
+ to: "360 12 12",
3438
+ dur: "0.9s",
3439
+ repeatCount: "indefinite"
3440
+ })
3441
+ })]
3442
+ });
3443
+ }
3444
+ const WRAP_STYLE$6 = {
3445
+ marginTop: 6,
3446
+ border: "1px solid rgba(127,127,127,0.22)",
3447
+ borderRadius: 6,
3448
+ overflow: "hidden"
3449
+ };
3450
+ const HEAD_STYLE$3 = {
3451
+ display: "flex",
3452
+ alignItems: "center",
3453
+ justifyContent: "space-between",
3454
+ gap: 8,
3455
+ padding: "3px 8px",
3456
+ fontSize: 11,
3457
+ background: "rgba(127,127,127,0.08)"
3458
+ };
3459
+ const CLOSE_STYLE = {
3460
+ padding: 0,
3461
+ border: "none",
3462
+ background: "transparent",
3463
+ color: ACCENT,
3464
+ fontSize: 12.5,
3465
+ cursor: "pointer"
3466
+ };
3467
+ const BODY_STYLE$1 = {
3468
+ position: "relative",
3469
+ background: "#fff",
3470
+ overflow: "hidden"
3471
+ };
3472
+ const IFRAME_STYLE = {
3473
+ display: "block",
3474
+ width: "100%",
3475
+ height: "100%",
3476
+ border: "none",
3477
+ background: "#fff",
3478
+ transition: "opacity 0.15s ease"
3479
+ };
3480
+ const OVERLAY_STYLE = {
3481
+ position: "absolute",
3482
+ inset: 0,
3483
+ display: "flex",
3484
+ flexDirection: "column",
3485
+ alignItems: "center",
3486
+ justifyContent: "center",
3487
+ gap: 10,
3488
+ background: "#fff"
3489
+ };
3490
+ const LOADING_TEXT_STYLE = {
3491
+ fontSize: 13.5,
3492
+ fontWeight: 700,
3493
+ letterSpacing: "0.02em",
3494
+ color: ACCENT
3495
+ };
3162
3496
  // src/client/PaperPane.tsx
3163
3497
  /**
3164
3498
  * 独立论文 tab:`dsh-resource://litsearch-paper/<taskId>/<paperId>`。
3165
3499
  *
3166
- * 展示完整摘要、标识符、筛读与标注、全文预览和 PubTator 实体;数据来自
3500
+ * 展示完整摘要、标识符、筛读与分组、全文预览和 PubTator 实体;数据来自
3167
3501
  * 单篇详情端点,避免为了打开一篇论文拉取整页结果。
3168
3502
  */
3169
3503
  const PAPER_ADDRESS_PREFIX = "dsh-resource://litsearch-paper/";
@@ -3194,6 +3528,7 @@ const PDF_COLOR$1 = {
3194
3528
  unknown: "rgba(127,127,127,0.8)",
3195
3529
  available: "#508cff",
3196
3530
  downloaded: "#2e9e5b",
3531
+ missing: "#d64545",
3197
3532
  "html-only": "#b26a00",
3198
3533
  paywalled: "#b26a00",
3199
3534
  blocked: "#d64545",
@@ -3202,20 +3537,22 @@ const PDF_COLOR$1 = {
3202
3537
  function messageOf$3(error) {
3203
3538
  return error instanceof Error ? error.message : String(error);
3204
3539
  }
3205
- function PaperPane({ t, useTabInfo, inputActions }) {
3540
+ function PaperPane({ t, useTabInfo, inputActions, onOpenLocal, onRevealLocal }) {
3206
3541
  const info = useTabInfo();
3207
3542
  const ref = paperRefFromAddress(info.tab.navigation?.address || info.tab.contentId || "");
3208
3543
  const [detail, setDetail] = (0, react.useState)(null);
3209
3544
  const [loading, setLoading] = (0, react.useState)(true);
3210
3545
  const [error, setError] = (0, react.useState)(null);
3211
3546
  const [busy, setBusy] = (0, react.useState)(false);
3547
+ const [downloading, setDownloading] = (0, react.useState)(false);
3212
3548
  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)("");
3549
+ const [toast, setToastState] = (0, react.useState)("");
3550
+ const [toastTone, setToastTone] = (0, react.useState)("info");
3551
+ const notify = (text, tone = "info") => {
3552
+ setToastState(text);
3553
+ setToastTone(tone);
3554
+ };
3555
+ const [showHtml, setShowHtml] = (0, react.useState)(false);
3219
3556
  const [risFallback, setRisFallback] = (0, react.useState)(null);
3220
3557
  const taskId = ref?.taskId ?? "";
3221
3558
  ref?.paperId;
@@ -3245,27 +3582,20 @@ function PaperPane({ t, useTabInfo, inputActions }) {
3245
3582
  nonce,
3246
3583
  t
3247
3584
  ]);
3248
- const state = detail?.state;
3249
- const noteKey = state?.note ?? "";
3250
- const tagsKey = (state?.tags ?? []).join(",");
3251
- const orderKey = state?.shortlistOrder ?? "";
3252
3585
  (0, react.useEffect)(() => {
3253
- setNote(noteKey);
3254
- setTags(tagsKey);
3255
- setOrder(orderKey === "" ? "" : String(orderKey));
3256
- }, [
3257
- noteKey,
3258
- tagsKey,
3259
- orderKey
3260
- ]);
3586
+ if (ref === null) return void 0;
3587
+ return subscribeTaskSummary((summary) => {
3588
+ if (summary.id === ref.taskId) setNonce((value) => value + 1);
3589
+ });
3590
+ }, [ref?.taskId]);
3591
+ const state = detail?.state;
3261
3592
  (0, react.useEffect)(() => {
3262
- setEditing(false);
3263
- setPreview(null);
3593
+ setShowHtml(false);
3264
3594
  setRisFallback(null);
3265
3595
  }, [ref?.taskId, ref?.paperId]);
3266
3596
  (0, react.useEffect)(() => {
3267
3597
  if (toast === "") return void 0;
3268
- const timer = setTimeout(() => setToast(""), 2400);
3598
+ const timer = setTimeout(() => setToastState(""), 4e3);
3269
3599
  return () => clearTimeout(timer);
3270
3600
  }, [toast]);
3271
3601
  const paper = detail?.paper;
@@ -3293,33 +3623,23 @@ function PaperPane({ t, useTabInfo, inputActions }) {
3293
3623
  const annotate = (input) => {
3294
3624
  if (busy) return;
3295
3625
  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);
3626
+ annotateTask(taskId, [input]).then(() => setNonce((value) => value + 1)).catch((err) => notify(messageOf$3(err))).finally(() => setBusy(false));
3309
3627
  };
3310
3628
  const insert = () => {
3311
3629
  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"));
3630
+ if (outcome === "inserted") notify(t("result.inserted"));
3631
+ else notify(t(outcome === "no-editor" ? "error.noEditor" : "error.draftChanged"));
3314
3632
  };
3315
3633
  const download = () => {
3316
3634
  setBusy(true);
3635
+ setDownloading(true);
3317
3636
  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(() => {
3637
+ if (result.status === "downloaded") notify(tpl(t("pdf.downloaded"), { name: result.filename ?? "" }));
3638
+ else if (result.status === "html-only") notify(tpl(t("pdf.htmlFallback"), { message: result.error ?? "" }), "error");
3639
+ else notify(tpl(t("pdf.error"), { message: result.error ?? result.status }), "error");
3640
+ }).catch((err) => notify(tpl(t("pdf.error"), { message: messageOf$3(err) }), "error")).finally(() => {
3322
3641
  setBusy(false);
3642
+ setDownloading(false);
3323
3643
  setNonce((value) => value + 1);
3324
3644
  });
3325
3645
  };
@@ -3327,8 +3647,8 @@ function PaperPane({ t, useTabInfo, inputActions }) {
3327
3647
  setBusy(true);
3328
3648
  syncZotero(taskId, [paper.paperId]).then((result) => {
3329
3649
  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"), {
3650
+ if (result.failed === 0 && message === "") notify(tpl(t("pdf.zoteroDone"), { n: result.saved }));
3651
+ else notify(tpl(t("pdf.zoteroPartial"), {
3332
3652
  saved: result.saved,
3333
3653
  failed: result.failed,
3334
3654
  message
@@ -3339,7 +3659,7 @@ function PaperPane({ t, useTabInfo, inputActions }) {
3339
3659
  });
3340
3660
  }).catch((err) => {
3341
3661
  const message = messageOf$3(err);
3342
- setToast(tpl(t("pdf.zoteroFailed"), { message }));
3662
+ notify(tpl(t("pdf.zoteroFailed"), { message }));
3343
3663
  setRisFallback({
3344
3664
  ids: [paper.paperId],
3345
3665
  message
@@ -3349,6 +3669,12 @@ function PaperPane({ t, useTabInfo, inputActions }) {
3349
3669
  const pdfStatus = state?.pdfStatus ?? (paper.pdfUrl === "" ? "unknown" : "available");
3350
3670
  const canPdf = paper.pdfUrl !== "" || paper.ids.pmcid !== "" || paper.ids.arxivId !== "" || paper.doi !== "";
3351
3671
  const canHtml = paper.ids.pmcid !== "";
3672
+ const downloaded = !downloading && state?.pdfStatus === "downloaded";
3673
+ const downloadedPath = downloaded ? state?.pdfFile : void 0;
3674
+ const pdfMissing = !downloading && state?.pdfStatus === "missing";
3675
+ const failedMessage = !downloading && state?.pdfStatus === "failed" ? state.pdfError : void 0;
3676
+ const downloadLabel = downloading ? t("pdf.downloading") : downloaded ? t("pdf.downloadedAction") : pdfMissing ? t("pdf.missingAction") : state?.pdfStatus === "failed" ? t("pdf.retry") : t("pdf.download");
3677
+ 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
3678
  const meta = [
3353
3679
  paper.authors.length === 0 ? "" : `${paper.authors.slice(0, 6).join(", ")}${paper.authors.length > 6 ? " et al." : ""}`,
3354
3680
  paper.year === null ? "" : String(paper.year),
@@ -3356,7 +3682,10 @@ function PaperPane({ t, useTabInfo, inputActions }) {
3356
3682
  ].filter((part) => part !== "").join(" · ");
3357
3683
  const groupName = detail.groups.find((group) => group.id === state?.groupId)?.name;
3358
3684
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3359
- style: panelStyle,
3685
+ style: {
3686
+ ...panelStyle,
3687
+ position: "relative"
3688
+ },
3360
3689
  children: [
3361
3690
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3362
3691
  style: {
@@ -3392,10 +3721,6 @@ function PaperPane({ t, useTabInfo, inputActions }) {
3392
3721
  style: badgeStyle,
3393
3722
  children: groupName
3394
3723
  }),
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
3724
  sources.map((source) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3400
3725
  style: badgeStyle,
3401
3726
  children: t(`source.${source}`)
@@ -3417,106 +3742,39 @@ function PaperPane({ t, useTabInfo, inputActions }) {
3417
3742
  })
3418
3743
  ]
3419
3744
  }),
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
3745
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3428
3746
  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
- })]
3747
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
3748
+ style: SELECT_STYLE$3,
3749
+ value: state?.screening ?? "unread",
3750
+ disabled: busy,
3751
+ "aria-label": t("filter.screening"),
3752
+ onChange: (event) => annotate({
3753
+ paperId: paper.paperId,
3754
+ screening: event.target.value
3491
3755
  }),
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
- })]
3756
+ children: SCREENINGS$3.map((value) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
3757
+ value,
3758
+ children: t(`screening.${value}`)
3759
+ }, value))
3760
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
3761
+ style: SELECT_STYLE$3,
3762
+ value: state?.groupId ?? "",
3763
+ disabled: busy,
3764
+ "aria-label": t("annotate.group"),
3765
+ title: detail.groups.length === 0 ? t("annotate.groupHint") : t("annotate.group"),
3766
+ onChange: (event) => annotate({
3767
+ paperId: paper.paperId,
3768
+ groupId: event.target.value === "" ? null : event.target.value
3504
3769
  }),
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
- })
3518
- })
3519
- ]
3770
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
3771
+ value: "",
3772
+ children: t("annotate.noGroup")
3773
+ }), detail.groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
3774
+ value: group.id,
3775
+ children: group.name
3776
+ }, group.id))]
3777
+ })]
3520
3778
  }),
3521
3779
  paper.abstract !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
3522
3780
  style: HEAD_STYLE$2,
@@ -3577,49 +3835,45 @@ function PaperPane({ t, useTabInfo, inputActions }) {
3577
3835
  paperId: paper.paperId,
3578
3836
  pmid: paper.ids.pmid
3579
3837
  })] }),
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
- })]
3838
+ showHtml && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PreviewFrame, {
3839
+ t,
3840
+ src: paperFullTextUrl(taskId, paper.paperId),
3841
+ height: 420,
3842
+ onClose: () => setShowHtml(false)
3595
3843
  }),
3596
- toast !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3597
- style: toastStyle,
3598
- children: toast
3844
+ (toast !== "" || risFallback !== null) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3845
+ style: toastStackStyle,
3846
+ children: [toast !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3847
+ role: "status",
3848
+ style: {
3849
+ ...toastStyle,
3850
+ ...toastTone === "error" ? toastErrorStyle : null
3851
+ },
3852
+ children: toast
3853
+ }), risFallback !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3854
+ style: FALLBACK_STYLE,
3855
+ children: [
3856
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3857
+ style: { minWidth: 0 },
3858
+ children: tpl(t("pdf.zoteroFallback"), { message: risFallback.message })
3859
+ }),
3860
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
3861
+ href: exportUrl(taskId, "ris", {}, risFallback.ids),
3862
+ download: true,
3863
+ style: FALLBACK_LINK_STYLE,
3864
+ children: t("pdf.zoteroFallbackLink")
3865
+ }),
3866
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3867
+ type: "button",
3868
+ style: FALLBACK_BUTTON_STYLE,
3869
+ onClick: () => setRisFallback(null),
3870
+ children: t("pdf.zoteroFallbackDismiss")
3871
+ })
3872
+ ]
3873
+ })]
3599
3874
  }),
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
- ]
3620
- }),
3621
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3622
- style: ACTION_STYLE,
3875
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3876
+ style: ACTION_STYLE,
3623
3877
  children: [
3624
3878
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
3625
3879
  variant: "ghost",
@@ -3628,23 +3882,23 @@ function PaperPane({ t, useTabInfo, inputActions }) {
3628
3882
  onClick: insert,
3629
3883
  children: t("result.insert")
3630
3884
  }),
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,
3885
+ canPdf && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadButton, {
3886
+ t,
3887
+ label: downloadLabel,
3888
+ hint: downloadHint,
3889
+ ...downloadedPath === void 0 ? {} : { downloadedPath },
3890
+ missing: pdfMissing,
3891
+ downloading,
3640
3892
  disabled: busy,
3641
- onClick: download,
3642
- children: t("pdf.download")
3893
+ style: linkButtonStyle,
3894
+ onDownload: download,
3895
+ ...onOpenLocal === void 0 ? {} : { onOpen: (path) => onOpenLocal(taskId, paper.paperId, path) },
3896
+ ...onRevealLocal === void 0 ? {} : { onReveal: onRevealLocal }
3643
3897
  }),
3644
3898
  canHtml && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3645
3899
  type: "button",
3646
3900
  style: linkButtonStyle,
3647
- onClick: () => setPreview((value) => value === "html" ? null : "html"),
3901
+ onClick: () => setShowHtml((value) => !value),
3648
3902
  children: t("pdf.fulltext")
3649
3903
  }),
3650
3904
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
@@ -3701,73 +3955,30 @@ const SELECT_STYLE$3 = {
3701
3955
  color: "inherit",
3702
3956
  maxWidth: 150
3703
3957
  };
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
3958
  const IDS_STYLE = {
3735
3959
  display: "flex",
3736
3960
  flexDirection: "column",
3737
3961
  gap: 3,
3738
3962
  fontSize: 12
3739
3963
  };
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
3964
  const FALLBACK_STYLE = {
3965
+ ...toastStyle,
3966
+ pointerEvents: "auto",
3761
3967
  display: "flex",
3762
3968
  alignItems: "center",
3763
3969
  flexWrap: "wrap",
3764
3970
  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
3971
+ borderLeft: "3px solid #b26a00"
3972
+ };
3973
+ const FALLBACK_LINK_STYLE = {
3974
+ color: "#fff",
3975
+ fontSize: 12,
3976
+ textDecoration: "underline"
3977
+ };
3978
+ const FALLBACK_BUTTON_STYLE = {
3979
+ ...linkButtonStyle,
3980
+ color: "#fff",
3981
+ textDecoration: "underline"
3771
3982
  };
3772
3983
  const ACTION_STYLE = {
3773
3984
  display: "flex",
@@ -4971,7 +5182,7 @@ function PaperRow({ paper }) {
4971
5182
  paper.venue
4972
5183
  ].filter((part) => part !== "").join(" · ");
4973
5184
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4974
- style: ROW_STYLE$3,
5185
+ style: ROW_STYLE$4,
4975
5186
  children: [
4976
5187
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4977
5188
  style: {
@@ -5038,7 +5249,7 @@ const LIST_STYLE$3 = {
5038
5249
  display: "flex",
5039
5250
  flexDirection: "column"
5040
5251
  };
5041
- const ROW_STYLE$3 = {
5252
+ const ROW_STYLE$4 = {
5042
5253
  display: "flex",
5043
5254
  flexDirection: "column",
5044
5255
  gap: 1,
@@ -5149,7 +5360,7 @@ function ConditionEditor({ t, detail, onSaved, onCancel }) {
5149
5360
  open: true,
5150
5361
  onClose: onCancel,
5151
5362
  title: t("condition.title"),
5152
- className: WIDE_MODAL_CLASS,
5363
+ className: WIDE_MODAL_CLASS$1,
5153
5364
  footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5154
5365
  style: {
5155
5366
  display: "flex",
@@ -5171,7 +5382,7 @@ function ConditionEditor({ t, detail, onSaved, onCancel }) {
5171
5382
  children: saving ? t("condition.saving") : t("condition.save")
5172
5383
  })]
5173
5384
  }),
5174
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: WIDE_MODAL_CSS }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5385
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: WIDE_MODAL_CSS$1 }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5175
5386
  style: BODY_STYLE,
5176
5387
  children: [
5177
5388
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -5213,7 +5424,7 @@ function ConditionEditor({ t, detail, onSaved, onCancel }) {
5213
5424
  infos: health?.sources
5214
5425
  }),
5215
5426
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5216
- style: ROW_STYLE$2,
5427
+ style: ROW_STYLE$3,
5217
5428
  children: [
5218
5429
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
5219
5430
  style: FIELD_STYLE$2,
@@ -5258,7 +5469,7 @@ function ConditionEditor({ t, detail, onSaved, onCancel }) {
5258
5469
  ]
5259
5470
  }),
5260
5471
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5261
- style: ROW_STYLE$2,
5472
+ style: ROW_STYLE$3,
5262
5473
  children: [
5263
5474
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
5264
5475
  style: CHECK_STYLE$2,
@@ -5334,16 +5545,16 @@ const BODY_STYLE = {
5334
5545
  overflowY: "auto"
5335
5546
  };
5336
5547
  /** 宿主 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))}
5548
+ const WIDE_MODAL_CLASS$1 = "litsearch-condition-modal";
5549
+ const WIDE_MODAL_CSS$1 = `
5550
+ .${WIDE_MODAL_CLASS$1}{width:min(680px,calc(100vw - 48px))}
5340
5551
  `;
5341
5552
  const QUERY_LIST_STYLE = {
5342
5553
  display: "flex",
5343
5554
  flexDirection: "column",
5344
5555
  gap: 6
5345
5556
  };
5346
- const ROW_STYLE$2 = {
5557
+ const ROW_STYLE$3 = {
5347
5558
  display: "flex",
5348
5559
  alignItems: "center",
5349
5560
  flexWrap: "wrap",
@@ -5377,6 +5588,249 @@ const CHECK_STYLE$2 = {
5377
5588
  gap: 4,
5378
5589
  fontSize: 12
5379
5590
  };
5591
+ // src/client/DownloadDialog.tsx
5592
+ const PHASE_COLOR = {
5593
+ pending: "rgba(127,127,127,0.9)",
5594
+ downloading: ACCENT,
5595
+ downloaded: "#2e9e5b",
5596
+ manual: "#b26a00",
5597
+ failed: ERROR
5598
+ };
5599
+ /** 单篇的展示状态:即时状态优先,其次回落到 Host 记录的文献状态。 */
5600
+ function phaseOf(paperId, states, downloads) {
5601
+ const runtime = downloads[paperId];
5602
+ if (runtime?.state === "failed" && isManualPdfFailure(runtime.reason)) return "manual";
5603
+ if (runtime?.state !== void 0) return runtime.state;
5604
+ const status = states[paperId]?.pdfStatus ?? "unknown";
5605
+ if (status === "downloaded") return "downloaded";
5606
+ if (status === "blocked" || status === "paywalled" || status === "html-only") return "manual";
5607
+ if (status === "failed" || status === "missing") return "failed";
5608
+ return "pending";
5609
+ }
5610
+ function errorOf(paperId, states, downloads) {
5611
+ return downloads[paperId]?.error ?? states[paperId]?.pdfError;
5612
+ }
5613
+ function DownloadDialog({ t, open, items, states, downloads, onClose }) {
5614
+ const phases = items.map((item) => phaseOf(item.paperId, states, downloads));
5615
+ const downloaded = phases.filter((phase) => phase === "downloaded").length;
5616
+ const downloading = phases.filter((phase) => phase === "downloading").length;
5617
+ const manual = phases.filter((phase) => phase === "manual").length;
5618
+ const failed = phases.filter((phase) => phase === "failed").length;
5619
+ const pending = phases.filter((phase) => phase === "pending").length;
5620
+ const total = items.length;
5621
+ const done = total - pending - downloading;
5622
+ const rate = total === 0 ? 0 : Math.round(done / total * 100);
5623
+ const complete = total > 0 && pending === 0 && downloading === 0;
5624
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
5625
+ open,
5626
+ onClose,
5627
+ title: t("batch.title"),
5628
+ description: tpl(t("batch.desc"), { n: total }),
5629
+ className: WIDE_MODAL_CLASS,
5630
+ footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5631
+ style: FOOTER_STYLE,
5632
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5633
+ style: statusStyle,
5634
+ children: tpl(t("batch.progress"), {
5635
+ done,
5636
+ total
5637
+ })
5638
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
5639
+ variant: "outline",
5640
+ size: "sm",
5641
+ type: "button",
5642
+ onClick: onClose,
5643
+ children: t("batch.close")
5644
+ })]
5645
+ }),
5646
+ children: [
5647
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: WIDE_MODAL_CSS }),
5648
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5649
+ style: OVERVIEW_STYLE,
5650
+ role: "status",
5651
+ "aria-live": "polite",
5652
+ children: [
5653
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5654
+ style: COUNTS_STYLE,
5655
+ children: [
5656
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: tpl(t("batch.total"), { n: total }) }),
5657
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5658
+ style: { color: PHASE_COLOR.downloaded },
5659
+ children: tpl(t("batch.downloaded"), { n: downloaded })
5660
+ }),
5661
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5662
+ style: { color: PHASE_COLOR.downloading },
5663
+ children: tpl(t("batch.downloading"), { n: downloading })
5664
+ }),
5665
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: tpl(t("batch.pending"), { n: pending }) }),
5666
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5667
+ style: { color: PHASE_COLOR.manual },
5668
+ children: tpl(t("batch.manual"), { n: manual })
5669
+ }),
5670
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5671
+ style: { color: PHASE_COLOR.failed },
5672
+ children: tpl(t("batch.failed"), { n: failed })
5673
+ })
5674
+ ]
5675
+ }),
5676
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5677
+ style: BAR_TRACK_STYLE,
5678
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { style: {
5679
+ ...BAR_FILL_STYLE$1,
5680
+ width: `${rate}%`
5681
+ } })
5682
+ }),
5683
+ complete && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5684
+ style: COMPLETE_STYLE,
5685
+ children: t("batch.complete")
5686
+ })
5687
+ ]
5688
+ }),
5689
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
5690
+ style: LIST_STYLE$2,
5691
+ children: items.map((item, index) => {
5692
+ const phase = phases[index] ?? "pending";
5693
+ const error = phase === "failed" || phase === "manual" ? errorOf(item.paperId, states, downloads) : void 0;
5694
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
5695
+ style: ROW_STYLE$2,
5696
+ children: [
5697
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5698
+ style: INDEX_STYLE,
5699
+ children: index + 1
5700
+ }),
5701
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5702
+ style: ROW_MAIN_STYLE$1,
5703
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5704
+ style: ROW_TITLE_STYLE$1,
5705
+ title: item.title,
5706
+ children: item.title === "" ? t("batch.untitled") : item.title
5707
+ }), error !== void 0 && error !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5708
+ style: ROW_ERROR_STYLE$1,
5709
+ title: error,
5710
+ children: error
5711
+ })]
5712
+ }),
5713
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5714
+ style: {
5715
+ ...STATUS_CHIP_STYLE,
5716
+ color: PHASE_COLOR[phase]
5717
+ },
5718
+ children: [phase === "downloading" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SpinnerIcon, {}), t(`batch.status.${phase}`)]
5719
+ })
5720
+ ]
5721
+ }, item.paperId);
5722
+ })
5723
+ })
5724
+ ]
5725
+ });
5726
+ }
5727
+ /** 批量下载弹窗的宽度约为默认弹窗的两倍(默认约 540px)。 */
5728
+ const WIDE_MODAL_CLASS = "litsearch-download-modal";
5729
+ const WIDE_MODAL_CSS = `
5730
+ .${WIDE_MODAL_CLASS}{width:min(1080px,calc(100vw - 48px))}
5731
+ `;
5732
+ const FOOTER_STYLE = {
5733
+ display: "flex",
5734
+ alignItems: "center",
5735
+ justifyContent: "space-between",
5736
+ gap: 8
5737
+ };
5738
+ const OVERVIEW_STYLE = {
5739
+ display: "flex",
5740
+ flexDirection: "column",
5741
+ gap: 6,
5742
+ marginBottom: 8,
5743
+ padding: "8px 10px",
5744
+ borderRadius: 8,
5745
+ border: "1px solid rgba(80,140,255,0.25)",
5746
+ background: "rgba(80,140,255,0.08)"
5747
+ };
5748
+ const COUNTS_STYLE = {
5749
+ display: "flex",
5750
+ flexWrap: "wrap",
5751
+ gap: 10,
5752
+ fontSize: 12,
5753
+ lineHeight: 1.5
5754
+ };
5755
+ const BAR_TRACK_STYLE = {
5756
+ height: 4,
5757
+ borderRadius: 2,
5758
+ background: "rgba(127,127,127,0.22)",
5759
+ overflow: "hidden"
5760
+ };
5761
+ const BAR_FILL_STYLE$1 = {
5762
+ height: "100%",
5763
+ borderRadius: 2,
5764
+ background: ACCENT,
5765
+ transition: "width 0.25s ease"
5766
+ };
5767
+ const COMPLETE_STYLE = {
5768
+ fontSize: 12,
5769
+ fontWeight: 650,
5770
+ color: "#2e9e5b"
5771
+ };
5772
+ const LIST_STYLE$2 = {
5773
+ listStyle: "none",
5774
+ margin: 0,
5775
+ padding: 0,
5776
+ maxHeight: "50vh",
5777
+ overflowY: "auto"
5778
+ };
5779
+ const ROW_STYLE$2 = {
5780
+ display: "flex",
5781
+ alignItems: "flex-start",
5782
+ gap: 8,
5783
+ padding: "7px 0",
5784
+ borderTop: "1px solid rgba(127,127,127,0.18)"
5785
+ };
5786
+ const INDEX_STYLE = {
5787
+ flexShrink: 0,
5788
+ minWidth: 18,
5789
+ fontSize: 11,
5790
+ lineHeight: "18px",
5791
+ opacity: .5,
5792
+ textAlign: "right"
5793
+ };
5794
+ const ROW_MAIN_STYLE$1 = {
5795
+ display: "flex",
5796
+ flexDirection: "column",
5797
+ gap: 2,
5798
+ flex: 1,
5799
+ minWidth: 0
5800
+ };
5801
+ const ROW_TITLE_STYLE$1 = {
5802
+ fontSize: 12.5,
5803
+ lineHeight: 1.45,
5804
+ fontWeight: 600,
5805
+ overflow: "hidden",
5806
+ display: "-webkit-box",
5807
+ WebkitLineClamp: 2,
5808
+ WebkitBoxOrient: "vertical",
5809
+ wordBreak: "break-word"
5810
+ };
5811
+ const ROW_ERROR_STYLE$1 = {
5812
+ fontSize: 11,
5813
+ lineHeight: 1.4,
5814
+ color: ERROR,
5815
+ opacity: .85,
5816
+ overflow: "hidden",
5817
+ display: "-webkit-box",
5818
+ WebkitLineClamp: 2,
5819
+ WebkitBoxOrient: "vertical",
5820
+ wordBreak: "break-word"
5821
+ };
5822
+ const STATUS_CHIP_STYLE = {
5823
+ display: "inline-flex",
5824
+ alignItems: "center",
5825
+ gap: 3,
5826
+ flexShrink: 0,
5827
+ padding: "1px 6px",
5828
+ borderRadius: 4,
5829
+ border: "1px solid currentColor",
5830
+ fontSize: 11,
5831
+ lineHeight: 1.5,
5832
+ whiteSpace: "nowrap"
5833
+ };
5380
5834
  // src/client/FullTextTab.tsx
5381
5835
  /**
5382
5836
  * 「全文」台账:全任务口径的覆盖率 + 可按状态切换的补抓队列。
@@ -5405,7 +5859,7 @@ const EMPTY_LIST = {
5405
5859
  function viewQuery(view) {
5406
5860
  return view === "missing" ? { missingFullText: true } : { pdfStatus: view };
5407
5861
  }
5408
- function FullTextTab({ t, taskId, rev, states, stats, onDownload, onZotero, onBatchDownload, onBatchZotero, onShowInResults }) {
5862
+ function FullTextTab({ t, taskId, rev, states, stats, onDownload, onOpenLocal, onRevealLocal, onZotero, onBatchDownload, onBatchZotero, onShowInResults }) {
5409
5863
  const [view, setView] = (0, react.useState)("missing");
5410
5864
  const [list, setList] = (0, react.useState)(EMPTY_LIST);
5411
5865
  const [selected, setSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
@@ -5484,7 +5938,7 @@ function FullTextTab({ t, taskId, rev, states, stats, onDownload, onZotero, onBa
5484
5938
  });
5485
5939
  const coverage = stats?.fullText;
5486
5940
  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;
5941
+ const missingCount = coverage === void 0 ? 0 : coverage.unknown + coverage.missing + coverage.paywalled + coverage.blocked + coverage.failed;
5488
5942
  const selectedPapers = list.papers.filter((paper) => selected.has(paper.paperId));
5489
5943
  const viewLabel = view === "missing" ? t("fulltext.missing") : t(`pdf.status.${view}`);
5490
5944
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -5555,15 +6009,18 @@ function FullTextTab({ t, taskId, rev, states, stats, onDownload, onZotero, onBa
5555
6009
  style: TEXT_STYLE$1,
5556
6010
  children: t("fulltext.none")
5557
6011
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
5558
- style: LIST_STYLE$2,
6012
+ style: LIST_STYLE$1,
5559
6013
  children: list.papers.map((paper) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FullTextRow, {
5560
6014
  t,
5561
6015
  paper,
5562
6016
  selected: selected.has(paper.paperId),
5563
6017
  status: paperStatus(states, paper),
5564
6018
  error: states[paper.paperId]?.pdfError,
6019
+ downloadedPath: states[paper.paperId]?.pdfFile,
5565
6020
  onToggleSelect: toggleSelect,
5566
6021
  onDownload,
6022
+ ...onOpenLocal === void 0 ? {} : { onOpenLocal: (path) => onOpenLocal(taskId, paper.paperId, path) },
6023
+ ...onRevealLocal === void 0 ? {} : { onRevealLocal },
5567
6024
  onZotero
5568
6025
  }, paper.paperId))
5569
6026
  }),
@@ -5615,7 +6072,9 @@ function BulkBar$1({ t, count, onSelectPage, onClear, onDownload, onZotero }) {
5615
6072
  ]
5616
6073
  });
5617
6074
  }
5618
- function FullTextRow({ t, paper, selected, status, error, onToggleSelect, onDownload, onZotero }) {
6075
+ function FullTextRow({ t, paper, selected, status, error, downloadedPath, onToggleSelect, onDownload, onOpenLocal, onRevealLocal, onZotero }) {
6076
+ const downloaded = status === "downloaded" && downloadedPath !== void 0;
6077
+ const pdfMissing = status === "missing";
5619
6078
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
5620
6079
  style: ROW_STYLE$1,
5621
6080
  children: [
@@ -5659,11 +6118,18 @@ function FullTextRow({ t, paper, selected, status, error, onToggleSelect, onDown
5659
6118
  }),
5660
6119
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5661
6120
  style: ROW_ACTIONS_STYLE,
5662
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5663
- type: "button",
6121
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadButton, {
6122
+ t,
6123
+ label: downloaded ? t("pdf.downloadedAction") : pdfMissing ? t("pdf.missingAction") : t("pdf.download"),
6124
+ ...downloaded ? {
6125
+ downloadedPath,
6126
+ hint: tpl(t("pdf.downloadedHint"), { path: downloadedPath ?? "" })
6127
+ } : pdfMissing ? { hint: t("pdf.missingHint") } : {},
6128
+ missing: pdfMissing,
5664
6129
  style: LINK_STYLE,
5665
- onClick: () => onDownload(paper),
5666
- children: t("pdf.download")
6130
+ onDownload: () => onDownload(paper),
6131
+ ...onOpenLocal === void 0 ? {} : { onOpen: onOpenLocal },
6132
+ ...onRevealLocal === void 0 ? {} : { onReveal: onRevealLocal }
5667
6133
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5668
6134
  type: "button",
5669
6135
  style: LINK_STYLE,
@@ -5754,7 +6220,7 @@ const MORE_STYLE = {
5754
6220
  fontSize: 11,
5755
6221
  cursor: "pointer"
5756
6222
  };
5757
- const LIST_STYLE$2 = {
6223
+ const LIST_STYLE$1 = {
5758
6224
  margin: 0,
5759
6225
  padding: 0,
5760
6226
  listStyle: "none"
@@ -5802,19 +6268,18 @@ const ROW_ACTIONS_STYLE = {
5802
6268
  /**
5803
6269
  * 任务「分组」tab:维护「为什么读」的分类,并把文献归入其中。
5804
6270
  *
5805
- * 分组是任务内概念;归组在结果卡片的标注区完成,这里负责增删改名、排序。
6271
+ * 分组是任务内概念;归组在结果卡片的分组下拉里完成,这里负责增删改名、排序。
5806
6272
  * 增删与排序立即保存,名称和说明在输入框失焦时保存——切到结果页或别处不会丢改动。
5807
6273
  */
5808
6274
  function draftOf(groups) {
5809
6275
  return groups.map((group) => ({ ...group }));
5810
6276
  }
5811
- function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
6277
+ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup, onNotify }) {
5812
6278
  const [draft, setDraft] = (0, react.useState)(() => draftOf(detail.groups));
5813
6279
  const [name, setName] = (0, react.useState)("");
5814
6280
  const [description, setDescription] = (0, react.useState)("");
5815
6281
  const [busy, setBusy] = (0, react.useState)(false);
5816
6282
  const [error, setError] = (0, react.useState)(null);
5817
- const [toast, setToast] = (0, react.useState)("");
5818
6283
  const latest = (0, react.useRef)(draft);
5819
6284
  const queued = (0, react.useRef)(false);
5820
6285
  const saving = (0, react.useRef)(false);
@@ -5836,14 +6301,13 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5836
6301
  saving.current = true;
5837
6302
  setBusy(true);
5838
6303
  setError(null);
5839
- setToast("");
5840
6304
  (async () => {
5841
6305
  try {
5842
6306
  while (queued.current) {
5843
6307
  queued.current = false;
5844
6308
  await updateTask(taskId, { groups: latest.current.map((group) => ({ ...group })) });
5845
6309
  }
5846
- setToast(t("groups.saved"));
6310
+ onNotify(t("groups.saved"));
5847
6311
  onSaved();
5848
6312
  } catch (reason) {
5849
6313
  setError(reason instanceof Error ? reason.message : String(reason));
@@ -5855,7 +6319,8 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5855
6319
  }, [
5856
6320
  taskId,
5857
6321
  t,
5858
- onSaved
6322
+ onSaved,
6323
+ onNotify
5859
6324
  ]);
5860
6325
  const add = () => {
5861
6326
  const trimmed = name.trim();
@@ -5908,7 +6373,7 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5908
6373
  style: LABEL_STYLE,
5909
6374
  children: t("groups.name")
5910
6375
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5911
- style: INPUT_STYLE$2,
6376
+ style: INPUT_STYLE$1,
5912
6377
  value: name,
5913
6378
  placeholder: t("groups.namePlaceholder"),
5914
6379
  "aria-label": t("groups.name"),
@@ -5927,7 +6392,7 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5927
6392
  style: LABEL_STYLE,
5928
6393
  children: t("groups.desc")
5929
6394
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5930
- style: INPUT_STYLE$2,
6395
+ style: INPUT_STYLE$1,
5931
6396
  value: description,
5932
6397
  placeholder: t("groups.descPlaceholder"),
5933
6398
  "aria-label": t("groups.desc"),
@@ -5953,7 +6418,7 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5953
6418
  style: statusStyle,
5954
6419
  children: t("groups.empty")
5955
6420
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5956
- style: LIST_STYLE$1,
6421
+ style: LIST_STYLE,
5957
6422
  children: draft.map((group, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5958
6423
  style: GROUP_ROW_STYLE,
5959
6424
  children: [
@@ -5961,7 +6426,7 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5961
6426
  style: GROUP_HEAD_STYLE,
5962
6427
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5963
6428
  style: {
5964
- ...INPUT_STYLE$2,
6429
+ ...INPUT_STYLE$1,
5965
6430
  flex: 1,
5966
6431
  fontWeight: 600
5967
6432
  },
@@ -6024,15 +6489,12 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
6024
6489
  ]
6025
6490
  }, group.id))
6026
6491
  }),
6027
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6492
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6028
6493
  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", {
6494
+ children: busy && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6033
6495
  style: statusStyle,
6034
6496
  children: t("groups.saving")
6035
- })]
6497
+ })
6036
6498
  }),
6037
6499
  error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6038
6500
  style: {
@@ -6071,7 +6533,7 @@ const LABEL_STYLE = {
6071
6533
  opacity: .7,
6072
6534
  whiteSpace: "nowrap"
6073
6535
  };
6074
- const INPUT_STYLE$2 = {
6536
+ const INPUT_STYLE$1 = {
6075
6537
  fontSize: 12,
6076
6538
  padding: "3px 5px",
6077
6539
  borderRadius: 4,
@@ -6081,13 +6543,13 @@ const INPUT_STYLE$2 = {
6081
6543
  minWidth: 0,
6082
6544
  flex: "1 1 120px"
6083
6545
  };
6084
- const LIST_STYLE$1 = {
6546
+ const LIST_STYLE = {
6085
6547
  display: "flex",
6086
6548
  flexDirection: "column",
6087
6549
  gap: 6
6088
6550
  };
6089
6551
  const ROW_INPUT_STYLE = {
6090
- ...INPUT_STYLE$2,
6552
+ ...INPUT_STYLE$1,
6091
6553
  flex: "0 0 auto"
6092
6554
  };
6093
6555
  const GROUP_ROW_STYLE = {
@@ -6113,55 +6575,25 @@ const ACTION_ROW_STYLE$1 = {
6113
6575
  gap: 10,
6114
6576
  flexWrap: "wrap"
6115
6577
  };
6116
- const TOAST_STYLE$1 = {
6117
- fontSize: 12,
6118
- color: "#2e9e5b"
6119
- };
6120
6578
  // src/client/NotesTab.tsx
6121
6579
  /**
6122
- * 任务「笔记」tab:任务级研究笔记 + 逐篇备注/标签汇总。
6580
+ * 任务「笔记」tab:任务级研究笔记。
6123
6581
  *
6124
- * 逐篇备注通过 /results 取回当前任务前 200 篇的标题做映射;任务笔记直接写回
6125
- * `updateTask({notes})`,与模型工具看到的是同一份数据。
6582
+ * 任务笔记直接写回 `updateTask({notes})`,与模型工具看到的是同一份数据。
6126
6583
  */
6127
- const NOTE_PAGE_LIMIT = 200;
6128
- function NotesTab({ t, taskId, detail, onSaved }) {
6584
+ function NotesTab({ t, taskId, detail, onSaved, onNotify }) {
6129
6585
  const [notes, setNotes] = (0, react.useState)(detail.notes);
6130
6586
  const [busy, setBusy] = (0, react.useState)(false);
6131
- const [toast, setToast] = (0, react.useState)("");
6132
6587
  const [error, setError] = (0, react.useState)(null);
6133
- const [titles, setTitles] = (0, react.useState)({});
6134
- const [loadingTitles, setLoadingTitles] = (0, react.useState)(true);
6135
6588
  (0, react.useEffect)(() => {
6136
6589
  setNotes(detail.notes);
6137
6590
  }, [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
6591
  const save = () => {
6160
6592
  if (busy) return;
6161
6593
  setBusy(true);
6162
6594
  setError(null);
6163
6595
  updateTask(taskId, { notes }).then(() => {
6164
- setToast(t("notes.saved"));
6596
+ onNotify(t("notes.saved"));
6165
6597
  onSaved();
6166
6598
  }).catch((reason) => {
6167
6599
  setError(reason instanceof Error ? reason.message : String(reason));
@@ -6169,9 +6601,9 @@ function NotesTab({ t, taskId, detail, onSaved }) {
6169
6601
  setBusy(false);
6170
6602
  });
6171
6603
  };
6172
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6604
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6173
6605
  style: WRAP_STYLE$2,
6174
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [
6606
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [
6175
6607
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
6176
6608
  style: HEAD_STYLE,
6177
6609
  children: t("notes.title")
@@ -6183,19 +6615,16 @@ function NotesTab({ t, taskId, detail, onSaved }) {
6183
6615
  placeholder: t("notes.taskPlaceholder"),
6184
6616
  onChange: (event) => setNotes(event.target.value)
6185
6617
  }),
6186
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6618
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6187
6619
  style: ACTION_ROW_STYLE,
6188
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
6620
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
6189
6621
  variant: "outline",
6190
6622
  size: "sm",
6191
6623
  type: "button",
6192
6624
  disabled: busy || notes === detail.notes,
6193
6625
  onClick: save,
6194
6626
  children: t("notes.save")
6195
- }), toast !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6196
- style: TOAST_STYLE,
6197
- children: toast
6198
- })]
6627
+ })
6199
6628
  }),
6200
6629
  error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6201
6630
  style: {
@@ -6204,42 +6633,7 @@ function NotesTab({ t, taskId, detail, onSaved }) {
6204
6633
  },
6205
6634
  children: error
6206
6635
  })
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
- ] })]
6636
+ ] })
6243
6637
  });
6244
6638
  }
6245
6639
  const WRAP_STYLE$2 = {
@@ -6272,35 +6666,6 @@ const ACTION_ROW_STYLE = {
6272
6666
  gap: 8,
6273
6667
  marginTop: 6
6274
6668
  };
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
6669
  // src/client/OverviewTab.tsx
6305
6670
  /**
6306
6671
  * 任务「概览」tab:一眼看清本轮检索发生了什么。
@@ -6349,6 +6714,11 @@ const FULLTEXT_STATUSES = [
6349
6714
  key: "downloaded",
6350
6715
  color: C.green
6351
6716
  },
6717
+ {
6718
+ status: "missing",
6719
+ key: "missing",
6720
+ color: "#d97706"
6721
+ },
6352
6722
  {
6353
6723
  status: "html-only",
6354
6724
  key: "htmlOnly",
@@ -7685,8 +8055,7 @@ const QUICK_FILTER_STYLE$1 = {
7685
8055
  /**
7686
8056
  * 任务结果里的单篇文献卡片。
7687
8057
  *
7688
- * 标题可点外链;徽章展示命中来源、被引、开放获取。筛读状态与分组就地修改,
7689
- * 备注 / 标签 / 必读顺序收在「标注」里,避免窄栏里堆满输入框。
8058
+ * 标题可点外链;徽章展示命中来源、被引、开放获取。筛读状态与分组就地修改。
7690
8059
  */
7691
8060
  const ABSTRACT_PREVIEW = 260;
7692
8061
  /** 分组下拉里「去新建分组」的哨兵值,不会与真实 group id 冲突。 */
@@ -7697,32 +8066,18 @@ const SCREENINGS$2 = [
7697
8066
  "included",
7698
8067
  "excluded"
7699
8068
  ];
8069
+ /** 出版商明确要求订阅 / 拒绝访问时,不再提供「下载 PDF」,避免必然失败的操作。 */
8070
+ const NO_PDF_STATUSES = /* @__PURE__ */ new Set(["paywalled", "blocked"]);
7700
8071
  const SCREENING_COLOR = {
7701
8072
  unread: "rgba(127,127,127,0.9)",
7702
8073
  read: "#508cff",
7703
8074
  included: "#2e9e5b",
7704
8075
  excluded: "#d64545"
7705
8076
  };
7706
- function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, onToggleSelect, onInsert, onAnnotate, onDownload, onZotero, onOpenPaper, onManageGroups }) {
8077
+ function PaperCard({ paper, t, taskId, state, groups, busy, downloadDir, download, onOpenLocal, onRevealLocal, selected = false, onToggleSelect, onInsert, onAnnotate, onDownload, onZotero, onOpenPaper, onManageGroups }) {
7707
8078
  const [expanded, setExpanded] = (0, react.useState)(false);
7708
- const [editing, setEditing] = (0, react.useState)(false);
7709
8079
  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
- ]);
8080
+ const [showHtml, setShowHtml] = (0, react.useState)(false);
7726
8081
  const meta = [
7727
8082
  paper.authors.length === 0 ? "" : `${paper.authors.slice(0, 3).join(", ")}${paper.authors.length > 3 ? " et al." : ""}`,
7728
8083
  paper.year === null ? "" : String(paper.year),
@@ -7733,22 +8088,16 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
7733
8088
  const abstract = expanded || !canCollapse ? paper.abstract : `${paper.abstract.slice(0, ABSTRACT_PREVIEW)}…`;
7734
8089
  const screening = state?.screening ?? "unread";
7735
8090
  const groupName = groups.find((group) => group.id === state?.groupId)?.name;
7736
- const hasNote = (state?.note ?? "") !== "" || (state?.tags ?? []).length > 0;
7737
8091
  const pdfStatus = state?.pdfStatus ?? (paper.pdfUrl === "" ? "unknown" : "available");
7738
- const canPdf = paper.pdfUrl !== "" || paper.ids.pmcid !== "" || paper.ids.arxivId !== "" || paper.doi !== "";
8092
+ const canPdf = !NO_PDF_STATUSES.has(pdfStatus) && (paper.pdfUrl !== "" || paper.ids.pmcid !== "" || paper.ids.arxivId !== "" || paper.doi !== "");
7739
8093
  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
- };
8094
+ const downloading = download?.state === "downloading";
8095
+ const pdfMissing = !downloading && pdfStatus === "missing";
8096
+ const downloaded = !pdfMissing && (download?.state === "downloaded" || pdfStatus === "downloaded" && !downloading);
8097
+ const downloadedPath = downloaded ? download?.path ?? state?.pdfFile : void 0;
8098
+ const failedMessage = download?.state === "failed" ? download.error : pdfStatus === "failed" ? state?.pdfError : void 0;
8099
+ const pdfLabel = downloading ? t("pdf.downloading") : downloaded ? t("pdf.downloadedAction") : pdfMissing ? t("pdf.missingAction") : pdfStatus === "failed" ? t("pdf.retry") : t("pdf.download");
8100
+ 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") });
7752
8101
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("article", {
7753
8102
  style: cardStyle,
7754
8103
  children: [
@@ -7807,63 +8156,6 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
7807
8156
  children: t("annotate.createGroup")
7808
8157
  })
7809
8158
  ]
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
8159
  })
7868
8160
  ]
7869
8161
  }),
@@ -7931,10 +8223,6 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
7931
8223
  })] })]
7932
8224
  })]
7933
8225
  }),
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
8226
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7939
8227
  style: CARD_ROW_STYLE,
7940
8228
  title: t("result.infoHint"),
@@ -7959,10 +8247,6 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
7959
8247
  style: INFO_BADGE_STYLE,
7960
8248
  children: groupName
7961
8249
  }),
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
8250
  sources.map((source) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7967
8251
  style: INFO_BADGE_STYLE,
7968
8252
  children: t(`source.${source}`)
@@ -7980,6 +8264,7 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
7980
8264
  ...INFO_BADGE_STYLE,
7981
8265
  color: PDF_COLOR[pdfStatus]
7982
8266
  },
8267
+ title: state?.pdfError,
7983
8268
  children: t(`pdf.status.${pdfStatus}`)
7984
8269
  })
7985
8270
  ]
@@ -8020,23 +8305,23 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
8020
8305
  onClick: () => setEntities((value) => !value),
8021
8306
  children: t("entity.title")
8022
8307
  }),
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,
8308
+ canPdf && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadButton, {
8309
+ t,
8310
+ label: pdfLabel,
8311
+ hint: pdfHint,
8312
+ ...downloadedPath === void 0 ? {} : { downloadedPath },
8313
+ missing: pdfMissing,
8314
+ downloading,
8032
8315
  disabled: busy,
8033
- onClick: () => onDownload(paper),
8034
- children: t("pdf.download")
8316
+ style: linkButtonStyle,
8317
+ onDownload: () => onDownload(paper),
8318
+ ...onOpenLocal === void 0 ? {} : { onOpen: (path) => onOpenLocal(taskId, paper.paperId, path) },
8319
+ ...onRevealLocal === void 0 ? {} : { onReveal: onRevealLocal }
8035
8320
  }),
8036
8321
  canHtml && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
8037
8322
  type: "button",
8038
8323
  style: linkButtonStyle,
8039
- onClick: () => setPreview((value) => value === "html" ? null : "html"),
8324
+ onClick: () => setShowHtml((value) => !value),
8040
8325
  children: t("pdf.fulltext")
8041
8326
  }),
8042
8327
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
@@ -8069,21 +8354,11 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
8069
8354
  paperId: paper.paperId,
8070
8355
  pmid: paper.ids.pmid
8071
8356
  }),
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
- })]
8357
+ showHtml && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PreviewFrame, {
8358
+ t,
8359
+ src: paperFullTextUrl(taskId, paper.paperId),
8360
+ height: 360,
8361
+ onClose: () => setShowHtml(false)
8087
8362
  })
8088
8363
  ]
8089
8364
  });
@@ -8092,39 +8367,13 @@ const PDF_COLOR = {
8092
8367
  unknown: "rgba(127,127,127,0.8)",
8093
8368
  available: "#508cff",
8094
8369
  downloaded: "#2e9e5b",
8370
+ missing: "#d64545",
8095
8371
  "html-only": "#b26a00",
8096
8372
  paywalled: "#b26a00",
8097
8373
  blocked: "#d64545",
8098
8374
  failed: "#d64545"
8099
8375
  };
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
- /** 卡片顶部工具行:勾选 / 筛读 / 分组 / 标注。 */
8376
+ /** 卡片顶部工具行:勾选 / 筛读 / 分组。 */
8128
8377
  const STATE_ROW_STYLE = {
8129
8378
  display: "flex",
8130
8379
  alignItems: "center",
@@ -8195,37 +8444,6 @@ const SELECT_STYLE = {
8195
8444
  color: "inherit",
8196
8445
  maxWidth: 130
8197
8446
  };
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",
8217
- borderRadius: 4,
8218
- border: "1px solid rgba(127,127,127,0.28)",
8219
- background: "transparent",
8220
- color: "inherit",
8221
- minWidth: 0
8222
- };
8223
- const NOTE_STYLE = {
8224
- ...INPUT_STYLE$1,
8225
- resize: "vertical",
8226
- fontFamily: "inherit",
8227
- lineHeight: 1.5
8228
- };
8229
8447
  // src/client/RoundsDialog.tsx
8230
8448
  /**
8231
8449
  * 任务内「检索轮次」时间轴弹窗。
@@ -9250,15 +9468,33 @@ function formatDate(value) {
9250
9468
  const date = new Date(value);
9251
9469
  return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
9252
9470
  }
9253
- function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onCloseTask }) {
9471
+ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onCloseTask, onOpenLocal, onRevealLocal }) {
9254
9472
  const info = useTabInfo();
9255
9473
  const taskId = parseTaskId(info);
9256
9474
  const [filters, setFilters] = (0, react.useState)({});
9257
9475
  const [tab, setTab] = (0, react.useState)("results");
9258
9476
  const view = useTask(taskId, info.tab.signal, 20, filters);
9259
9477
  const bodyRef = (0, react.useRef)(null);
9260
- const [toast, setToast] = (0, react.useState)("");
9478
+ const [toast, setToastState] = (0, react.useState)("");
9479
+ const [toastTone, setToastTone] = (0, react.useState)("info");
9480
+ const [toastMs, setToastMs] = (0, react.useState)(4e3);
9481
+ /** 统一的底部浮层提示:tone 区分普通结果与下载失败。 */
9482
+ const notify = (text, tone = "info", ms = 4e3) => {
9483
+ setToastState(text);
9484
+ setToastTone(tone);
9485
+ setToastMs(ms);
9486
+ };
9487
+ /** paperId → 下载状态;刷新任务后仍保留「下载中 / 已下载」的即时反馈。 */
9488
+ const [downloads, setDownloads] = (0, react.useState)({});
9489
+ /** 批量下载弹窗:只集中展示,真实状态仍写在 downloads / 任务详情的 states 里。 */
9490
+ const [batchItems, setBatchItems] = (0, react.useState)([]);
9491
+ const [batchOpen, setBatchOpen] = (0, react.useState)(false);
9492
+ const [batchRunning, setBatchRunning] = (0, react.useState)(false);
9493
+ const batchRunningRef = (0, react.useRef)(false);
9261
9494
  const [selected, setSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
9495
+ /** 结果页里见过的文献:跨页选中的条目做「加入对话」时要能拿到论文详情。 */
9496
+ const papersByIdRef = (0, react.useRef)(/* @__PURE__ */ new Map());
9497
+ const ids = [...selected];
9262
9498
  const [editingConditions, setEditingConditions] = (0, react.useState)(false);
9263
9499
  const [risFallback, setRisFallback] = (0, react.useState)(null);
9264
9500
  const [showRounds, setShowRounds] = (0, react.useState)(false);
@@ -9267,10 +9503,28 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9267
9503
  const [overview, setOverview] = (0, react.useState)(null);
9268
9504
  const detail = view.detail;
9269
9505
  const filterActive = (0, react.useMemo)(() => activeFilterCount(filters) > 0, [filters]);
9270
- const filterKey = JSON.stringify(filters);
9506
+ const selectionScope = JSON.stringify({
9507
+ ...filters,
9508
+ sort: void 0
9509
+ });
9510
+ const downloadsRef = (0, react.useRef)(downloads);
9511
+ downloadsRef.current = downloads;
9512
+ const statesRef = (0, react.useRef)({});
9513
+ statesRef.current = detail?.states ?? {};
9271
9514
  (0, react.useEffect)(() => {
9272
9515
  setSelected(/* @__PURE__ */ new Set());
9273
- }, [taskId, filterKey]);
9516
+ }, [taskId, selectionScope]);
9517
+ (0, react.useEffect)(() => {
9518
+ papersByIdRef.current.clear();
9519
+ }, [taskId]);
9520
+ (0, react.useEffect)(() => {
9521
+ for (const paper of view.page?.papers ?? []) papersByIdRef.current.set(paper.paperId, paper);
9522
+ }, [view.page]);
9523
+ (0, react.useEffect)(() => {
9524
+ setDownloads({});
9525
+ setBatchItems([]);
9526
+ setBatchOpen(false);
9527
+ }, [taskId]);
9274
9528
  (0, react.useEffect)(() => {
9275
9529
  setEditingConditions(false);
9276
9530
  setRisFallback(null);
@@ -9293,43 +9547,83 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9293
9547
  }, [taskId]);
9294
9548
  (0, react.useEffect)(() => {
9295
9549
  if (toast === "") return void 0;
9296
- const timer = setTimeout(() => setToast(""), 2400);
9550
+ const timer = setTimeout(() => notify(""), toastMs);
9297
9551
  return () => clearTimeout(timer);
9298
- }, [toast]);
9552
+ }, [toast, toastMs]);
9299
9553
  (0, react.useEffect)(() => {
9300
9554
  bodyRef.current?.scrollTo({ top: 0 });
9301
9555
  }, [view.pageIndex, tab]);
9302
9556
  const insert = (paper) => {
9303
9557
  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"));
9558
+ if (outcome === "inserted") notify(t("result.inserted"));
9559
+ else notify(t(outcome === "no-editor" ? "error.noEditor" : "error.draftChanged"));
9306
9560
  };
9561
+ /** 跨页选中也要整批插入:优先用翻页时缓存的论文,缺的再按 id 补详情。 */
9307
9562
  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"));
9563
+ if (taskId === null || ids.length === 0) return;
9564
+ const known = papersByIdRef.current;
9565
+ const finish = () => {
9566
+ const papers = ids.map((paperId) => known.get(paperId)).filter((paper) => paper !== void 0);
9567
+ if (papers.length === 0) return;
9568
+ const outcome = insertQuote(inputActions, papersText(t, papers), papersSummary(t, papers));
9569
+ if (outcome === "inserted") notify(tpl(t("bulk.inserted"), { n: papers.length }));
9570
+ else notify(t(outcome === "no-editor" ? "error.noEditor" : "error.draftChanged"));
9571
+ };
9572
+ const missing = ids.filter((paperId) => !known.has(paperId));
9573
+ if (missing.length === 0) {
9574
+ finish();
9575
+ return;
9576
+ }
9577
+ Promise.all(missing.map((paperId) => fetchPaper(taskId, paperId).then((loaded) => {
9578
+ known.set(paperId, loaded.paper);
9579
+ }).catch(() => void 0))).then(finish);
9315
9580
  };
9316
9581
  /** 把任务 id 与当前条件带入会话草稿,新会话也能续接同一任务。 */
9317
9582
  const insertTask = () => {
9318
9583
  if (detail === null) return;
9319
9584
  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"));
9585
+ if (outcome === "inserted") notify(t("task.inserted"));
9586
+ else notify(t(outcome === "no-editor" ? "error.noEditor" : "error.draftChanged"));
9322
9587
  };
9323
9588
  const downloadPaper = (paper) => {
9324
9589
  if (taskId === null) return;
9590
+ const paperId = paper.paperId;
9591
+ setDownloads((previous) => ({
9592
+ ...previous,
9593
+ [paperId]: { state: "downloading" }
9594
+ }));
9325
9595
  (async () => {
9326
9596
  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 }));
9597
+ const result = await downloadPaperPdf(taskId, paperId);
9598
+ if (result.status === "downloaded") {
9599
+ setDownloads((previous) => ({
9600
+ ...previous,
9601
+ [paperId]: {
9602
+ state: "downloaded",
9603
+ ...result.path === void 0 ? {} : { path: result.path }
9604
+ }
9605
+ }));
9606
+ notify(tpl(t("pdf.downloaded"), { name: result.filename ?? "" }), "info", 6e3);
9607
+ } else {
9608
+ setDownloads((previous) => ({
9609
+ ...previous,
9610
+ [paperId]: {
9611
+ state: "failed",
9612
+ error: result.error ?? result.status,
9613
+ ...result.reason === void 0 ? {} : { reason: result.reason }
9614
+ }
9615
+ }));
9616
+ notify(result.status === "html-only" ? tpl(t("pdf.htmlFallback"), { message: result.error ?? "" }) : tpl(t("pdf.error"), { message: result.error ?? result.status }), "error", 8e3);
9617
+ }
9331
9618
  } catch (error) {
9332
- setToast(tpl(t("pdf.error"), { message: messageOf(error) }));
9619
+ setDownloads((previous) => ({
9620
+ ...previous,
9621
+ [paperId]: {
9622
+ state: "failed",
9623
+ error: messageOf(error)
9624
+ }
9625
+ }));
9626
+ notify(tpl(t("pdf.error"), { message: messageOf(error) }), "error", 8e3);
9333
9627
  } finally {
9334
9628
  view.reload();
9335
9629
  }
@@ -9338,14 +9632,14 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9338
9632
  const zoteroPaper = (paper) => {
9339
9633
  if (taskId === null) return;
9340
9634
  syncZotero(taskId, [paper.paperId]).then((result) => {
9341
- setToast(zoteroToast(t, result.saved, result.failed, result.messages));
9635
+ notify(zoteroToast(t, result.saved, result.failed, result.messages));
9342
9636
  if (result.failed > 0) setRisFallback({
9343
9637
  ids: [paper.paperId],
9344
9638
  message: result.messages[0] ?? t("pdf.zoteroFailed")
9345
9639
  });
9346
9640
  }).catch((error) => {
9347
9641
  const message = messageOf(error);
9348
- setToast(tpl(t("pdf.zoteroFailed"), { message }));
9642
+ notify(tpl(t("pdf.zoteroFailed"), { message }));
9349
9643
  setRisFallback({
9350
9644
  ids: [paper.paperId],
9351
9645
  message
@@ -9360,7 +9654,17 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9360
9654
  return next;
9361
9655
  });
9362
9656
  };
9363
- const ids = [...selected];
9657
+ /** 本页全选用并集:翻页可累加,不会把其它页已选的文献冲掉。 */
9658
+ const selectPage = (pageIds) => {
9659
+ if (pageIds.length === 0) return;
9660
+ setSelected((previous) => {
9661
+ const next = new Set(previous);
9662
+ for (const paperId of pageIds) next.add(paperId);
9663
+ return next;
9664
+ });
9665
+ };
9666
+ /** 取消全选:清空所有已选(含其它页)。 */
9667
+ const clearSelection = () => setSelected(/* @__PURE__ */ new Set());
9364
9668
  const annotateMany = (patch) => {
9365
9669
  if (ids.length === 0) return;
9366
9670
  view.annotate(ids.map((paperId) => ({
@@ -9380,44 +9684,129 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9380
9684
  })));
9381
9685
  })();
9382
9686
  };
9383
- /** 结果页与全文页共用同一套批量下载 / Zotero 逻辑。 */
9384
- const downloadIds = (paperIds) => {
9385
- if (taskId === null || paperIds.length === 0) return;
9687
+ /**
9688
+ * 批量下载:打开进度弹窗后按最多 5 篇并发下载。
9689
+ *
9690
+ * 已下载(含文件已删除以外的正常已下载)的文献直接略过,不再重复请求;
9691
+ * 每篇的结果实时写入 downloads,弹窗只做集中展示。批量未结束时再次触发
9692
+ * 只重新打开弹窗,避免两轮循环重复下载同一批文献。
9693
+ */
9694
+ const startBatchDownload = (entries) => {
9695
+ if (taskId === null || entries.length === 0) return;
9696
+ if (batchRunningRef.current) {
9697
+ setBatchOpen(true);
9698
+ return;
9699
+ }
9700
+ setBatchItems(entries.map((entry) => ({ ...entry })));
9701
+ setBatchOpen(true);
9702
+ batchRunningRef.current = true;
9703
+ setBatchRunning(true);
9386
9704
  (async () => {
9387
9705
  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;
9706
+ let manualCount = 0;
9707
+ let failedCount = 0;
9708
+ let changed = false;
9709
+ /** 单篇:跳过已下载的,下载结果实时写回 downloads。 */
9710
+ const runOne = async (paperId) => {
9711
+ const runtime = downloadsRef.current[paperId];
9712
+ if (runtime?.state === "downloading") return;
9713
+ if (runtime?.state === "downloaded" || statesRef.current[paperId]?.pdfStatus === "downloaded") return;
9714
+ changed = true;
9715
+ setDownloads((previous) => ({
9716
+ ...previous,
9717
+ [paperId]: { state: "downloading" }
9718
+ }));
9719
+ try {
9720
+ const result = await downloadPaperPdf(taskId, paperId);
9721
+ if (result.status === "downloaded") {
9722
+ ok += 1;
9723
+ setDownloads((previous) => ({
9724
+ ...previous,
9725
+ [paperId]: {
9726
+ state: "downloaded",
9727
+ ...result.path === void 0 ? {} : { path: result.path }
9728
+ }
9729
+ }));
9730
+ } else {
9731
+ if (isManualPdfFailure(result.reason)) manualCount += 1;
9732
+ else failedCount += 1;
9733
+ setDownloads((previous) => ({
9734
+ ...previous,
9735
+ [paperId]: {
9736
+ state: "failed",
9737
+ error: result.error ?? result.status,
9738
+ ...result.reason === void 0 ? {} : { reason: result.reason }
9739
+ }
9740
+ }));
9741
+ }
9742
+ } catch (error) {
9743
+ failedCount += 1;
9744
+ setDownloads((previous) => ({
9745
+ ...previous,
9746
+ [paperId]: {
9747
+ state: "failed",
9748
+ error: messageOf(error)
9749
+ }
9750
+ }));
9751
+ }
9752
+ };
9753
+ let cursor = 0;
9754
+ const worker = async () => {
9755
+ while (cursor < entries.length) {
9756
+ const entry = entries[cursor];
9757
+ cursor += 1;
9758
+ if (entry !== void 0) await runOne(entry.paperId);
9759
+ }
9760
+ };
9761
+ try {
9762
+ await Promise.all(Array.from({ length: Math.min(BATCH_CONCURRENCY, entries.length) }, () => worker()));
9763
+ } finally {
9764
+ batchRunningRef.current = false;
9765
+ setBatchRunning(false);
9766
+ if (changed) view.reload();
9395
9767
  }
9396
- setToast(tpl(t("bulk.downloadDone"), {
9768
+ if (changed) notify(tpl(t("bulk.downloadDone"), {
9397
9769
  ok,
9398
- failed
9399
- }));
9400
- view.reload();
9770
+ manual: manualCount,
9771
+ failed: failedCount
9772
+ }), failedCount > 0 ? "error" : "info", 8e3);
9401
9773
  })();
9402
9774
  };
9775
+ /** 结果页选中项:标题优先取当前页,跨页选中的文献再异步补详情。 */
9776
+ const downloadMany = () => {
9777
+ if (taskId === null || ids.length === 0) return;
9778
+ const known = /* @__PURE__ */ new Map();
9779
+ for (const paper of view.page?.papers ?? []) known.set(paper.paperId, paper.title);
9780
+ startBatchDownload(ids.map((paperId) => ({
9781
+ paperId,
9782
+ title: known.get(paperId) ?? ""
9783
+ })));
9784
+ const missing = ids.filter((paperId) => !known.has(paperId));
9785
+ if (missing.length === 0) return;
9786
+ Promise.all(missing.map((paperId) => fetchPaper(taskId, paperId).then((loaded) => {
9787
+ setBatchItems((previous) => previous.map((item) => item.paperId === paperId ? {
9788
+ ...item,
9789
+ title: loaded.paper.title
9790
+ } : item));
9791
+ }).catch(() => void 0)));
9792
+ };
9403
9793
  const zoteroIds = (paperIds) => {
9404
9794
  if (taskId === null || paperIds.length === 0) return;
9405
9795
  syncZotero(taskId, paperIds).then((result) => {
9406
- setToast(zoteroToast(t, result.saved, result.failed, result.messages));
9796
+ notify(zoteroToast(t, result.saved, result.failed, result.messages));
9407
9797
  if (result.failed > 0) setRisFallback({
9408
9798
  ids: [...paperIds],
9409
9799
  message: result.messages[0] ?? t("pdf.zoteroFailed")
9410
9800
  });
9411
9801
  }).catch((error) => {
9412
9802
  const message = messageOf(error);
9413
- setToast(tpl(t("pdf.zoteroFailed"), { message }));
9803
+ notify(tpl(t("pdf.zoteroFailed"), { message }));
9414
9804
  setRisFallback({
9415
9805
  ids: [...paperIds],
9416
9806
  message
9417
9807
  });
9418
9808
  });
9419
9809
  };
9420
- const downloadMany = () => downloadIds(ids);
9421
9810
  const zoteroMany = () => zoteroIds(ids);
9422
9811
  if (taskId === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
9423
9812
  style: panelStyle,
@@ -9447,11 +9836,12 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9447
9836
  const failed = detail.result.sources.filter((source) => !source.ok && !source.skipped);
9448
9837
  const skipped = detail.result.sources.filter((source) => source.skipped);
9449
9838
  const page = view.page;
9839
+ const pageIds = page?.papers.map((paper) => paper.paperId) ?? [];
9450
9840
  /** 当前生效的排序:面板选择优先,未选择时跟随任务条件。 */
9451
9841
  const sortValue = filters.sort ?? detail.conditions.sort;
9452
9842
  const coverage = overview?.fullText;
9453
9843
  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;
9844
+ const overviewMissing = coverage === void 0 ? 0 : coverage.unknown + coverage.missing + coverage.paywalled + coverage.blocked + coverage.failed;
9455
9845
  const overviewRate = overview === null || overview.loaded === 0 ? 0 : Math.round(overviewAvailable / overview.loaded * 100);
9456
9846
  const conditionMeta = [tpl(t("task.pageSize"), { n: detail.conditions.pageSize })];
9457
9847
  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 +10113,40 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9723
10113
  onCancel: () => setEditingConditions(false),
9724
10114
  onSaved: () => {
9725
10115
  setEditingConditions(false);
9726
- setToast(t("condition.saved"));
10116
+ notify(t("condition.saved"));
9727
10117
  view.reload();
9728
10118
  }
9729
10119
  }),
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
- ]
10120
+ (toast !== "" || risFallback !== null) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10121
+ style: toastStackStyle,
10122
+ children: [toast !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
10123
+ role: "status",
10124
+ style: {
10125
+ ...toastStyle,
10126
+ ...toastTone === "error" ? toastErrorStyle : null
10127
+ },
10128
+ children: toast
10129
+ }), risFallback !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10130
+ style: RIS_FALLBACK_STYLE,
10131
+ children: [
10132
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10133
+ style: { minWidth: 0 },
10134
+ children: tpl(t("pdf.zoteroFallback"), { message: risFallback.message })
10135
+ }),
10136
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
10137
+ href: exportUrl(taskId, "ris", {}, risFallback.ids),
10138
+ download: true,
10139
+ style: RIS_FALLBACK_LINK_STYLE,
10140
+ children: t("pdf.zoteroFallbackLink")
10141
+ }),
10142
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10143
+ type: "button",
10144
+ style: RIS_FALLBACK_BUTTON_STYLE,
10145
+ onClick: () => setRisFallback(null),
10146
+ children: t("pdf.zoteroFallbackDismiss")
10147
+ })
10148
+ ]
10149
+ })]
9754
10150
  }),
9755
10151
  view.error !== null && detail.result.totalLoaded > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
9756
10152
  style: {
@@ -9846,23 +10242,48 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9846
10242
  }),
9847
10243
  page !== null && page.total > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
9848
10244
  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
- })]
10245
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10246
+ style: PAGE_TOOLBAR_LEFT_STYLE,
10247
+ children: [
10248
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
10249
+ style: PAGE_SORT_LABEL_STYLE,
10250
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("filter.sort") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
10251
+ style: PAGE_SORT_SELECT_STYLE,
10252
+ value: sortValue,
10253
+ onChange: (event) => {
10254
+ const next = event.target.value;
10255
+ setFilters((previous) => ({
10256
+ ...previous,
10257
+ sort: next === detail.conditions.sort ? void 0 : next
10258
+ }));
10259
+ },
10260
+ children: SORT_MODES.map((mode) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
10261
+ value: mode,
10262
+ children: t(`sort.${mode}`)
10263
+ }, mode))
10264
+ })]
10265
+ }),
10266
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10267
+ type: "button",
10268
+ style: PAGE_TOOLBAR_ACTION_STYLE,
10269
+ title: t("bulk.selectPageHint"),
10270
+ onClick: () => selectPage(pageIds),
10271
+ children: t("bulk.selectPage")
10272
+ }),
10273
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10274
+ type: "button",
10275
+ style: selected.size === 0 ? PAGE_TOOLBAR_ACTION_DISABLED_STYLE : PAGE_TOOLBAR_ACTION_STYLE,
10276
+ disabled: selected.size === 0,
10277
+ title: t("bulk.unselectAllHint"),
10278
+ onClick: clearSelection,
10279
+ children: t("bulk.unselectAll")
10280
+ }),
10281
+ selected.size > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10282
+ style: PAGE_TOOLBAR_COUNT_STYLE,
10283
+ title: t("bulk.keepHint"),
10284
+ children: tpl(t("bulk.count"), { n: selected.size })
10285
+ })
10286
+ ]
9866
10287
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pager, {
9867
10288
  t,
9868
10289
  index: view.pageIndex,
@@ -9876,16 +10297,21 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9876
10297
  taskId,
9877
10298
  count: selected.size,
9878
10299
  ids,
9879
- pageIds: page?.papers.map((paper) => paper.paperId) ?? [],
10300
+ pageIds,
9880
10301
  groups: detail.groups,
9881
10302
  busy: view.busy,
9882
10303
  filters,
9883
- onClear: () => setSelected(/* @__PURE__ */ new Set()),
9884
- onSelectPage: (pageIds) => setSelected(new Set(pageIds)),
10304
+ onClear: clearSelection,
10305
+ onSelectPage: selectPage,
9885
10306
  onAnnotate: annotateMany,
9886
10307
  onInsert: insertMany,
9887
10308
  onDownload: downloadMany,
9888
- onZotero: zoteroMany
10309
+ onZotero: zoteroMany,
10310
+ batchRunning,
10311
+ ...detail.downloadDir === void 0 || onRevealLocal === void 0 ? {} : {
10312
+ downloadDir: detail.downloadDir,
10313
+ onRevealDir: onRevealLocal
10314
+ }
9889
10315
  }),
9890
10316
  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
10317
  style: listStyle$1,
@@ -9896,6 +10322,10 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9896
10322
  state: detail.states[paper.paperId],
9897
10323
  groups: detail.groups,
9898
10324
  busy: view.busy,
10325
+ ...detail.downloadDir === void 0 ? {} : { downloadDir: detail.downloadDir },
10326
+ ...downloads[paper.paperId] === void 0 ? {} : { download: downloads[paper.paperId] },
10327
+ ...onOpenLocal === void 0 ? {} : { onOpenLocal },
10328
+ ...onRevealLocal === void 0 ? {} : { onRevealLocal },
9899
10329
  selected: selected.has(paper.paperId),
9900
10330
  onToggleSelect: toggleSelect,
9901
10331
  onInsert: insert,
@@ -9946,8 +10376,13 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9946
10376
  states: detail.states,
9947
10377
  stats: overview,
9948
10378
  onDownload: downloadPaper,
10379
+ ...onOpenLocal === void 0 ? {} : { onOpenLocal },
10380
+ ...onRevealLocal === void 0 ? {} : { onRevealLocal },
9949
10381
  onZotero: zoteroPaper,
9950
- onBatchDownload: (papers) => downloadIds(papers.map((paper) => paper.paperId)),
10382
+ onBatchDownload: (papers) => startBatchDownload(papers.map((paper) => ({
10383
+ paperId: paper.paperId,
10384
+ title: paper.title
10385
+ }))),
9951
10386
  onBatchZotero: (papers) => zoteroIds(papers.map((paper) => paper.paperId)),
9952
10387
  onShowInResults: (viewKey) => {
9953
10388
  setFilters(viewKey === "missing" ? { missingFullText: true } : { pdfStatus: viewKey });
@@ -9958,6 +10393,7 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9958
10393
  taskId,
9959
10394
  detail,
9960
10395
  onSaved: view.reload,
10396
+ onNotify: notify,
9961
10397
  onPickGroup: (groupId) => {
9962
10398
  setFilters((previous) => ({
9963
10399
  ...previous,
@@ -9969,7 +10405,8 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9969
10405
  t,
9970
10406
  taskId,
9971
10407
  detail,
9972
- onSaved: view.reload
10408
+ onSaved: view.reload,
10409
+ onNotify: notify
9973
10410
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CompareTab, {
9974
10411
  t,
9975
10412
  taskId,
@@ -9986,6 +10423,17 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9986
10423
  }
9987
10424
  })
9988
10425
  }),
10426
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadDialog, {
10427
+ t,
10428
+ open: batchOpen,
10429
+ items: batchItems,
10430
+ states: detail.states,
10431
+ downloads,
10432
+ onClose: () => {
10433
+ setBatchOpen(false);
10434
+ view.reload();
10435
+ }
10436
+ }),
9989
10437
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RoundsDialog, {
9990
10438
  t,
9991
10439
  open: showRounds,
@@ -9995,7 +10443,7 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onClos
9995
10443
  busy: active,
9996
10444
  onRerun: () => {
9997
10445
  view.reload();
9998
- setToast(t("rounds.rerunStarted"));
10446
+ notify(t("rounds.rerunStarted"));
9999
10447
  }
10000
10448
  }),
10001
10449
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskListDialog, {
@@ -10154,6 +10602,8 @@ const EXPORT_FORMATS = [
10154
10602
  "bibtex",
10155
10603
  "md"
10156
10604
  ];
10605
+ /** 批量下载的并发上限:同时下载 5 篇。 */
10606
+ const BATCH_CONCURRENCY = 5;
10157
10607
  /** 筛读计数(点击筛选):与「筛选」按钮同排,接在筛选条最前面。 */
10158
10608
  function ScreeningPills({ t, detail, filters, onChange }) {
10159
10609
  const counts = detail.stats.screening;
@@ -10186,7 +10636,7 @@ function ScreeningPills({ t, detail, filters, onChange }) {
10186
10636
  });
10187
10637
  }
10188
10638
  /** 选中多篇后的批量操作条:筛读、分组、下载、Zotero、按选中导出。 */
10189
- function BulkBar({ t, taskId, count, ids, pageIds, groups, busy, filters, onClear, onSelectPage, onAnnotate, onInsert, onDownload, onZotero }) {
10639
+ function BulkBar({ t, taskId, count, ids, pageIds, groups, busy, batchRunning, downloadDir, onRevealDir, filters, onClear, onSelectPage, onAnnotate, onInsert, onDownload, onZotero }) {
10190
10640
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
10191
10641
  style: BULK_STYLE,
10192
10642
  children: [
@@ -10261,11 +10711,15 @@ function BulkBar({ t, taskId, count, ids, pageIds, groups, busy, filters, onClea
10261
10711
  label: t("bulk.insert"),
10262
10712
  onClick: onInsert
10263
10713
  }),
10264
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BulkActionButton, {
10265
- icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadIcon, {}),
10714
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BulkDownloadButton, {
10715
+ t,
10266
10716
  label: t("bulk.download"),
10267
10717
  onClick: onDownload,
10268
- disabled: busy
10718
+ disabled: busy || batchRunning,
10719
+ ...downloadDir === void 0 || onRevealDir === void 0 ? {} : {
10720
+ downloadDir,
10721
+ onReveal: onRevealDir
10722
+ }
10269
10723
  }),
10270
10724
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BulkActionButton, {
10271
10725
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoteroIcon, {}),
@@ -10317,6 +10771,73 @@ function BulkActionButton({ icon, label, onClick, disabled = false }) {
10317
10771
  children: [icon, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label })]
10318
10772
  });
10319
10773
  }
10774
+ /**
10775
+ * 批量下载按钮 + 下三角:点主体打开下载进度弹窗;点箭头展开菜单,
10776
+ * 在系统文件管理器中打开下载目录(与条目上「查看已下载 ▾」的菜单一致)。
10777
+ */
10778
+ function BulkDownloadButton({ t, label, onClick, disabled, downloadDir, onReveal }) {
10779
+ const [open, setOpen] = (0, react.useState)(false);
10780
+ const groupRef = (0, react.useRef)(null);
10781
+ (0, react.useEffect)(() => {
10782
+ if (!open) return void 0;
10783
+ const closeOnOutside = (event) => {
10784
+ if (groupRef.current !== null && !groupRef.current.contains(event.target)) setOpen(false);
10785
+ };
10786
+ const closeOnEscape = (event) => {
10787
+ if (event.key === "Escape") setOpen(false);
10788
+ };
10789
+ document.addEventListener("mousedown", closeOnOutside);
10790
+ document.addEventListener("keydown", closeOnEscape);
10791
+ return () => {
10792
+ document.removeEventListener("mousedown", closeOnOutside);
10793
+ document.removeEventListener("keydown", closeOnEscape);
10794
+ };
10795
+ }, [open]);
10796
+ const revealable = downloadDir !== void 0 && onReveal !== void 0;
10797
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
10798
+ ref: groupRef,
10799
+ style: BULK_DOWNLOAD_GROUP_STYLE,
10800
+ children: [
10801
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BulkActionButton, {
10802
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadIcon, {}),
10803
+ label,
10804
+ onClick,
10805
+ disabled
10806
+ }),
10807
+ revealable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10808
+ type: "button",
10809
+ style: {
10810
+ ...BULK_ARROW_STYLE,
10811
+ ...disabled ? {
10812
+ opacity: .45,
10813
+ cursor: "default"
10814
+ } : null
10815
+ },
10816
+ "aria-haspopup": "menu",
10817
+ "aria-expanded": open,
10818
+ "aria-label": t("bulk.revealDir"),
10819
+ title: t("bulk.revealDir"),
10820
+ disabled,
10821
+ onClick: () => setOpen((value) => !value),
10822
+ children: "▾"
10823
+ }),
10824
+ open && revealable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10825
+ role: "menu",
10826
+ style: BULK_MENU_STYLE,
10827
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
10828
+ type: "button",
10829
+ role: "menuitem",
10830
+ style: BULK_MENU_ITEM_STYLE,
10831
+ onClick: () => {
10832
+ setOpen(false);
10833
+ if (downloadDir !== void 0) onReveal?.(downloadDir);
10834
+ },
10835
+ children: t("bulk.revealDir")
10836
+ })
10837
+ })
10838
+ ]
10839
+ });
10840
+ }
10320
10841
  /** 结果页翻页条:首页 / 上一页 / 页码 / 下一页 / 末页。单页时不渲染。 */
10321
10842
  function Pager({ t, index, count, total, onChange }) {
10322
10843
  if (count <= 1) return null;
@@ -10399,16 +10920,23 @@ const ROUND_RESULTS_ICON_STYLE = {
10399
10920
  flexShrink: 0
10400
10921
  };
10401
10922
  const RIS_FALLBACK_STYLE = {
10923
+ ...toastStyle,
10924
+ pointerEvents: "auto",
10402
10925
  display: "flex",
10403
10926
  alignItems: "center",
10404
10927
  flexWrap: "wrap",
10405
10928
  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
10929
+ borderLeft: "3px solid #b26a00"
10930
+ };
10931
+ const RIS_FALLBACK_LINK_STYLE = {
10932
+ color: "#fff",
10933
+ fontSize: 12,
10934
+ textDecoration: "underline"
10935
+ };
10936
+ const RIS_FALLBACK_BUTTON_STYLE = {
10937
+ ...linkButtonStyle,
10938
+ color: "#fff",
10939
+ textDecoration: "underline"
10412
10940
  };
10413
10941
  const TAB_ROW_STYLE = {
10414
10942
  display: "flex",
@@ -10517,6 +11045,50 @@ const BULK_BUTTON_STYLE = {
10517
11045
  lineHeight: 1.4,
10518
11046
  cursor: "pointer"
10519
11047
  };
11048
+ /** 批量下载 + 下三角的组合:箭头紧贴按钮右侧,菜单相对整体定位。 */
11049
+ const BULK_DOWNLOAD_GROUP_STYLE = {
11050
+ position: "relative",
11051
+ display: "inline-flex",
11052
+ alignItems: "center",
11053
+ gap: 2
11054
+ };
11055
+ const BULK_ARROW_STYLE = {
11056
+ padding: "2px 3px",
11057
+ borderRadius: 5,
11058
+ border: "1px solid rgba(80,140,255,0.4)",
11059
+ background: "transparent",
11060
+ color: ACCENT,
11061
+ fontSize: 12,
11062
+ lineHeight: 1.4,
11063
+ cursor: "pointer"
11064
+ };
11065
+ const BULK_MENU_STYLE = {
11066
+ position: "absolute",
11067
+ top: "100%",
11068
+ left: 0,
11069
+ marginTop: 3,
11070
+ zIndex: 40,
11071
+ display: "flex",
11072
+ flexDirection: "column",
11073
+ minWidth: 132,
11074
+ padding: 2,
11075
+ borderRadius: 6,
11076
+ border: "1px solid rgba(127,127,127,0.3)",
11077
+ background: "#fff",
11078
+ boxShadow: "0 6px 20px rgba(0,0,0,0.18)"
11079
+ };
11080
+ const BULK_MENU_ITEM_STYLE = {
11081
+ padding: "5px 8px",
11082
+ border: "none",
11083
+ borderRadius: 4,
11084
+ background: "transparent",
11085
+ color: "#1f2328",
11086
+ fontSize: 12,
11087
+ lineHeight: 1.4,
11088
+ textAlign: "left",
11089
+ whiteSpace: "nowrap",
11090
+ cursor: "pointer"
11091
+ };
10520
11092
  const COVERAGE_WRAP_STYLE = {
10521
11093
  display: "flex",
10522
11094
  flexDirection: "column",
@@ -10550,6 +11122,7 @@ const QUICK_FILTER_ACTIVE_STYLE = {
10550
11122
  };
10551
11123
  const TASK_PANEL_STYLE = {
10552
11124
  ...panelStyle,
11125
+ position: "relative",
10553
11126
  height: "100%",
10554
11127
  minHeight: 0,
10555
11128
  boxSizing: "border-box"
@@ -10563,7 +11136,7 @@ const SCROLL_BODY_STYLE = {
10563
11136
  overflowY: "auto",
10564
11137
  overflowX: "hidden"
10565
11138
  };
10566
- /** 排序与翻页同一行:排序靠左,翻页(含总篇数)靠右。 */
11139
+ /** 排序(含本页全选 / 取消全选)与翻页同一行:操作靠左,翻页(含总篇数)靠右。 */
10567
11140
  const PAGE_TOOLBAR_ROW_STYLE = {
10568
11141
  display: "flex",
10569
11142
  alignItems: "center",
@@ -10572,6 +11145,27 @@ const PAGE_TOOLBAR_ROW_STYLE = {
10572
11145
  gap: 8,
10573
11146
  padding: "2px 0"
10574
11147
  };
11148
+ const PAGE_TOOLBAR_LEFT_STYLE = {
11149
+ display: "flex",
11150
+ alignItems: "center",
11151
+ flexWrap: "wrap",
11152
+ gap: 8
11153
+ };
11154
+ const PAGE_TOOLBAR_ACTION_STYLE = {
11155
+ ...linkButtonStyle,
11156
+ fontSize: 11.5,
11157
+ whiteSpace: "nowrap"
11158
+ };
11159
+ const PAGE_TOOLBAR_ACTION_DISABLED_STYLE = {
11160
+ ...PAGE_TOOLBAR_ACTION_STYLE,
11161
+ opacity: .4,
11162
+ cursor: "default"
11163
+ };
11164
+ const PAGE_TOOLBAR_COUNT_STYLE = {
11165
+ fontSize: 11.5,
11166
+ opacity: .66,
11167
+ whiteSpace: "nowrap"
11168
+ };
10575
11169
  const PAGE_SORT_LABEL_STYLE = {
10576
11170
  display: "flex",
10577
11171
  alignItems: "center",
@@ -10636,6 +11230,12 @@ const PAPER_TAB_ID = "litsearch-paper";
10636
11230
  /** 设置对话框里这一页的 section id。 */
10637
11231
  const SETTINGS_SECTION_ID = "litsearch";
10638
11232
  const HEALTH_REFRESH_MS = 3e4;
11233
+ /** 文件资源地址:与 DSH 文档预览 tab 使用的 `dsh-resource://file/session/...` 保持一致。 */
11234
+ function localFileAddress(sessionId, path) {
11235
+ const encode = (segment) => encodeURIComponent(segment).replace(/%3A/gi, ":");
11236
+ const normalized = path.replace(/\\/g, "/").replace(/^(?:\.\/)+/, "");
11237
+ return `dsh-resource://file/session/${encode(sessionId)}/${normalized.split("/").map(encode).join("/")}`;
11238
+ }
10639
11239
  const name = "dsh-plugin-litsearch";
10640
11240
  const inject = [
10641
11241
  "slots",
@@ -10710,13 +11310,33 @@ function apply(ctx) {
10710
11310
  const address = `${PAPER_ADDRESS_PREFIX}${encodeURIComponent(taskId)}/${encodeURIComponent(paperId)}`;
10711
11311
  ctx.sidebarRight.openResource(address, title === "" ? void 0 : { params: { title } });
10712
11312
  };
11313
+ /**
11314
+ * 在右侧栏用 DSH 文档预览打开已下载的本地 PDF。
11315
+ * 页面加载后文件可能被删除,先让 Host 复核一次;缺失时状态会广播为
11316
+ * `missing`,按钮随即变成「下载文件已删除点击重新下载」。
11317
+ */
11318
+ const openPdfPreview = (taskId, paperId, path) => {
11319
+ recheckPaperPdf(taskId, paperId).then((result) => result.exists).catch(() => true).then((exists) => {
11320
+ if (!exists || path === "") return;
11321
+ const sessionId = ctx.sidebarRight.mounted.getSnapshot();
11322
+ if (sessionId === void 0) return;
11323
+ ctx.sidebarRight.openResource(localFileAddress(sessionId, path), { revealIfOpened: true });
11324
+ });
11325
+ };
11326
+ /** 在系统文件管理器中打开下载目录并选中该 PDF(失败时静默,避免打断阅读)。 */
11327
+ const revealPdf = (path) => {
11328
+ if (path === "") return;
11329
+ revealLocalFile(path).catch(() => void 0);
11330
+ };
10713
11331
  const TaskBody = (props) => (0, react.createElement)(TaskPane, {
10714
11332
  t,
10715
11333
  useTabInfo: props.useTabInfo,
10716
11334
  inputActions: props.inputActions,
10717
11335
  onOpenPaper: openPaper,
10718
11336
  onOpenTask: (id, title) => openTask(id, title),
10719
- onCloseTask: (id) => closeTask(id)
11337
+ onCloseTask: (id) => closeTask(id),
11338
+ onOpenLocal: openPdfPreview,
11339
+ onRevealLocal: revealPdf
10720
11340
  });
10721
11341
  /** tab 标题:按地址里的任务 id 读缓存,缺了补取一次详情。 */
10722
11342
  const TaskTitle = (props) => {
@@ -10750,7 +11370,9 @@ function apply(ctx) {
10750
11370
  const PaperBody = (props) => (0, react.createElement)(PaperPane, {
10751
11371
  t,
10752
11372
  useTabInfo: props.useTabInfo,
10753
- inputActions: props.inputActions
11373
+ inputActions: props.inputActions,
11374
+ onOpenLocal: openPdfPreview,
11375
+ onRevealLocal: revealPdf
10754
11376
  });
10755
11377
  const PaperTitle = (props) => {
10756
11378
  const params = props.useTabInfo().tab.navigation?.params;