dsh-plugin-litsearch 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/client.js CHANGED
@@ -124,6 +124,7 @@ function resultParams(query) {
124
124
  if (query.language !== void 0) params.set("language", query.language);
125
125
  if (query.hasPdf === true) params.set("hasPdf", "1");
126
126
  if (query.missingFullText === true) params.set("missingFullText", "1");
127
+ if (query.pdfStatus !== void 0) params.set("pdfStatus", query.pdfStatus);
127
128
  if (query.groupId !== void 0 && query.groupId !== "") params.set("groupId", query.groupId);
128
129
  if (query.venue !== void 0 && query.venue !== "") params.set("venue", query.venue);
129
130
  if (query.author !== void 0 && query.author !== "") params.set("author", query.author);
@@ -249,6 +250,8 @@ function useHealth() {
249
250
  *
250
251
  * Host→Client 没有广播通道,这里按 `sinceRev` 轮询 `/events`:
251
252
  * - 首次轮询只对齐版本号,不把宿主启动前的历史任务全打开;
253
+ * - 模型每次运行任务都会请求 `focus`:直接打开/聚焦该任务 tab(面板关着时
254
+ * 重新打开),不受「一轮只开一个」限制,但仍尊重自动打开开关;
252
255
  * - 之后只对 `origin === 'model'` 的新任务调 `openResource`;
253
256
  * - 同一轮(2 分钟)最多自动打开 1 个 tab,其余进「待打开」队列,由主面板横幅提示;
254
257
  * - `maxTaskTabs` 按「最近真的打开过的 tab」计数(带 30 分钟有效期),不再用
@@ -359,6 +362,8 @@ function enqueuePending(task) {
359
362
  const TAB_WINDOW_MS = 18e5;
360
363
  /** 同一轮对话窗口:这段时间内最多自动打开 1 个 tab,其余进待打开队列。 */
361
364
  const ROUND_WINDOW_MS = 12e4;
365
+ /** 聚焦请求的有效期:更早的请求可能是上次使用留下的,重新加载页面时不再弹出。 */
366
+ const FOCUS_WINDOW_MS = 6e5;
362
367
  let opened = readOpened();
363
368
  function readOpened() {
364
369
  const stored = readJson(OPENED_KEY, []);
@@ -403,6 +408,9 @@ function activeTabCount() {
403
408
  const cutoff = Date.now() - TAB_WINDOW_MS;
404
409
  return opened.filter((entry) => entry.tab === true && entry.at >= cutoff).length;
405
410
  }
411
+ function isFreshFocus(focus) {
412
+ return Date.now() - focus.at <= FOCUS_WINDOW_MS;
413
+ }
406
414
  function lastAutoOpenAt() {
407
415
  const cutoff = Date.now() - TAB_WINDOW_MS;
408
416
  let last = 0;
@@ -452,9 +460,15 @@ function startAutoOpen(options) {
452
460
  const first = !seeded;
453
461
  seeded = true;
454
462
  rev = page.rev;
455
- if (first) return;
456
463
  const settings = getHealth()?.settings;
457
- if (!(settings?.autoOpenTaskTab ?? true)) return;
464
+ const autoOpen = settings?.autoOpenTaskTab ?? true;
465
+ if (autoOpen && page.focus !== void 0 && isFreshFocus(page.focus)) {
466
+ const screen = options.sessionId?.();
467
+ const target = page.focus.sessionId;
468
+ if (target === void 0 || screen !== void 0 && target === screen) alsoFocus(page.focus.taskId, page.focus.title);
469
+ else rememberOpened(page.focus.taskId, false);
470
+ }
471
+ if (first || !autoOpen) return;
458
472
  const maxTabs = settings?.maxTaskTabs ?? 5;
459
473
  for (const task of page.tasks) {
460
474
  if (task.origin !== "model") continue;
@@ -471,6 +485,11 @@ function startAutoOpen(options) {
471
485
  options.open(task.id, task.title);
472
486
  }
473
487
  };
488
+ const alsoFocus = (id, title) => {
489
+ rememberTaskTitle(id, title);
490
+ rememberOpened(id, true);
491
+ options.open(id, title);
492
+ };
474
493
  tick();
475
494
  const timer = setInterval(() => {
476
495
  tick();
@@ -545,6 +564,45 @@ function TaskListIcon() {
545
564
  })]
546
565
  });
547
566
  }
567
+ /** 结果卡片字段图标统一尺寸:比右上角图标按钮略小,跟正文基线对齐。 */
568
+ const fieldSvgProps = {
569
+ ...svgProps,
570
+ width: 14,
571
+ height: 14
572
+ };
573
+ /** 文献标题:带折角的文档 + 标题行。 */
574
+ function TitleIcon() {
575
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
576
+ ...fieldSvgProps,
577
+ children: [
578
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" }),
579
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M14 3v5h5" }),
580
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M8 13h8M8 17h5" })
581
+ ]
582
+ });
583
+ }
584
+ /** 作者:双人像。 */
585
+ function AuthorsIcon() {
586
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
587
+ ...fieldSvgProps,
588
+ children: [
589
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
590
+ cx: "9",
591
+ cy: "8",
592
+ r: "3"
593
+ }),
594
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3.5 20v-1a5.5 5.5 0 0 1 11 0v1" }),
595
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M16 5.4a3 3 0 0 1 0 5.2M18 15.2a5 5 0 0 1 2.5 3.8v1" })
596
+ ]
597
+ });
598
+ }
599
+ /** 摘要:段落横排文字。 */
600
+ function AbstractIcon() {
601
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
602
+ ...fieldSvgProps,
603
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4 6h16M4 10h16M4 14h13M4 18h8" })
604
+ });
605
+ }
548
606
  // src/client/locales.ts
549
607
  /**
550
608
  * 文案字典(zh / en)。
@@ -599,6 +657,9 @@ const dicts = {
599
657
  "task.refresh": "刷新",
600
658
  "task.rerun": "重跑",
601
659
  "task.cancel": "取消",
660
+ "task.insertToChat": "带入对话",
661
+ "task.insertToChatHint": "把任务 id 与当前检索条件写入会话草稿;建议一个对话只跟进一个检索任务",
662
+ "task.inserted": "已把任务信息带入会话草稿",
602
663
  "task.pin": "置顶",
603
664
  "task.unpin": "取消置顶",
604
665
  "task.running": "检索中…",
@@ -651,12 +712,19 @@ const dicts = {
651
712
  "filter.authorPlaceholder": "完整姓名",
652
713
  "filter.group": "分组",
653
714
  "filter.any": "不限",
715
+ "filter.keywordChip": "关键词:{value}",
716
+ "filter.sourceChip": "来源:{value}",
717
+ "filter.yearChip": "年份:{value}",
718
+ "filter.screeningChip": "状态:{value}",
719
+ "filter.languageChip": "语言:{value}",
720
+ "filter.sortChip": "排序:{value}",
654
721
  "filter.typeChip": "类型:{value}",
655
722
  "filter.queryChip": "检索式:{value}",
656
723
  "filter.groupChip": "分组:{value}",
657
724
  "filter.venueChip": "出处:{value}",
658
725
  "filter.authorChip": "作者:{value}",
659
726
  "filter.newChip": "仅新增",
727
+ "filter.pdfStatusChip": "全文状态:{value}",
660
728
  "filter.noMatch": "当前筛选没有匹配文献,试试放宽条件。",
661
729
  "ledger.export": "导出",
662
730
  "export.csv": "CSV 台账",
@@ -701,14 +769,21 @@ const dicts = {
701
769
  "bulk.export": "按选中导出",
702
770
  "bulk.downloadDone": "批量下载:成功 {ok} 篇,失败 {failed} 篇",
703
771
  "fulltext.loading": "统计全文覆盖…",
704
- "fulltext.hint": "全文覆盖与概览同一口径;缺失列表只显示前 100 篇。",
705
- "fulltext.coverage": "可获取全文 {available}/{total} 篇(含已下载)",
772
+ "fulltext.hint": "统计基于全部 {total} 篇,与概览同口径,不受当前筛选影响;点状态可切换清单,列表分页加载。",
773
+ "fulltext.hintFallback": "统计暂不可用,仍可按状态查看文献清单。",
774
+ "fulltext.coverage": "可获取全文 {available}/{total} 篇(含已下载与 HTML 全文)",
706
775
  "fulltext.missing": "缺全文",
707
- "fulltext.showInResults": "在结果里筛选",
708
- "fulltext.none": "当前任务没有缺全文的文献。",
709
- "fulltext.more": "只显示前 {shown} 篇,共 {total} 篇;可导出后继续处理。",
776
+ "fulltext.showInResults": "在结果中查看全部 {n} 篇 →",
777
+ "fulltext.none": "当前视图没有符合条件的文献。",
778
+ "fulltext.loadMore": "加载更多(已显示 {shown}/{total})",
779
+ "fulltext.coverageBar": "全文覆盖 {available}/{total}({rate}%)· {missing} 篇待补",
780
+ "fulltext.barHint": "全任务口径,不受当前筛选影响",
781
+ "fulltext.openTab": "全文台账 →",
782
+ "fulltext.quickFilter": "只看缺全文",
710
783
  "annotate.group": "分组",
711
784
  "annotate.noGroup": "未分组",
785
+ "annotate.createGroup": "+ 新建分组…",
786
+ "annotate.groupHint": "还没有分组;可在任务的「分组」页新建,选「未分组」可清除分组",
712
787
  "annotate.expand": "标注",
713
788
  "annotate.collapse": "收起标注",
714
789
  "annotate.note": "备注",
@@ -750,7 +825,6 @@ const dicts = {
750
825
  "groups.name": "名称",
751
826
  "groups.desc": "说明",
752
827
  "groups.add": "添加分组",
753
- "groups.save": "保存分组",
754
828
  "groups.delete": "删除",
755
829
  "groups.up": "上移",
756
830
  "groups.down": "下移",
@@ -761,7 +835,8 @@ const dicts = {
761
835
  "groups.descPlaceholder": "如:用于方法对比",
762
836
  "groups.deleteConfirm": "删除分组「{name}」?组内文献会回到未分组,标注不受影响。",
763
837
  "groups.saved": "分组已保存",
764
- "groups.added": "已添加分组",
838
+ "groups.saving": "正在保存…",
839
+ "groups.retry": "重试保存",
765
840
  "notes.title": "任务笔记",
766
841
  "notes.taskPlaceholder": "记录检索策略、纳入排除标准、阶段性结论…",
767
842
  "notes.save": "保存笔记",
@@ -846,6 +921,10 @@ const dicts = {
846
921
  "result.insert": "插入到对话",
847
922
  "result.inserted": "已插入到会话草稿",
848
923
  "result.pdf": "PDF",
924
+ "result.infoLabel": "状态与来源",
925
+ "result.infoHint": "筛读状态、命中的数据库、被引次数、开放获取与全文状态",
926
+ "result.actionLabel": "快捷操作",
927
+ "result.actionHint": "对这篇文献执行操作:插入对话、打开详情、预览或下载全文、存入 Zotero",
849
928
  "error.noEditor": "当前面板不在会话上下文中,无法插入到输入框",
850
929
  "error.draftChanged": "输入框草稿已变化,未插入;请避免在插入期间编辑草稿",
851
930
  "error.sourceFailed": "数据源 {source} 检索失败:{message}",
@@ -973,6 +1052,14 @@ const dicts = {
973
1052
  "cmp.paper.url": "链接:{value}",
974
1053
  "cmp.paper.pdf": "PDF:{value}",
975
1054
  "cmp.paper.abstract": "摘要:{value}",
1055
+ "cmp.task.header": "【文献检索任务】请继续使用下面这个现有任务,不要新建任务",
1056
+ "cmp.task.id": "任务 id:{value}",
1057
+ "cmp.task.title": "任务标题:{value}",
1058
+ "cmp.task.rounds": "已有 {n} 轮检索,已加载 {loaded} 篇",
1059
+ "cmp.task.queries": "当前检索式:",
1060
+ "cmp.task.conditions": "检索条件:数据源 {sources};每源 {pageSize} 条;年份 {years};{access};语言 {language};排序 {sort}",
1061
+ "cmp.task.anyAccess": "不限获取方式",
1062
+ "cmp.task.continue": "后续需要调整条件继续检索时,调用 literature_search 并传入 taskId「{id}」,在这个任务内开新一轮;只有我明确要求新建查询任务时才新建。",
976
1063
  "rounds.open": "查看检索历史",
977
1064
  "rounds.title": "检索轮次历史",
978
1065
  "rounds.desc": "共 {n} 轮检索;只记录条件与命中,结果始终是最新一轮",
@@ -994,7 +1081,7 @@ const dicts = {
994
1081
  "rounds.badge": "{n} 轮",
995
1082
  "taskList.open": "搜索任务列表",
996
1083
  "taskList.title": "搜索任务",
997
- "taskList.desc": "一个研究主题对应一个任务、一个 tab;可打开、重命名或删除任务。",
1084
+ "taskList.desc": "一个研究主题对应一个任务;每个对话只保留一个任务 tab,切换任务请在这里进行。",
998
1085
  "taskList.count": "共 {n} 个任务",
999
1086
  "taskList.close": "关闭",
1000
1087
  "taskList.loading": "正在读取任务…",
@@ -1046,6 +1133,9 @@ const dicts = {
1046
1133
  "task.refresh": "Refresh",
1047
1134
  "task.rerun": "Run again",
1048
1135
  "task.cancel": "Cancel",
1136
+ "task.insertToChat": "Add to chat",
1137
+ "task.insertToChatHint": "Insert the task id and current conditions into the chat draft; prefer one conversation per search task",
1138
+ "task.inserted": "Task details added to the chat draft",
1049
1139
  "task.pin": "Pin",
1050
1140
  "task.unpin": "Unpin",
1051
1141
  "task.running": "Searching…",
@@ -1098,12 +1188,19 @@ const dicts = {
1098
1188
  "filter.authorPlaceholder": "full name",
1099
1189
  "filter.group": "Group",
1100
1190
  "filter.any": "Any",
1191
+ "filter.keywordChip": "Keyword: {value}",
1192
+ "filter.sourceChip": "Source: {value}",
1193
+ "filter.yearChip": "Year: {value}",
1194
+ "filter.screeningChip": "Status: {value}",
1195
+ "filter.languageChip": "Language: {value}",
1196
+ "filter.sortChip": "Sort: {value}",
1101
1197
  "filter.typeChip": "Type: {value}",
1102
1198
  "filter.queryChip": "Query: {value}",
1103
1199
  "filter.groupChip": "Group: {value}",
1104
1200
  "filter.venueChip": "Venue: {value}",
1105
1201
  "filter.authorChip": "Author: {value}",
1106
1202
  "filter.newChip": "New only",
1203
+ "filter.pdfStatusChip": "Full-text status: {value}",
1107
1204
  "filter.noMatch": "No papers match the current filters; try relaxing them.",
1108
1205
  "ledger.export": "Export",
1109
1206
  "export.csv": "CSV ledger",
@@ -1148,14 +1245,21 @@ const dicts = {
1148
1245
  "bulk.export": "Export selected",
1149
1246
  "bulk.downloadDone": "Download: {ok} succeeded, {failed} failed",
1150
1247
  "fulltext.loading": "Computing full-text coverage…",
1151
- "fulltext.hint": "Coverage matches the overview; the missing list shows the first 100 papers.",
1152
- "fulltext.coverage": "Full text reachable for {available}/{total} papers (including downloads)",
1248
+ "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.",
1249
+ "fulltext.hintFallback": "Coverage is unavailable; you can still browse papers by status.",
1250
+ "fulltext.coverage": "Full text reachable for {available}/{total} papers (including downloads and HTML)",
1153
1251
  "fulltext.missing": "Missing full text",
1154
- "fulltext.showInResults": "Filter in results",
1155
- "fulltext.none": "No papers are missing full text in this task.",
1156
- "fulltext.more": "Showing the first {shown} of {total}; export to continue offline.",
1252
+ "fulltext.showInResults": "View all {n} in results →",
1253
+ "fulltext.none": "No papers match this view.",
1254
+ "fulltext.loadMore": "Load more (showing {shown}/{total})",
1255
+ "fulltext.coverageBar": "Full text {available}/{total} ({rate}%) · {missing} to fetch",
1256
+ "fulltext.barHint": "Task-wide basis, unaffected by current filters",
1257
+ "fulltext.openTab": "Full-text ledger →",
1258
+ "fulltext.quickFilter": "Missing full text only",
1157
1259
  "annotate.group": "Group",
1158
1260
  "annotate.noGroup": "Ungrouped",
1261
+ "annotate.createGroup": "+ New group…",
1262
+ "annotate.groupHint": "No groups yet; create one on the Groups tab, or pick Ungrouped to clear",
1159
1263
  "annotate.expand": "Annotate",
1160
1264
  "annotate.collapse": "Hide annotation",
1161
1265
  "annotate.note": "Note",
@@ -1197,7 +1301,6 @@ const dicts = {
1197
1301
  "groups.name": "Name",
1198
1302
  "groups.desc": "Description",
1199
1303
  "groups.add": "Add group",
1200
- "groups.save": "Save groups",
1201
1304
  "groups.delete": "Delete",
1202
1305
  "groups.up": "Move up",
1203
1306
  "groups.down": "Move down",
@@ -1208,7 +1311,8 @@ const dicts = {
1208
1311
  "groups.descPlaceholder": "e.g. For method comparison",
1209
1312
  "groups.deleteConfirm": "Delete group \"{name}\"? Its papers become ungrouped; annotations are kept.",
1210
1313
  "groups.saved": "Groups saved",
1211
- "groups.added": "Group added",
1314
+ "groups.saving": "Saving…",
1315
+ "groups.retry": "Retry saving",
1212
1316
  "notes.title": "Task notes",
1213
1317
  "notes.taskPlaceholder": "Search strategy, inclusion criteria, interim conclusions…",
1214
1318
  "notes.save": "Save notes",
@@ -1293,6 +1397,10 @@ const dicts = {
1293
1397
  "result.insert": "Add to conversation",
1294
1398
  "result.inserted": "Added to the conversation draft",
1295
1399
  "result.pdf": "PDF",
1400
+ "result.infoLabel": "Status & sources",
1401
+ "result.infoHint": "Screening status, matched databases, citations, open access and full-text status",
1402
+ "result.actionLabel": "Actions",
1403
+ "result.actionHint": "Actions on this paper: insert into chat, open details, preview or download full text, save to Zotero",
1296
1404
  "error.noEditor": "This panel is not in a conversation context; cannot insert into the composer",
1297
1405
  "error.draftChanged": "The draft changed; nothing was inserted. Avoid editing while inserting.",
1298
1406
  "error.sourceFailed": "Source {source} failed: {message}",
@@ -1420,6 +1528,14 @@ const dicts = {
1420
1528
  "cmp.paper.url": "URL: {value}",
1421
1529
  "cmp.paper.pdf": "PDF: {value}",
1422
1530
  "cmp.paper.abstract": "Abstract: {value}",
1531
+ "cmp.task.header": "[Literature task] Continue using the existing task below; do not create a new task",
1532
+ "cmp.task.id": "Task id: {value}",
1533
+ "cmp.task.title": "Task title: {value}",
1534
+ "cmp.task.rounds": "{n} rounds so far, {loaded} papers loaded",
1535
+ "cmp.task.queries": "Current queries:",
1536
+ "cmp.task.conditions": "Conditions: sources {sources}; {pageSize} per source; years {years}; {access}; language {language}; sort {sort}",
1537
+ "cmp.task.anyAccess": "any access",
1538
+ "cmp.task.continue": "To adjust conditions and search again, call literature_search with taskId \"{id}\" to start a new round inside this task; create a new task only when I explicitly ask for a new query task.",
1423
1539
  "rounds.open": "Search rounds",
1424
1540
  "rounds.title": "Search rounds",
1425
1541
  "rounds.desc": "{n} rounds; metadata only, results are always from the latest round",
@@ -1441,7 +1557,7 @@ const dicts = {
1441
1557
  "rounds.badge": "{n} rounds",
1442
1558
  "taskList.open": "Search tasks",
1443
1559
  "taskList.title": "Search tasks",
1444
- "taskList.desc": "One research topic = one task = one tab. Open, rename or delete tasks.",
1560
+ "taskList.desc": "One research topic = one task. Each conversation keeps a single task tab — switch tasks here.",
1445
1561
  "taskList.count": "{n} tasks",
1446
1562
  "taskList.close": "Close",
1447
1563
  "taskList.loading": "Loading tasks…",
@@ -1464,12 +1580,12 @@ const dicts = {
1464
1580
  const ACCENT = "#508cff";
1465
1581
  /** 错误色。 */
1466
1582
  const ERROR = "#d64545";
1467
- /** 面板根容器。 */
1583
+ /** 面板根容器(主页 / 任务页 / 论文页共用):上下 10px、左右 15px 页面边距。 */
1468
1584
  const panelStyle = {
1469
1585
  display: "flex",
1470
1586
  flexDirection: "column",
1471
1587
  gap: 10,
1472
- padding: "4px 2px 12px",
1588
+ padding: "10px 15px",
1473
1589
  minHeight: 0,
1474
1590
  fontSize: 13
1475
1591
  };
@@ -1507,10 +1623,10 @@ const listStyle$1 = {
1507
1623
  flexDirection: "column",
1508
1624
  gap: 0
1509
1625
  };
1510
- /** 单条结果卡片。 */
1626
+ /** 单条结果卡片:顶线即文献之间的分割线,透明度需足够高才看得清。 */
1511
1627
  const cardStyle = {
1512
1628
  padding: "10px 0",
1513
- borderTop: "1px solid rgba(127,127,127,0.18)"
1629
+ borderTop: "1px solid rgba(127,127,127,0.5)"
1514
1630
  };
1515
1631
  /** 标题。 */
1516
1632
  const titleStyle = {
@@ -1623,7 +1739,7 @@ function formatDate$3(value) {
1623
1739
  const date = new Date(value);
1624
1740
  return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
1625
1741
  }
1626
- function TaskListDialog({ t, open, onClose, currentTaskId, onOpenTask }) {
1742
+ function TaskListDialog({ t, open, onClose, currentTaskId, onOpenTask, onCurrentRemoved }) {
1627
1743
  const version = useTaskListVersion();
1628
1744
  const [tasks, setTasks] = (0, react.useState)([]);
1629
1745
  const [loading, setLoading] = (0, react.useState)(false);
@@ -1690,11 +1806,17 @@ function TaskListDialog({ t, open, onClose, currentTaskId, onOpenTask }) {
1690
1806
  const remove = async (task) => {
1691
1807
  if (busyId !== null) return;
1692
1808
  setBusyId(task.id);
1809
+ const index = tasks.findIndex((entry) => entry.id === task.id);
1693
1810
  try {
1694
1811
  await deleteTask(task.id);
1695
1812
  forgetTask(task.id);
1696
1813
  notifyTaskListChanged();
1697
1814
  setConfirmDeleteId(null);
1815
+ if (task.id === currentTaskId) {
1816
+ const remaining = tasks.filter((entry) => entry.id !== task.id);
1817
+ const fallback = index < 0 ? null : remaining[index - 1] ?? remaining[index] ?? null;
1818
+ onCurrentRemoved?.(fallback);
1819
+ }
1698
1820
  await load();
1699
1821
  } catch (err) {
1700
1822
  setError(messageOf$4(err));
@@ -2474,6 +2596,36 @@ function paperText(t, paper) {
2474
2596
  function papersText(t, papers) {
2475
2597
  return papers.map((paper, index) => `${index + 1}. ${paperText(t, paper)}`).join("\n\n");
2476
2598
  }
2599
+ /**
2600
+ * 把任务带到会话:写入任务 id 与当前条件,让新会话能明确续接同一任务。
2601
+ *
2602
+ * 模型侧只要收到带 taskId 的指令就不会新建任务,因此这里把「不要新建」和
2603
+ * 续接方式都写清楚。
2604
+ */
2605
+ function taskText(t, detail) {
2606
+ const conditions = detail.conditions;
2607
+ const queries = conditions.queries.filter((query) => query.enabled && query.text.trim() !== "").map((query) => `- ${query.name}: ${query.text}`);
2608
+ const lines = [
2609
+ t("cmp.task.header"),
2610
+ tpl(t("cmp.task.id"), { value: detail.id }),
2611
+ tpl(t("cmp.task.title"), { value: detail.title }),
2612
+ tpl(t("cmp.task.rounds"), {
2613
+ n: detail.roundCount ?? 0,
2614
+ loaded: detail.result.totalLoaded
2615
+ })
2616
+ ];
2617
+ if (queries.length > 0) lines.push(t("cmp.task.queries"), ...queries);
2618
+ lines.push(tpl(t("cmp.task.conditions"), {
2619
+ sources: conditions.sources.map((id) => t(`source.${id}`)).join("、"),
2620
+ pageSize: conditions.pageSize,
2621
+ years: `${conditions.yearFrom ?? t("filter.any")}-${conditions.yearTo ?? t("filter.any")}`,
2622
+ access: conditions.openAccessOnly ? t("filter.openAccess") : t("cmp.task.anyAccess"),
2623
+ language: t(`lang.${conditions.language}`),
2624
+ sort: t(`sort.${conditions.sort}`)
2625
+ }));
2626
+ lines.push("", tpl(t("cmp.task.continue"), { id: detail.id }));
2627
+ return lines.join("\n");
2628
+ }
2477
2629
  function truncate(value, max) {
2478
2630
  return value.length <= max ? value : `${value.slice(0, max)}…`;
2479
2631
  }
@@ -2566,7 +2718,7 @@ function EntityPanel({ t, taskId, paperId, pmid }) {
2566
2718
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2567
2719
  style: CHIPS_STYLE,
2568
2720
  children: items.map((entity) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2569
- style: CHIP_STYLE$3,
2721
+ style: CHIP_STYLE$2,
2570
2722
  title: entityTitle(entity),
2571
2723
  children: [entity.text, entity.count > 1 ? ` ×${entity.count}` : ""]
2572
2724
  }, `${entity.identifier}\u0000${entity.text}`))
@@ -2627,7 +2779,7 @@ const CHIPS_STYLE = {
2627
2779
  gap: 4,
2628
2780
  minWidth: 0
2629
2781
  };
2630
- const CHIP_STYLE$3 = {
2782
+ const CHIP_STYLE$2 = {
2631
2783
  padding: "0 6px",
2632
2784
  borderRadius: 9,
2633
2785
  border: "1px solid rgba(127,127,127,0.28)",
@@ -2935,8 +3087,9 @@ function PaperPane({ t, useTabInfo, inputActions }) {
2935
3087
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
2936
3088
  style: SELECT_STYLE$3,
2937
3089
  value: state?.groupId ?? "",
2938
- disabled: busy || detail.groups.length === 0,
3090
+ disabled: busy,
2939
3091
  "aria-label": t("annotate.group"),
3092
+ title: detail.groups.length === 0 ? t("annotate.groupHint") : t("annotate.group"),
2940
3093
  onChange: (event) => annotate({
2941
3094
  paperId: paper.paperId,
2942
3095
  groupId: event.target.value === "" ? null : event.target.value
@@ -2961,7 +3114,7 @@ function PaperPane({ t, useTabInfo, inputActions }) {
2961
3114
  style: EDIT_STYLE$1,
2962
3115
  children: [
2963
3116
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2964
- style: FIELD_STYLE$3,
3117
+ style: FIELD_STYLE$4,
2965
3118
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("annotate.note") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
2966
3119
  style: NOTE_STYLE$1,
2967
3120
  value: note,
@@ -2971,7 +3124,7 @@ function PaperPane({ t, useTabInfo, inputActions }) {
2971
3124
  })]
2972
3125
  }),
2973
3126
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2974
- style: FIELD_STYLE$3,
3127
+ style: FIELD_STYLE$4,
2975
3128
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("annotate.tags") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2976
3129
  style: INPUT_STYLE$3,
2977
3130
  value: tags,
@@ -2981,7 +3134,7 @@ function PaperPane({ t, useTabInfo, inputActions }) {
2981
3134
  }),
2982
3135
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2983
3136
  style: {
2984
- ...FIELD_STYLE$3,
3137
+ ...FIELD_STYLE$4,
2985
3138
  maxWidth: 120
2986
3139
  },
2987
3140
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("annotate.shortlistOrder") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
@@ -3192,7 +3345,7 @@ const EDIT_STYLE$1 = {
3192
3345
  borderRadius: 6,
3193
3346
  background: "rgba(127,127,127,0.07)"
3194
3347
  };
3195
- const FIELD_STYLE$3 = {
3348
+ const FIELD_STYLE$4 = {
3196
3349
  display: "flex",
3197
3350
  flexDirection: "column",
3198
3351
  gap: 2,
@@ -4374,7 +4527,7 @@ function CompareTab({ t, taskId, fromTaskId, rev, busy = false, onOnlyNew, onInc
4374
4527
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4375
4528
  style: PICK_ROW_STYLE,
4376
4529
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4377
- style: FIELD_STYLE$2,
4530
+ style: FIELD_STYLE$3,
4378
4531
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("compare.base") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
4379
4532
  style: SELECT_STYLE$2,
4380
4533
  value: baseId,
@@ -4492,7 +4645,7 @@ const PICK_ROW_STYLE = {
4492
4645
  gap: 8,
4493
4646
  flexWrap: "wrap"
4494
4647
  };
4495
- const FIELD_STYLE$2 = {
4648
+ const FIELD_STYLE$3 = {
4496
4649
  display: "flex",
4497
4650
  flexDirection: "column",
4498
4651
  gap: 2,
@@ -4693,7 +4846,7 @@ function ConditionEditor({ t, detail, onSaved, onCancel }) {
4693
4846
  style: ROW_STYLE$1,
4694
4847
  children: [
4695
4848
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4696
- style: FIELD_STYLE$1,
4849
+ style: FIELD_STYLE$2,
4697
4850
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4698
4851
  style: FIELD_LABEL_STYLE,
4699
4852
  children: t("condition.pageSize")
@@ -4707,7 +4860,7 @@ function ConditionEditor({ t, detail, onSaved, onCancel }) {
4707
4860
  })]
4708
4861
  }),
4709
4862
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4710
- style: FIELD_STYLE$1,
4863
+ style: FIELD_STYLE$2,
4711
4864
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4712
4865
  style: FIELD_LABEL_STYLE,
4713
4866
  children: t("condition.yearFrom")
@@ -4720,7 +4873,7 @@ function ConditionEditor({ t, detail, onSaved, onCancel }) {
4720
4873
  })]
4721
4874
  }),
4722
4875
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4723
- style: FIELD_STYLE$1,
4876
+ style: FIELD_STYLE$2,
4724
4877
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4725
4878
  style: FIELD_LABEL_STYLE,
4726
4879
  children: t("condition.yearTo")
@@ -4738,7 +4891,7 @@ function ConditionEditor({ t, detail, onSaved, onCancel }) {
4738
4891
  style: ROW_STYLE$1,
4739
4892
  children: [
4740
4893
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4741
- style: CHECK_STYLE$1,
4894
+ style: CHECK_STYLE$2,
4742
4895
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4743
4896
  type: "checkbox",
4744
4897
  checked: openAccessOnly,
@@ -4746,7 +4899,7 @@ function ConditionEditor({ t, detail, onSaved, onCancel }) {
4746
4899
  }), t("condition.openAccess")]
4747
4900
  }),
4748
4901
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4749
- style: FIELD_STYLE$1,
4902
+ style: FIELD_STYLE$2,
4750
4903
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4751
4904
  style: FIELD_LABEL_STYLE,
4752
4905
  children: t("filter.language")
@@ -4761,7 +4914,7 @@ function ConditionEditor({ t, detail, onSaved, onCancel }) {
4761
4914
  })]
4762
4915
  }),
4763
4916
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4764
- style: FIELD_STYLE$1,
4917
+ style: FIELD_STYLE$2,
4765
4918
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4766
4919
  style: FIELD_LABEL_STYLE,
4767
4920
  children: t("filter.sort")
@@ -4779,7 +4932,7 @@ function ConditionEditor({ t, detail, onSaved, onCancel }) {
4779
4932
  }),
4780
4933
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4781
4934
  style: {
4782
- ...FIELD_STYLE$1,
4935
+ ...FIELD_STYLE$2,
4783
4936
  flexDirection: "column",
4784
4937
  alignItems: "stretch"
4785
4938
  },
@@ -4842,7 +4995,7 @@ const ROW_STYLE$1 = {
4842
4995
  flexWrap: "wrap",
4843
4996
  gap: 8
4844
4997
  };
4845
- const FIELD_STYLE$1 = {
4998
+ const FIELD_STYLE$2 = {
4846
4999
  display: "flex",
4847
5000
  alignItems: "center",
4848
5001
  gap: 4
@@ -4864,7 +5017,7 @@ const SELECT_STYLE$1 = {
4864
5017
  background: "transparent",
4865
5018
  color: "inherit"
4866
5019
  };
4867
- const CHECK_STYLE$1 = {
5020
+ const CHECK_STYLE$2 = {
4868
5021
  display: "flex",
4869
5022
  alignItems: "center",
4870
5023
  gap: 4,
@@ -4872,10 +5025,11 @@ const CHECK_STYLE$1 = {
4872
5025
  };
4873
5026
  // src/client/FullTextTab.tsx
4874
5027
  /**
4875
- * 「全文」台账:与概览覆盖同口径列出缺全文的文献及失败原因。
5028
+ * 「全文」台账:全任务口径的覆盖率 + 可按状态切换的补抓队列。
4876
5029
  *
4877
- * 列表只取缺全文的一页,避免把整个任务拉进组件;下载 / Zotero 仍走父组件的
4878
- * 同一套操作,完成后任务 rev 变化会触发本页重取。
5030
+ * 统计由父组件一次取回(与结果页的覆盖率横幅共用同一份数据),本页只按视图
5031
+ * 拉列表;切换视图或任务 rev 变化时重置分页。下载 / Zotero 走父组件的同一套
5032
+ * 操作,完成后 rev 变化会触发本页重取。
4879
5033
  */
4880
5034
  const STATUSES = [
4881
5035
  "unknown",
@@ -4886,159 +5040,284 @@ const STATUSES = [
4886
5040
  "blocked",
4887
5041
  "failed"
4888
5042
  ];
4889
- function FullTextTab({ t, taskId, rev, states, onDownload, onZotero, onShowMissing }) {
4890
- const [state, setState] = (0, react.useState)({
4891
- stats: null,
4892
- page: null,
4893
- loading: true,
4894
- error: null
4895
- });
5043
+ const PAGE_SIZE = 100;
5044
+ const EMPTY_LIST = {
5045
+ papers: [],
5046
+ total: 0,
5047
+ loading: true,
5048
+ loadingMore: false,
5049
+ error: null
5050
+ };
5051
+ function viewQuery(view) {
5052
+ return view === "missing" ? { missingFullText: true } : { pdfStatus: view };
5053
+ }
5054
+ function FullTextTab({ t, taskId, rev, states, stats, onDownload, onZotero, onBatchDownload, onBatchZotero, onShowInResults }) {
5055
+ const [view, setView] = (0, react.useState)("missing");
5056
+ const [list, setList] = (0, react.useState)(EMPTY_LIST);
5057
+ const [selected, setSelected] = (0, react.useState)(/* @__PURE__ */ new Set());
5058
+ const seqRef = (0, react.useRef)(0);
4896
5059
  (0, react.useEffect)(() => {
5060
+ const seq = ++seqRef.current;
4897
5061
  const controller = new AbortController();
4898
- setState((previous) => ({
4899
- ...previous,
4900
- loading: true,
4901
- error: null
4902
- }));
4903
- Promise.all([fetchStats(taskId, {}, "task", controller.signal), fetchResults(taskId, {
4904
- missingFullText: true,
4905
- limit: 100
4906
- }, controller.signal)]).then(([stats, page]) => {
4907
- if (!controller.signal.aborted) setState({
4908
- stats,
4909
- page,
5062
+ setList(EMPTY_LIST);
5063
+ setSelected(/* @__PURE__ */ new Set());
5064
+ fetchResults(taskId, {
5065
+ ...viewQuery(view),
5066
+ limit: PAGE_SIZE
5067
+ }, controller.signal).then((page) => {
5068
+ if (seqRef.current !== seq || controller.signal.aborted) return;
5069
+ setList({
5070
+ papers: page.papers,
5071
+ total: page.total,
4910
5072
  loading: false,
5073
+ loadingMore: false,
4911
5074
  error: null
4912
5075
  });
4913
5076
  }).catch((error) => {
4914
- if (controller.signal.aborted) return;
4915
- setState({
4916
- stats: null,
4917
- page: null,
5077
+ if (seqRef.current !== seq || controller.signal.aborted) return;
5078
+ setList({
5079
+ ...EMPTY_LIST,
4918
5080
  loading: false,
4919
5081
  error: error instanceof Error ? error.message : String(error)
4920
5082
  });
4921
5083
  });
4922
- return () => {
4923
- controller.abort();
4924
- };
4925
- }, [taskId, rev]);
4926
- if (state.loading && state.stats === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5084
+ return () => controller.abort();
5085
+ }, [
5086
+ taskId,
5087
+ rev,
5088
+ view
5089
+ ]);
5090
+ const loadMore = () => {
5091
+ if (list.loading || list.loadingMore || list.papers.length >= list.total) return;
5092
+ const seq = seqRef.current;
5093
+ const offset = list.papers.length;
5094
+ setList((previous) => ({
5095
+ ...previous,
5096
+ loadingMore: true
5097
+ }));
5098
+ fetchResults(taskId, {
5099
+ ...viewQuery(view),
5100
+ offset,
5101
+ limit: PAGE_SIZE
5102
+ }).then((page) => {
5103
+ if (seqRef.current !== seq) return;
5104
+ setList((previous) => ({
5105
+ ...previous,
5106
+ papers: [...previous.papers, ...page.papers],
5107
+ total: page.total,
5108
+ loadingMore: false
5109
+ }));
5110
+ }).catch((error) => {
5111
+ if (seqRef.current !== seq) return;
5112
+ setList((previous) => ({
5113
+ ...previous,
5114
+ loadingMore: false,
5115
+ error: error instanceof Error ? error.message : String(error)
5116
+ }));
5117
+ });
5118
+ };
5119
+ const toggleSelect = (paperId) => {
5120
+ setSelected((previous) => {
5121
+ const next = new Set(previous);
5122
+ if (next.has(paperId)) next.delete(paperId);
5123
+ else next.add(paperId);
5124
+ return next;
5125
+ });
5126
+ };
5127
+ if (list.loading && list.papers.length === 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4927
5128
  style: TEXT_STYLE$1,
4928
5129
  children: t("fulltext.loading")
4929
5130
  });
4930
- if (state.error !== null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4931
- style: {
4932
- ...TEXT_STYLE$1,
4933
- color: "#d64545"
4934
- },
4935
- children: state.error
4936
- });
4937
- const stats = state.stats;
4938
- const page = state.page;
4939
5131
  const coverage = stats?.fullText;
5132
+ const availableCount = coverage === void 0 ? 0 : coverage.available + coverage.downloaded + coverage.htmlOnly;
5133
+ const missingCount = coverage === void 0 ? 0 : coverage.unknown + coverage.paywalled + coverage.blocked + coverage.failed;
5134
+ const selectedPapers = list.papers.filter((paper) => selected.has(paper.paperId));
5135
+ const viewLabel = view === "missing" ? t("fulltext.missing") : t(`pdf.status.${view}`);
4940
5136
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4941
5137
  style: WRAP_STYLE$4,
4942
5138
  children: [
4943
5139
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4944
5140
  style: HINT_STYLE,
4945
- children: t("fulltext.hint")
5141
+ children: coverage === void 0 ? t("fulltext.hintFallback") : tpl(t("fulltext.hint"), { total: stats?.loaded ?? 0 })
4946
5142
  }),
4947
- coverage !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5143
+ coverage !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4948
5144
  style: CHIP_ROW_STYLE$1,
4949
- children: STATUSES.map((status) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4950
- style: CHIP_STYLE$2,
5145
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
5146
+ type: "button",
5147
+ style: view === "missing" ? CHIP_ACTIVE_STYLE : CHIP_BUTTON_STYLE$1,
5148
+ onClick: () => setView("missing"),
5149
+ children: [
5150
+ t("fulltext.missing"),
5151
+ " ",
5152
+ missingCount
5153
+ ]
5154
+ }), STATUSES.map((status) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
5155
+ type: "button",
5156
+ style: view === status ? CHIP_ACTIVE_STYLE : CHIP_BUTTON_STYLE$1,
5157
+ onClick: () => setView(status),
4951
5158
  children: [
4952
5159
  t(`pdf.status.${status}`),
4953
5160
  " ",
4954
5161
  coverage[statusKey(status)]
4955
5162
  ]
4956
- }, status))
4957
- }),
4958
- coverage !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5163
+ }, status))]
5164
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4959
5165
  style: TEXT_STYLE$1,
4960
5166
  children: tpl(t("fulltext.coverage"), {
4961
- available: coverage.available + coverage.downloaded,
5167
+ available: availableCount,
4962
5168
  total: stats?.loaded ?? 0
4963
5169
  })
4964
- }),
5170
+ })] }),
4965
5171
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4966
5172
  style: ROW_HEAD_STYLE,
4967
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("fulltext.missing") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5173
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
5174
+ viewLabel,
5175
+ "(",
5176
+ list.total,
5177
+ ")"
5178
+ ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4968
5179
  type: "button",
4969
5180
  style: LINK_STYLE,
4970
- onClick: onShowMissing,
4971
- children: t("fulltext.showInResults")
5181
+ onClick: () => onShowInResults(view),
5182
+ children: tpl(t("fulltext.showInResults"), { n: list.total })
4972
5183
  })]
4973
5184
  }),
4974
- page === null || page.papers.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5185
+ selected.size > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BulkBar$1, {
5186
+ t,
5187
+ count: selected.size,
5188
+ onSelectPage: () => setSelected(new Set(list.papers.map((paper) => paper.paperId))),
5189
+ onClear: () => setSelected(/* @__PURE__ */ new Set()),
5190
+ onDownload: () => onBatchDownload(selectedPapers),
5191
+ onZotero: () => onBatchZotero(selectedPapers)
5192
+ }),
5193
+ list.error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5194
+ style: {
5195
+ ...TEXT_STYLE$1,
5196
+ color: "#d64545"
5197
+ },
5198
+ children: list.error
5199
+ }),
5200
+ list.papers.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4975
5201
  style: TEXT_STYLE$1,
4976
5202
  children: t("fulltext.none")
4977
5203
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
4978
5204
  style: LIST_STYLE$2,
4979
- children: page.papers.map((paper) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FullTextRow, {
5205
+ children: list.papers.map((paper) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FullTextRow, {
4980
5206
  t,
4981
5207
  paper,
5208
+ selected: selected.has(paper.paperId),
4982
5209
  status: paperStatus(states, paper),
4983
5210
  error: states[paper.paperId]?.pdfError,
5211
+ onToggleSelect: toggleSelect,
4984
5212
  onDownload,
4985
5213
  onZotero
4986
5214
  }, paper.paperId))
4987
5215
  }),
4988
- page !== null && page.total > page.papers.length && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4989
- style: TEXT_STYLE$1,
4990
- children: tpl(t("fulltext.more"), {
4991
- shown: page.papers.length,
4992
- total: page.total
5216
+ list.papers.length < list.total && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5217
+ type: "button",
5218
+ style: MORE_STYLE,
5219
+ disabled: list.loadingMore,
5220
+ onClick: loadMore,
5221
+ children: tpl(t("fulltext.loadMore"), {
5222
+ shown: list.papers.length,
5223
+ total: list.total
4993
5224
  })
4994
5225
  })
4995
5226
  ]
4996
5227
  });
4997
5228
  }
4998
- function FullTextRow({ t, paper, status, error, onDownload, onZotero }) {
4999
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
5000
- style: ROW_STYLE,
5001
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5002
- style: ROW_MAIN_STYLE,
5003
- children: [
5004
- paper.url === "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5005
- style: ROW_TITLE_STYLE,
5006
- children: paper.title
5007
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
5008
- style: ROW_TITLE_STYLE,
5009
- href: paper.url,
5010
- target: "_blank",
5011
- rel: "noreferrer noopener",
5012
- children: paper.title
5013
- }),
5014
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5015
- style: ROW_META_STYLE,
5016
- children: [
5017
- [paper.year === null ? "" : String(paper.year), paper.venue].filter((part) => part !== "").join(" · "),
5018
- " ",
5019
- "· ",
5020
- t(`pdf.status.${status}`)
5021
- ]
5022
- }),
5023
- error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5024
- style: ROW_ERROR_STYLE,
5025
- children: error
5026
- })
5027
- ]
5028
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5029
- style: ROW_ACTIONS_STYLE,
5030
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5229
+ function BulkBar$1({ t, count, onSelectPage, onClear, onDownload, onZotero }) {
5230
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5231
+ style: BULK_STYLE$1,
5232
+ children: [
5233
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", {
5234
+ style: { fontSize: 12 },
5235
+ children: tpl(t("bulk.count"), { n: count })
5236
+ }),
5237
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5031
5238
  type: "button",
5032
5239
  style: LINK_STYLE,
5033
- onClick: () => onDownload(paper),
5034
- children: t("pdf.download")
5035
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5240
+ onClick: onSelectPage,
5241
+ children: t("bulk.selectPage")
5242
+ }),
5243
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5036
5244
  type: "button",
5037
5245
  style: LINK_STYLE,
5038
- onClick: () => onZotero(paper),
5039
- children: t("pdf.zotero")
5040
- })]
5041
- })]
5246
+ onClick: onClear,
5247
+ children: t("bulk.clear")
5248
+ }),
5249
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5250
+ type: "button",
5251
+ style: LINK_STYLE,
5252
+ onClick: onDownload,
5253
+ children: t("bulk.download")
5254
+ }),
5255
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5256
+ type: "button",
5257
+ style: LINK_STYLE,
5258
+ onClick: onZotero,
5259
+ children: t("bulk.zotero")
5260
+ })
5261
+ ]
5262
+ });
5263
+ }
5264
+ function FullTextRow({ t, paper, selected, status, error, onToggleSelect, onDownload, onZotero }) {
5265
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
5266
+ style: ROW_STYLE,
5267
+ children: [
5268
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5269
+ type: "checkbox",
5270
+ style: CHECK_STYLE$1,
5271
+ checked: selected,
5272
+ "aria-label": t("bulk.select"),
5273
+ onChange: () => onToggleSelect(paper.paperId)
5274
+ }),
5275
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5276
+ style: {
5277
+ ...ROW_MAIN_STYLE,
5278
+ flex: 1
5279
+ },
5280
+ children: [
5281
+ paper.url === "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5282
+ style: ROW_TITLE_STYLE,
5283
+ children: paper.title
5284
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
5285
+ style: ROW_TITLE_STYLE,
5286
+ href: paper.url,
5287
+ target: "_blank",
5288
+ rel: "noreferrer noopener",
5289
+ children: paper.title
5290
+ }),
5291
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5292
+ style: ROW_META_STYLE,
5293
+ children: [
5294
+ [paper.year === null ? "" : String(paper.year), paper.venue].filter((part) => part !== "").join(" · "),
5295
+ " ",
5296
+ "· ",
5297
+ t(`pdf.status.${status}`)
5298
+ ]
5299
+ }),
5300
+ error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5301
+ style: ROW_ERROR_STYLE,
5302
+ children: error
5303
+ })
5304
+ ]
5305
+ }),
5306
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5307
+ style: ROW_ACTIONS_STYLE,
5308
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5309
+ type: "button",
5310
+ style: LINK_STYLE,
5311
+ onClick: () => onDownload(paper),
5312
+ children: t("pdf.download")
5313
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5314
+ type: "button",
5315
+ style: LINK_STYLE,
5316
+ onClick: () => onZotero(paper),
5317
+ children: t("pdf.zotero")
5318
+ })]
5319
+ })
5320
+ ]
5042
5321
  });
5043
5322
  }
5044
5323
  function paperStatus(states, paper) {
@@ -5067,11 +5346,20 @@ const CHIP_ROW_STYLE$1 = {
5067
5346
  flexWrap: "wrap",
5068
5347
  gap: 4
5069
5348
  };
5070
- const CHIP_STYLE$2 = {
5349
+ const CHIP_BUTTON_STYLE$1 = {
5071
5350
  fontSize: 11,
5072
5351
  padding: "1px 6px",
5073
5352
  borderRadius: 4,
5074
- border: "1px solid rgba(127,127,127,0.28)"
5353
+ border: "1px solid rgba(127,127,127,0.28)",
5354
+ background: "transparent",
5355
+ color: "inherit",
5356
+ cursor: "pointer"
5357
+ };
5358
+ const CHIP_ACTIVE_STYLE = {
5359
+ ...CHIP_BUTTON_STYLE$1,
5360
+ borderColor: "#508cff",
5361
+ color: "#508cff",
5362
+ fontWeight: 600
5075
5363
  };
5076
5364
  const ROW_HEAD_STYLE = {
5077
5365
  display: "flex",
@@ -5091,16 +5379,40 @@ const LINK_STYLE = {
5091
5379
  cursor: "pointer",
5092
5380
  whiteSpace: "nowrap"
5093
5381
  };
5382
+ const BULK_STYLE$1 = {
5383
+ display: "flex",
5384
+ alignItems: "center",
5385
+ flexWrap: "wrap",
5386
+ gap: 8,
5387
+ padding: "4px 6px",
5388
+ borderRadius: 6,
5389
+ background: "rgba(80,140,255,0.08)",
5390
+ border: "1px solid rgba(80,140,255,0.25)"
5391
+ };
5392
+ const MORE_STYLE = {
5393
+ alignSelf: "flex-start",
5394
+ marginTop: 4,
5395
+ padding: "3px 8px",
5396
+ borderRadius: 4,
5397
+ border: "1px solid rgba(127,127,127,0.28)",
5398
+ background: "transparent",
5399
+ color: "inherit",
5400
+ fontSize: 11,
5401
+ cursor: "pointer"
5402
+ };
5094
5403
  const LIST_STYLE$2 = {
5095
5404
  margin: 0,
5096
5405
  padding: 0,
5097
5406
  listStyle: "none"
5098
5407
  };
5408
+ const CHECK_STYLE$1 = {
5409
+ marginTop: 4,
5410
+ flex: "0 0 auto"
5411
+ };
5099
5412
  const ROW_STYLE = {
5100
5413
  display: "flex",
5101
5414
  alignItems: "flex-start",
5102
- justifyContent: "space-between",
5103
- gap: 8,
5415
+ gap: 6,
5104
5416
  padding: "5px 0",
5105
5417
  borderBottom: "1px solid rgba(127,127,127,0.1)"
5106
5418
  };
@@ -5136,8 +5448,8 @@ const ROW_ACTIONS_STYLE = {
5136
5448
  /**
5137
5449
  * 任务「分组」tab:维护「为什么读」的分类,并把文献归入其中。
5138
5450
  *
5139
- * 分组是任务内概念;归组在结果卡片的标注区完成,这里只负责增删改名、排序,
5140
- * 以及按组跳到结果页筛选。保存前全部改动只落在本地草稿。
5451
+ * 分组是任务内概念;归组在结果卡片的标注区完成,这里负责增删改名、排序。
5452
+ * 增删与排序立即保存,名称和说明在输入框失焦时保存——切到结果页或别处不会丢改动。
5141
5453
  */
5142
5454
  function draftOf(groups) {
5143
5455
  return groups.map((group) => ({ ...group }));
@@ -5149,9 +5461,9 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5149
5461
  const [busy, setBusy] = (0, react.useState)(false);
5150
5462
  const [error, setError] = (0, react.useState)(null);
5151
5463
  const [toast, setToast] = (0, react.useState)("");
5152
- (0, react.useEffect)(() => {
5153
- setDraft(draftOf(detail.groups));
5154
- }, [detail.rev]);
5464
+ const latest = (0, react.useRef)(draft);
5465
+ const queued = (0, react.useRef)(false);
5466
+ const saving = (0, react.useRef)(false);
5155
5467
  const usage = (0, react.useMemo)(() => {
5156
5468
  const counts = /* @__PURE__ */ new Map();
5157
5469
  for (const state of Object.values(detail.states)) {
@@ -5160,15 +5472,37 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5160
5472
  }
5161
5473
  return counts;
5162
5474
  }, [detail.states]);
5163
- const dirty = JSON.stringify(draft.map((group) => [
5164
- group.id,
5165
- group.name,
5166
- group.description ?? ""
5167
- ])) !== JSON.stringify(detail.groups.map((group) => [
5168
- group.id,
5169
- group.name,
5170
- group.description ?? ""
5171
- ]));
5475
+ const persist = (0, react.useCallback)((next) => {
5476
+ latest.current = next.map((group, order) => ({
5477
+ ...group,
5478
+ order
5479
+ }));
5480
+ queued.current = true;
5481
+ if (saving.current) return;
5482
+ saving.current = true;
5483
+ setBusy(true);
5484
+ setError(null);
5485
+ setToast("");
5486
+ (async () => {
5487
+ try {
5488
+ while (queued.current) {
5489
+ queued.current = false;
5490
+ await updateTask(taskId, { groups: latest.current.map((group) => ({ ...group })) });
5491
+ }
5492
+ setToast(t("groups.saved"));
5493
+ onSaved();
5494
+ } catch (reason) {
5495
+ setError(reason instanceof Error ? reason.message : String(reason));
5496
+ } finally {
5497
+ saving.current = false;
5498
+ setBusy(false);
5499
+ }
5500
+ })();
5501
+ }, [
5502
+ taskId,
5503
+ t,
5504
+ onSaved
5505
+ ]);
5172
5506
  const add = () => {
5173
5507
  const trimmed = name.trim();
5174
5508
  if (trimmed === "") return;
@@ -5179,43 +5513,30 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5179
5513
  };
5180
5514
  const desc = description.trim();
5181
5515
  if (desc !== "") group.description = desc.slice(0, 200);
5182
- setDraft((previous) => [...previous, group]);
5516
+ const next = [...draft, group];
5517
+ setDraft(next);
5183
5518
  setName("");
5184
5519
  setDescription("");
5185
- setToast(t("groups.added"));
5520
+ persist(next);
5186
5521
  };
5187
5522
  const move = (index, delta) => {
5188
- setDraft((previous) => {
5189
- const target = index + delta;
5190
- if (target < 0 || target >= previous.length) return previous;
5191
- const next = [...previous];
5192
- const [item] = next.splice(index, 1);
5193
- next.splice(target, 0, item);
5194
- return next.map((group, order) => ({
5195
- ...group,
5196
- order
5197
- }));
5198
- });
5523
+ const target = index + delta;
5524
+ if (target < 0 || target >= draft.length) return;
5525
+ const next = [...draft];
5526
+ const [item] = next.splice(index, 1);
5527
+ next.splice(target, 0, item);
5528
+ const ordered = next.map((group, order) => ({
5529
+ ...group,
5530
+ order
5531
+ }));
5532
+ setDraft(ordered);
5533
+ persist(ordered);
5199
5534
  };
5200
5535
  const remove = (group) => {
5201
5536
  if (!window.confirm(tpl(t("groups.deleteConfirm"), { name: group.name }))) return;
5202
- setDraft((previous) => previous.filter((entry) => entry.id !== group.id));
5203
- };
5204
- const save = () => {
5205
- if (busy) return;
5206
- setBusy(true);
5207
- setError(null);
5208
- updateTask(taskId, { groups: draft.map((group, order) => ({
5209
- ...group,
5210
- order
5211
- })) }).then(() => {
5212
- setToast(t("groups.saved"));
5213
- onSaved();
5214
- }).catch((reason) => {
5215
- setError(reason instanceof Error ? reason.message : String(reason));
5216
- }).finally(() => {
5217
- setBusy(false);
5218
- });
5537
+ const next = draft.filter((entry) => entry.id !== group.id);
5538
+ setDraft(next);
5539
+ persist(next);
5219
5540
  };
5220
5541
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5221
5542
  style: WRAP_STYLE$3,
@@ -5227,28 +5548,48 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5227
5548
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5228
5549
  style: ADD_ROW_STYLE,
5229
5550
  children: [
5230
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5231
- style: INPUT_STYLE$2,
5232
- value: name,
5233
- placeholder: t("groups.namePlaceholder"),
5234
- onChange: (event) => setName(event.target.value)
5235
- }),
5236
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5237
- style: INPUT_STYLE$2,
5238
- value: description,
5239
- placeholder: t("groups.descPlaceholder"),
5240
- onChange: (event) => setDescription(event.target.value),
5241
- onKeyDown: (event) => {
5242
- if (event.key === "Enter") {
5243
- event.preventDefault();
5244
- add();
5551
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5552
+ style: FIELD_STYLE$1,
5553
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5554
+ style: LABEL_STYLE,
5555
+ children: t("groups.name")
5556
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5557
+ style: INPUT_STYLE$2,
5558
+ value: name,
5559
+ placeholder: t("groups.namePlaceholder"),
5560
+ "aria-label": t("groups.name"),
5561
+ onChange: (event) => setName(event.target.value),
5562
+ onKeyDown: (event) => {
5563
+ if (event.key === "Enter") {
5564
+ event.preventDefault();
5565
+ add();
5566
+ }
5245
5567
  }
5246
- }
5568
+ })]
5247
5569
  }),
5248
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
5249
- variant: "outline",
5250
- size: "sm",
5251
- type: "button",
5570
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5571
+ style: FIELD_STYLE$1,
5572
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5573
+ style: LABEL_STYLE,
5574
+ children: t("groups.desc")
5575
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5576
+ style: INPUT_STYLE$2,
5577
+ value: description,
5578
+ placeholder: t("groups.descPlaceholder"),
5579
+ "aria-label": t("groups.desc"),
5580
+ onChange: (event) => setDescription(event.target.value),
5581
+ onKeyDown: (event) => {
5582
+ if (event.key === "Enter") {
5583
+ event.preventDefault();
5584
+ add();
5585
+ }
5586
+ }
5587
+ })]
5588
+ }),
5589
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
5590
+ variant: "outline",
5591
+ size: "sm",
5592
+ type: "button",
5252
5593
  onClick: add,
5253
5594
  children: t("groups.add")
5254
5595
  })
@@ -5271,23 +5612,27 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5271
5612
  fontWeight: 600
5272
5613
  },
5273
5614
  value: group.name,
5615
+ "aria-label": t("groups.name"),
5274
5616
  onChange: (event) => setDraft((previous) => previous.map((entry) => entry.id === group.id ? {
5275
5617
  ...entry,
5276
5618
  name: event.target.value
5277
- } : entry))
5619
+ } : entry)),
5620
+ onBlur: () => persist(draft)
5278
5621
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5279
5622
  style: COUNT_STYLE,
5280
5623
  children: tpl(t("groups.count"), { n: usage.get(group.id) ?? 0 })
5281
5624
  })]
5282
5625
  }),
5283
5626
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5284
- style: INPUT_STYLE$2,
5627
+ style: ROW_INPUT_STYLE,
5285
5628
  value: group.description ?? "",
5286
5629
  placeholder: t("groups.descPlaceholder"),
5630
+ "aria-label": t("groups.desc"),
5287
5631
  onChange: (event) => setDraft((previous) => previous.map((entry) => entry.id === group.id ? {
5288
5632
  ...entry,
5289
5633
  description: event.target.value
5290
- } : entry))
5634
+ } : entry)),
5635
+ onBlur: () => persist(draft)
5291
5636
  }),
5292
5637
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5293
5638
  style: ACTION_ROW_STYLE$1,
@@ -5327,24 +5672,25 @@ function GroupsTab({ t, taskId, detail, onSaved, onPickGroup }) {
5327
5672
  }),
5328
5673
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5329
5674
  style: ACTION_ROW_STYLE$1,
5330
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
5331
- variant: "outline",
5332
- size: "sm",
5333
- type: "button",
5334
- disabled: !dirty || busy,
5335
- onClick: save,
5336
- children: t("groups.save")
5337
- }), toast !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5675
+ children: [toast !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5338
5676
  style: TOAST_STYLE$1,
5339
5677
  children: toast
5678
+ }), busy && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5679
+ style: statusStyle,
5680
+ children: t("groups.saving")
5340
5681
  })]
5341
5682
  }),
5342
- error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5683
+ error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5343
5684
  style: {
5344
5685
  ...statusStyle,
5345
5686
  color: "#d64545"
5346
5687
  },
5347
- children: error
5688
+ children: [error, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5689
+ type: "button",
5690
+ style: linkButtonStyle,
5691
+ onClick: () => persist(draft),
5692
+ children: t("groups.retry")
5693
+ })]
5348
5694
  })
5349
5695
  ]
5350
5696
  });
@@ -5359,6 +5705,18 @@ const ADD_ROW_STYLE = {
5359
5705
  gap: 6,
5360
5706
  flexWrap: "wrap"
5361
5707
  };
5708
+ const FIELD_STYLE$1 = {
5709
+ display: "flex",
5710
+ alignItems: "center",
5711
+ gap: 4,
5712
+ flex: "1 1 140px",
5713
+ minWidth: 0
5714
+ };
5715
+ const LABEL_STYLE = {
5716
+ fontSize: 12,
5717
+ opacity: .7,
5718
+ whiteSpace: "nowrap"
5719
+ };
5362
5720
  const INPUT_STYLE$2 = {
5363
5721
  fontSize: 12,
5364
5722
  padding: "3px 5px",
@@ -5374,6 +5732,10 @@ const LIST_STYLE$1 = {
5374
5732
  flexDirection: "column",
5375
5733
  gap: 6
5376
5734
  };
5735
+ const ROW_INPUT_STYLE = {
5736
+ ...INPUT_STYLE$2,
5737
+ flex: "0 0 auto"
5738
+ };
5377
5739
  const GROUP_ROW_STYLE = {
5378
5740
  display: "flex",
5379
5741
  flexDirection: "column",
@@ -6050,6 +6412,8 @@ const CHIP_BUTTON_STYLE = {
6050
6412
  * 备注 / 标签 / 必读顺序收在「标注」里,避免窄栏里堆满输入框。
6051
6413
  */
6052
6414
  const ABSTRACT_PREVIEW = 260;
6415
+ /** 分组下拉里「去新建分组」的哨兵值,不会与真实 group id 冲突。 */
6416
+ const CREATE_GROUP_VALUE = "__create_group__";
6053
6417
  const SCREENINGS$2 = [
6054
6418
  "unread",
6055
6419
  "read",
@@ -6062,7 +6426,7 @@ const SCREENING_COLOR = {
6062
6426
  included: "#2e9e5b",
6063
6427
  excluded: "#d64545"
6064
6428
  };
6065
- function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, onToggleSelect, onInsert, onAnnotate, onDownload, onZotero, onOpenPaper }) {
6429
+ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, onToggleSelect, onInsert, onAnnotate, onDownload, onZotero, onOpenPaper, onManageGroups }) {
6066
6430
  const [expanded, setExpanded] = (0, react.useState)(false);
6067
6431
  const [editing, setEditing] = (0, react.useState)(false);
6068
6432
  const [entities, setEntities] = (0, react.useState)(false);
@@ -6111,63 +6475,6 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
6111
6475
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("article", {
6112
6476
  style: cardStyle,
6113
6477
  children: [
6114
- paper.url === "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6115
- style: titleStyle,
6116
- children: paper.title
6117
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
6118
- style: titleStyle,
6119
- href: paper.url,
6120
- target: "_blank",
6121
- rel: "noreferrer noopener",
6122
- children: paper.title
6123
- }),
6124
- meta !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6125
- style: metaStyle,
6126
- children: meta
6127
- }),
6128
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6129
- style: badgeRowStyle,
6130
- children: [
6131
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6132
- style: {
6133
- ...badgeStyle,
6134
- color: SCREENING_COLOR[screening]
6135
- },
6136
- children: t(`screening.${screening}`)
6137
- }),
6138
- groupName !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6139
- style: badgeStyle,
6140
- children: groupName
6141
- }),
6142
- state?.shortlistOrder !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6143
- style: badgeStyle,
6144
- children: tpl(t("annotate.shortlistBadge"), { n: state.shortlistOrder })
6145
- }),
6146
- sources.map((source) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6147
- style: badgeStyle,
6148
- children: t(`source.${source}`)
6149
- }, source)),
6150
- paper.citedBy !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6151
- style: badgeStyle,
6152
- children: tpl(t("result.citedBy"), { n: paper.citedBy })
6153
- }),
6154
- paper.openAccess && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6155
- style: badgeStyle,
6156
- children: t("result.openAccess")
6157
- }),
6158
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6159
- style: {
6160
- ...badgeStyle,
6161
- color: PDF_COLOR[pdfStatus]
6162
- },
6163
- children: t(`pdf.status.${pdfStatus}`)
6164
- })
6165
- ]
6166
- }),
6167
- state?.pdfError !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6168
- style: PDF_ERROR_STYLE,
6169
- children: tpl(t("pdf.error"), { message: state.pdfError })
6170
- }),
6171
6478
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6172
6479
  style: STATE_ROW_STYLE,
6173
6480
  children: [
@@ -6194,19 +6501,35 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
6194
6501
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
6195
6502
  style: SELECT_STYLE,
6196
6503
  value: state?.groupId ?? "",
6197
- disabled: busy || groups.length === 0,
6504
+ disabled: busy,
6198
6505
  "aria-label": t("annotate.group"),
6199
- onChange: (event) => onAnnotate({
6200
- paperId: paper.paperId,
6201
- groupId: event.target.value === "" ? null : event.target.value
6202
- }),
6203
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
6204
- value: "",
6205
- children: t("annotate.noGroup")
6206
- }), groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
6207
- value: group.id,
6208
- children: group.name
6209
- }, group.id))]
6506
+ title: groups.length === 0 ? t("annotate.groupHint") : t("annotate.group"),
6507
+ onChange: (event) => {
6508
+ const value = event.target.value;
6509
+ if (value === CREATE_GROUP_VALUE) {
6510
+ event.target.value = state?.groupId ?? "";
6511
+ onManageGroups?.();
6512
+ return;
6513
+ }
6514
+ onAnnotate({
6515
+ paperId: paper.paperId,
6516
+ groupId: value === "" ? null : value
6517
+ });
6518
+ },
6519
+ children: [
6520
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
6521
+ value: "",
6522
+ children: t("annotate.noGroup")
6523
+ }),
6524
+ groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
6525
+ value: group.id,
6526
+ children: group.name
6527
+ }, group.id)),
6528
+ groups.length === 0 && onManageGroups !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
6529
+ value: CREATE_GROUP_VALUE,
6530
+ children: t("annotate.createGroup")
6531
+ })
6532
+ ]
6210
6533
  }),
6211
6534
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
6212
6535
  type: "button",
@@ -6267,14 +6590,201 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
6267
6590
  })
6268
6591
  ]
6269
6592
  }),
6593
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6594
+ style: FIELD_ROW_STYLE,
6595
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6596
+ style: FIELD_ICON_STYLE,
6597
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TitleIcon, {})
6598
+ }), paper.url === "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6599
+ style: {
6600
+ ...titleStyle,
6601
+ ...FIELD_BODY_STYLE
6602
+ },
6603
+ children: paper.title
6604
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
6605
+ style: {
6606
+ ...titleStyle,
6607
+ ...FIELD_BODY_STYLE
6608
+ },
6609
+ href: paper.url,
6610
+ target: "_blank",
6611
+ rel: "noreferrer noopener",
6612
+ children: paper.title
6613
+ })]
6614
+ }),
6615
+ meta !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6616
+ style: {
6617
+ ...FIELD_ROW_STYLE,
6618
+ marginTop: 4
6619
+ },
6620
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6621
+ style: {
6622
+ ...FIELD_ICON_STYLE,
6623
+ marginTop: 1
6624
+ },
6625
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AuthorsIcon, {})
6626
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6627
+ style: {
6628
+ ...metaStyle,
6629
+ ...FIELD_BODY_STYLE,
6630
+ marginTop: 0
6631
+ },
6632
+ children: meta
6633
+ })]
6634
+ }),
6270
6635
  paper.abstract !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6271
- style: abstractStyle,
6272
- children: [abstract, canCollapse && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [" ", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6273
- type: "button",
6274
- style: linkButtonStyle,
6275
- onClick: () => setExpanded((value) => !value),
6276
- children: expanded ? t("result.collapseAbstract") : t("result.expandAbstract")
6277
- })] })]
6636
+ style: {
6637
+ ...FIELD_ROW_STYLE,
6638
+ marginTop: 6
6639
+ },
6640
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6641
+ style: FIELD_ICON_STYLE,
6642
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AbstractIcon, {})
6643
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6644
+ style: {
6645
+ ...abstractStyle,
6646
+ ...FIELD_BODY_STYLE,
6647
+ marginTop: 0
6648
+ },
6649
+ children: [abstract, canCollapse && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [" ", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6650
+ type: "button",
6651
+ style: linkButtonStyle,
6652
+ onClick: () => setExpanded((value) => !value),
6653
+ children: expanded ? t("result.collapseAbstract") : t("result.expandAbstract")
6654
+ })] })]
6655
+ })]
6656
+ }),
6657
+ state?.pdfError !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6658
+ style: PDF_ERROR_STYLE,
6659
+ children: tpl(t("pdf.error"), { message: state.pdfError })
6660
+ }),
6661
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6662
+ style: CARD_ROW_STYLE,
6663
+ title: t("result.infoHint"),
6664
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6665
+ style: INFO_LABEL_STYLE,
6666
+ children: t("result.infoLabel")
6667
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6668
+ style: {
6669
+ ...badgeRowStyle,
6670
+ ...FIELD_BODY_STYLE,
6671
+ marginTop: 0
6672
+ },
6673
+ children: [
6674
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6675
+ style: {
6676
+ ...INFO_BADGE_STYLE,
6677
+ color: SCREENING_COLOR[screening]
6678
+ },
6679
+ children: t(`screening.${screening}`)
6680
+ }),
6681
+ groupName !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6682
+ style: INFO_BADGE_STYLE,
6683
+ children: groupName
6684
+ }),
6685
+ state?.shortlistOrder !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6686
+ style: INFO_BADGE_STYLE,
6687
+ children: tpl(t("annotate.shortlistBadge"), { n: state.shortlistOrder })
6688
+ }),
6689
+ sources.map((source) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6690
+ style: INFO_BADGE_STYLE,
6691
+ children: t(`source.${source}`)
6692
+ }, source)),
6693
+ paper.citedBy !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6694
+ style: INFO_BADGE_STYLE,
6695
+ children: tpl(t("result.citedBy"), { n: paper.citedBy })
6696
+ }),
6697
+ paper.openAccess && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6698
+ style: INFO_BADGE_STYLE,
6699
+ children: t("result.openAccess")
6700
+ }),
6701
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6702
+ style: {
6703
+ ...INFO_BADGE_STYLE,
6704
+ color: PDF_COLOR[pdfStatus]
6705
+ },
6706
+ children: t(`pdf.status.${pdfStatus}`)
6707
+ })
6708
+ ]
6709
+ })]
6710
+ }),
6711
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6712
+ style: CARD_ROW_STYLE,
6713
+ title: t("result.actionHint"),
6714
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6715
+ style: ACTION_LABEL_STYLE,
6716
+ children: t("result.actionLabel")
6717
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6718
+ style: {
6719
+ ...actionRowStyle,
6720
+ ...FIELD_BODY_STYLE,
6721
+ marginTop: 0,
6722
+ flexWrap: "wrap",
6723
+ rowGap: 4
6724
+ },
6725
+ children: [
6726
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
6727
+ variant: "ghost",
6728
+ size: "sm",
6729
+ type: "button",
6730
+ style: { color: ACCENT },
6731
+ onClick: () => onInsert(paper),
6732
+ children: t("result.insert")
6733
+ }),
6734
+ onOpenPaper !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6735
+ type: "button",
6736
+ style: linkButtonStyle,
6737
+ onClick: () => onOpenPaper(paper),
6738
+ children: t("paper.open")
6739
+ }),
6740
+ paper.ids.pmid !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6741
+ type: "button",
6742
+ style: linkButtonStyle,
6743
+ onClick: () => setEntities((value) => !value),
6744
+ children: t("entity.title")
6745
+ }),
6746
+ canPdf && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6747
+ type: "button",
6748
+ style: linkButtonStyle,
6749
+ onClick: () => setPreview((value) => value === "pdf" ? null : "pdf"),
6750
+ children: t("pdf.preview")
6751
+ }),
6752
+ canPdf && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6753
+ type: "button",
6754
+ style: linkButtonStyle,
6755
+ disabled: busy,
6756
+ onClick: () => onDownload(paper),
6757
+ children: t("pdf.download")
6758
+ }),
6759
+ canHtml && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6760
+ type: "button",
6761
+ style: linkButtonStyle,
6762
+ onClick: () => setPreview((value) => value === "html" ? null : "html"),
6763
+ children: t("pdf.fulltext")
6764
+ }),
6765
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6766
+ type: "button",
6767
+ style: linkButtonStyle,
6768
+ disabled: busy,
6769
+ onClick: () => onZotero(paper),
6770
+ children: t("pdf.zotero")
6771
+ }),
6772
+ paper.pdfUrl !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
6773
+ style: linkStyle,
6774
+ href: paper.pdfUrl,
6775
+ target: "_blank",
6776
+ rel: "noreferrer noopener",
6777
+ children: t("result.pdf")
6778
+ }),
6779
+ paper.doi !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
6780
+ style: linkStyle,
6781
+ href: `https://doi.org/${paper.doi}`,
6782
+ target: "_blank",
6783
+ rel: "noreferrer noopener",
6784
+ children: "DOI"
6785
+ })
6786
+ ]
6787
+ })]
6278
6788
  }),
6279
6789
  entities && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EntityPanel, {
6280
6790
  t,
@@ -6297,70 +6807,6 @@ function PaperCard({ paper, t, taskId, state, groups, busy, selected = false, on
6297
6807
  src: preview === "pdf" ? paperPdfUrl(taskId, paper.paperId) : paperFullTextUrl(taskId, paper.paperId),
6298
6808
  style: IFRAME_STYLE
6299
6809
  })]
6300
- }),
6301
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6302
- style: actionRowStyle,
6303
- children: [
6304
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
6305
- variant: "ghost",
6306
- size: "sm",
6307
- type: "button",
6308
- onClick: () => onInsert(paper),
6309
- children: t("result.insert")
6310
- }),
6311
- onOpenPaper !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6312
- type: "button",
6313
- style: linkButtonStyle,
6314
- onClick: () => onOpenPaper(paper),
6315
- children: t("paper.open")
6316
- }),
6317
- paper.ids.pmid !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6318
- type: "button",
6319
- style: linkButtonStyle,
6320
- onClick: () => setEntities((value) => !value),
6321
- children: t("entity.title")
6322
- }),
6323
- canPdf && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6324
- type: "button",
6325
- style: linkButtonStyle,
6326
- onClick: () => setPreview((value) => value === "pdf" ? null : "pdf"),
6327
- children: t("pdf.preview")
6328
- }),
6329
- canPdf && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6330
- type: "button",
6331
- style: linkButtonStyle,
6332
- disabled: busy,
6333
- onClick: () => onDownload(paper),
6334
- children: t("pdf.download")
6335
- }),
6336
- canHtml && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6337
- type: "button",
6338
- style: linkButtonStyle,
6339
- onClick: () => setPreview((value) => value === "html" ? null : "html"),
6340
- children: t("pdf.fulltext")
6341
- }),
6342
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6343
- type: "button",
6344
- style: linkButtonStyle,
6345
- disabled: busy,
6346
- onClick: () => onZotero(paper),
6347
- children: t("pdf.zotero")
6348
- }),
6349
- paper.pdfUrl !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
6350
- style: linkStyle,
6351
- href: paper.pdfUrl,
6352
- target: "_blank",
6353
- rel: "noreferrer noopener",
6354
- children: t("result.pdf")
6355
- }),
6356
- paper.doi !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
6357
- style: linkStyle,
6358
- href: `https://doi.org/${paper.doi}`,
6359
- target: "_blank",
6360
- rel: "noreferrer noopener",
6361
- children: "DOI"
6362
- })
6363
- ]
6364
6810
  })
6365
6811
  ]
6366
6812
  });
@@ -6401,14 +6847,66 @@ const IFRAME_STYLE = {
6401
6847
  border: "none",
6402
6848
  background: "#fff"
6403
6849
  };
6850
+ /** 卡片顶部工具行:勾选 / 筛读 / 分组 / 标注。 */
6404
6851
  const STATE_ROW_STYLE = {
6405
6852
  display: "flex",
6406
6853
  alignItems: "center",
6407
6854
  flexWrap: "wrap",
6408
- gap: 6,
6409
- marginTop: 6,
6855
+ gap: 8,
6856
+ marginBottom: 8,
6410
6857
  fontSize: 12
6411
6858
  };
6859
+ /** 标题 / 作者 / 摘要统一为「图标 + 正文」两列,长文本只在正文列换行。 */
6860
+ const FIELD_ROW_STYLE = {
6861
+ display: "flex",
6862
+ alignItems: "flex-start",
6863
+ gap: 6
6864
+ };
6865
+ const FIELD_ICON_STYLE = {
6866
+ display: "inline-flex",
6867
+ flexShrink: 0,
6868
+ width: 14,
6869
+ marginTop: 2,
6870
+ color: ACCENT,
6871
+ opacity: .75
6872
+ };
6873
+ const FIELD_BODY_STYLE = {
6874
+ flex: 1,
6875
+ minWidth: 0
6876
+ };
6877
+ /**
6878
+ * 卡片底部两行:左边固定宽度的行标签,右边是内容。
6879
+ * 「状态与来源」用青绿、「快捷操作」用主题蓝,避免两行都是蓝字难以分辨。
6880
+ */
6881
+ const INFO_COLOR = "#12836c";
6882
+ const CARD_ROW_STYLE = {
6883
+ display: "flex",
6884
+ alignItems: "flex-start",
6885
+ gap: 8,
6886
+ marginTop: 8
6887
+ };
6888
+ const ROW_LABEL_STYLE = {
6889
+ flexShrink: 0,
6890
+ width: 92,
6891
+ paddingTop: 1,
6892
+ fontSize: 11,
6893
+ lineHeight: "18px",
6894
+ fontWeight: 600,
6895
+ whiteSpace: "nowrap"
6896
+ };
6897
+ const INFO_LABEL_STYLE = {
6898
+ ...ROW_LABEL_STYLE,
6899
+ color: INFO_COLOR
6900
+ };
6901
+ const ACTION_LABEL_STYLE = {
6902
+ ...ROW_LABEL_STYLE,
6903
+ color: ACCENT
6904
+ };
6905
+ const INFO_BADGE_STYLE = {
6906
+ ...badgeStyle,
6907
+ color: INFO_COLOR,
6908
+ border: "1px solid rgba(18,131,108,0.45)"
6909
+ };
6412
6910
  const SELECT_STYLE = {
6413
6911
  fontSize: 11,
6414
6912
  padding: "2px 4px",
@@ -6422,7 +6920,7 @@ const EDIT_STYLE = {
6422
6920
  display: "flex",
6423
6921
  flexDirection: "column",
6424
6922
  gap: 6,
6425
- marginTop: 6,
6923
+ marginBottom: 8,
6426
6924
  padding: "6px 8px",
6427
6925
  borderRadius: 6,
6428
6926
  background: "rgba(127,127,127,0.07)"
@@ -6689,6 +7187,9 @@ const roundCardStyle = {
6689
7187
  * 文本类条件(关键词、年份、类型)本地暂存 300ms 再上抛,避免每敲一个字都
6690
7188
  * 打一次 Host;下拉类条件立即生效。父组件的筛选对象必须能直接喂给
6691
7189
  * `/results` 与 `/stats`。
7190
+ *
7191
+ * 顶部标签与「收起筛选(N)」共用 collectActiveFilters:新增筛选维度时
7192
+ * 只在那里加一项,不要另外单写 chip,否则两处会再次不同步。
6692
7193
  */
6693
7194
  const TEXT_DEBOUNCE_MS = 300;
6694
7195
  const SORT_MODES = [
@@ -6696,11 +7197,7 @@ const SORT_MODES = [
6696
7197
  "citations",
6697
7198
  "year"
6698
7199
  ];
6699
- const LANGUAGES = [
6700
- "any",
6701
- "eng",
6702
- "chi"
6703
- ];
7200
+ const LANGUAGES = ["eng", "chi"];
6704
7201
  const SCREENINGS$1 = [
6705
7202
  "unread",
6706
7203
  "read",
@@ -6729,25 +7226,54 @@ function mergeDraft(base, draft) {
6729
7226
  next.yearTo = draft.yearTo.trim() === "" || !Number.isFinite(yearTo) || yearTo <= 0 ? void 0 : Math.floor(yearTo);
6730
7227
  return next;
6731
7228
  }
7229
+ /**
7230
+ * 已生效条件的唯一清单:折叠态的计数与顶部标签都从这里派生,
7231
+ * 新增筛选维度时只需加一项,两处必然同步(此前只有 6 个维度有标签)。
7232
+ * `t` 可省略——只计数时用占位翻译,文案不参与判断。
7233
+ */
7234
+ function collectActiveFilters(value, t = (key) => key, queries = [], groups = []) {
7235
+ const list = [];
7236
+ const add = (key, label, clear) => {
7237
+ list.push({
7238
+ key,
7239
+ label,
7240
+ clear
7241
+ });
7242
+ };
7243
+ if (value.q !== void 0 && value.q.trim() !== "") add("q", tpl(t("filter.keywordChip"), { value: value.q }), { q: void 0 });
7244
+ if (value.source !== void 0) add("source", tpl(t("filter.sourceChip"), { value: t(`source.${value.source}`) }), { source: void 0 });
7245
+ if (value.yearFrom !== void 0 || value.yearTo !== void 0) {
7246
+ const from = value.yearFrom === void 0 ? "" : String(value.yearFrom);
7247
+ const to = value.yearTo === void 0 ? "" : String(value.yearTo);
7248
+ add("year", tpl(t("filter.yearChip"), { value: `${from}–${to}` }), {
7249
+ yearFrom: void 0,
7250
+ yearTo: void 0
7251
+ });
7252
+ }
7253
+ if (value.screening !== void 0) add("screening", tpl(t("filter.screeningChip"), { value: t(`screening.${value.screening}`) }), { screening: void 0 });
7254
+ if (value.queryId !== void 0) {
7255
+ const name = queries.find((query) => query.id === value.queryId)?.name ?? value.queryId;
7256
+ add("queryId", tpl(t("filter.queryChip"), { value: name }), { queryId: void 0 });
7257
+ }
7258
+ if (value.groupId !== void 0) {
7259
+ const name = groups.find((group) => group.id === value.groupId)?.name ?? value.groupId;
7260
+ add("groupId", tpl(t("filter.groupChip"), { value: name }), { groupId: void 0 });
7261
+ }
7262
+ if (value.language !== void 0 && value.language !== "any") add("language", tpl(t("filter.languageChip"), { value: t(`lang.${value.language}`) }), { language: void 0 });
7263
+ if (value.sort !== void 0 && value.sort !== "relevance") add("sort", tpl(t("filter.sortChip"), { value: t(`sort.${value.sort}`) }), { sort: void 0 });
7264
+ if (value.openAccessOnly === true) add("openAccessOnly", t("filter.openAccess"), { openAccessOnly: void 0 });
7265
+ if (value.hasPdf === true) add("hasPdf", t("filter.hasPdf"), { hasPdf: void 0 });
7266
+ if (value.missingFullText === true) add("missingFullText", t("filter.missingFullText"), { missingFullText: void 0 });
7267
+ if (value.pdfStatus !== void 0) add("pdfStatus", tpl(t("filter.pdfStatusChip"), { value: t(`pdf.status.${value.pdfStatus}`) }), { pdfStatus: void 0 });
7268
+ if (value.type !== void 0) add("type", tpl(t("filter.typeChip"), { value: value.type }), { type: void 0 });
7269
+ if (value.venue !== void 0) add("venue", tpl(t("filter.venueChip"), { value: value.venue }), { venue: void 0 });
7270
+ if (value.author !== void 0) add("author", tpl(t("filter.authorChip"), { value: value.author }), { author: void 0 });
7271
+ if (value.newOnly === true) add("newOnly", t("filter.newChip"), { newOnly: void 0 });
7272
+ return list;
7273
+ }
6732
7274
  /** 已生效的筛选条件数量(用于折叠态提示)。 */
6733
7275
  function activeFilterCount(value) {
6734
- let count = 0;
6735
- if (value.q !== void 0 && value.q.trim() !== "") count += 1;
6736
- if (value.source !== void 0) count += 1;
6737
- if (value.yearFrom !== void 0 || value.yearTo !== void 0) count += 1;
6738
- if (value.openAccessOnly === true) count += 1;
6739
- if (value.hasPdf === true) count += 1;
6740
- if (value.missingFullText === true) count += 1;
6741
- if (value.screening !== void 0) count += 1;
6742
- if (value.language !== void 0) count += 1;
6743
- if (value.queryId !== void 0) count += 1;
6744
- if (value.type !== void 0) count += 1;
6745
- if (value.sort !== void 0) count += 1;
6746
- if (value.groupId !== void 0) count += 1;
6747
- if (value.venue !== void 0) count += 1;
6748
- if (value.author !== void 0) count += 1;
6749
- if (value.newOnly === true) count += 1;
6750
- return count;
7276
+ return collectActiveFilters(value).length;
6751
7277
  }
6752
7278
  function TaskFilters({ t, value, sources, queries, groups = [], onChange }) {
6753
7279
  const [open, setOpen] = (0, react.useState)(false);
@@ -6780,9 +7306,8 @@ function TaskFilters({ t, value, sources, queries, groups = [], onChange }) {
6780
7306
  ...next
6781
7307
  });
6782
7308
  };
6783
- const count = activeFilterCount(value);
6784
- const queryName = value.queryId === void 0 ? "" : queries.find((query) => query.id === value.queryId)?.name ?? value.queryId;
6785
- const groupName = value.groupId === void 0 ? "" : groups.find((group) => group.id === value.groupId)?.name ?? value.groupId;
7309
+ const chips = collectActiveFilters(value, t, queries, groups);
7310
+ const count = chips.length;
6786
7311
  const setDraftValue = (key, text) => {
6787
7312
  setDraft((previous) => ({
6788
7313
  ...previous,
@@ -6806,30 +7331,10 @@ function TaskFilters({ t, value, sources, queries, groups = [], onChange }) {
6806
7331
  onClick: () => onChange({}),
6807
7332
  children: t("filter.reset")
6808
7333
  }),
6809
- value.type !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActiveChip, {
6810
- label: tpl(t("filter.typeChip"), { value: value.type }),
6811
- onRemove: () => patch({ type: void 0 })
6812
- }),
6813
- value.queryId !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActiveChip, {
6814
- label: tpl(t("filter.queryChip"), { value: queryName }),
6815
- onRemove: () => patch({ queryId: void 0 })
6816
- }),
6817
- value.groupId !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActiveChip, {
6818
- label: tpl(t("filter.groupChip"), { value: groupName }),
6819
- onRemove: () => patch({ groupId: void 0 })
6820
- }),
6821
- value.venue !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActiveChip, {
6822
- label: tpl(t("filter.venueChip"), { value: value.venue }),
6823
- onRemove: () => patch({ venue: void 0 })
6824
- }),
6825
- value.author !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActiveChip, {
6826
- label: tpl(t("filter.authorChip"), { value: value.author }),
6827
- onRemove: () => patch({ author: void 0 })
6828
- }),
6829
- value.newOnly === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActiveChip, {
6830
- label: t("filter.newChip"),
6831
- onRemove: () => patch({ newOnly: void 0 })
6832
- })
7334
+ chips.map((chip) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ActiveChip, {
7335
+ label: chip.label,
7336
+ onRemove: () => patch(chip.clear)
7337
+ }, chip.key))
6833
7338
  ]
6834
7339
  }), open && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6835
7340
  style: GRID_STYLE,
@@ -6924,7 +7429,7 @@ function TaskFilters({ t, value, sources, queries, groups = [], onChange }) {
6924
7429
  style: FIELD_STYLE,
6925
7430
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("filter.language") }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
6926
7431
  style: INPUT_STYLE,
6927
- value: value.language ?? "",
7432
+ value: value.language === "any" ? "" : value.language ?? "",
6928
7433
  onChange: (event) => patch({ language: event.target.value === "" ? void 0 : event.target.value }),
6929
7434
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
6930
7435
  value: "",
@@ -6940,7 +7445,7 @@ function TaskFilters({ t, value, sources, queries, groups = [], onChange }) {
6940
7445
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("filter.sort") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
6941
7446
  style: INPUT_STYLE,
6942
7447
  value: value.sort ?? "relevance",
6943
- onChange: (event) => patch({ sort: event.target.value }),
7448
+ onChange: (event) => patch({ sort: event.target.value === "relevance" ? void 0 : event.target.value }),
6944
7449
  children: SORT_MODES.map((mode) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
6945
7450
  value: mode,
6946
7451
  children: t(`sort.${mode}`)
@@ -7350,7 +7855,7 @@ function formatDate(value) {
7350
7855
  const date = new Date(value);
7351
7856
  return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
7352
7857
  }
7353
- function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask }) {
7858
+ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask, onCloseTask }) {
7354
7859
  const info = useTabInfo();
7355
7860
  const taskId = parseTaskId(info);
7356
7861
  const [filters, setFilters] = (0, react.useState)({});
@@ -7362,6 +7867,8 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask }) {
7362
7867
  const [risFallback, setRisFallback] = (0, react.useState)(null);
7363
7868
  const [showRounds, setShowRounds] = (0, react.useState)(false);
7364
7869
  const [showTaskList, setShowTaskList] = (0, react.useState)(false);
7870
+ /** 任务级全文统计:结果页覆盖率横幅与全文页台账共用同一份数据。 */
7871
+ const [overview, setOverview] = (0, react.useState)(null);
7365
7872
  const detail = view.detail;
7366
7873
  const filterActive = (0, react.useMemo)(() => activeFilterCount(filters) > 0, [filters]);
7367
7874
  const filterKey = JSON.stringify(filters);
@@ -7375,6 +7882,19 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask }) {
7375
7882
  (0, react.useEffect)(() => {
7376
7883
  if (detail !== null) rememberTaskTitle(detail.id, detail.title);
7377
7884
  }, [detail]);
7885
+ (0, react.useEffect)(() => {
7886
+ if (taskId === null) return void 0;
7887
+ const controller = new AbortController();
7888
+ fetchStats(taskId, {}, "task", controller.signal).then((report) => {
7889
+ if (!controller.signal.aborted) setOverview(report);
7890
+ }).catch(() => {
7891
+ if (!controller.signal.aborted) setOverview(null);
7892
+ });
7893
+ return () => controller.abort();
7894
+ }, [taskId, detail?.rev]);
7895
+ (0, react.useEffect)(() => {
7896
+ setOverview(null);
7897
+ }, [taskId]);
7378
7898
  (0, react.useEffect)(() => {
7379
7899
  if (toast === "") return void 0;
7380
7900
  const timer = setTimeout(() => setToast(""), 2400);
@@ -7398,6 +7918,15 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask }) {
7398
7918
  else setToast(t(outcome === "no-editor" ? "error.noEditor" : "error.draftChanged"));
7399
7919
  })();
7400
7920
  };
7921
+ /** 把任务 id 与当前条件带入会话草稿,新会话也能续接同一任务。 */
7922
+ const insertTask = () => {
7923
+ if (detail === null) return;
7924
+ (async () => {
7925
+ const outcome = await insertBuilt(inputActions, () => taskText(t, detail));
7926
+ if (outcome === "inserted") setToast(t("task.inserted"));
7927
+ else setToast(t(outcome === "no-editor" ? "error.noEditor" : "error.draftChanged"));
7928
+ })();
7929
+ };
7401
7930
  const downloadPaper = (paper) => {
7402
7931
  if (taskId === null) return;
7403
7932
  (async () => {
@@ -7456,12 +7985,13 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask }) {
7456
7985
  })));
7457
7986
  })();
7458
7987
  };
7459
- const downloadMany = () => {
7460
- if (taskId === null || ids.length === 0) return;
7988
+ /** 结果页与全文页共用同一套批量下载 / Zotero 逻辑。 */
7989
+ const downloadIds = (paperIds) => {
7990
+ if (taskId === null || paperIds.length === 0) return;
7461
7991
  (async () => {
7462
7992
  let ok = 0;
7463
7993
  let failed = 0;
7464
- for (const paperId of ids) try {
7994
+ for (const paperId of paperIds) try {
7465
7995
  const result = await downloadPaperPdf(taskId, paperId);
7466
7996
  if (result.status === "downloaded" || result.status === "html-only") ok += 1;
7467
7997
  else failed += 1;
@@ -7475,23 +8005,25 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask }) {
7475
8005
  view.reload();
7476
8006
  })();
7477
8007
  };
7478
- const zoteroMany = () => {
7479
- if (taskId === null || ids.length === 0) return;
7480
- syncZotero(taskId, ids).then((result) => {
8008
+ const zoteroIds = (paperIds) => {
8009
+ if (taskId === null || paperIds.length === 0) return;
8010
+ syncZotero(taskId, paperIds).then((result) => {
7481
8011
  setToast(zoteroToast(t, result.saved, result.failed, result.messages));
7482
8012
  if (result.failed > 0) setRisFallback({
7483
- ids,
8013
+ ids: [...paperIds],
7484
8014
  message: result.messages[0] ?? t("pdf.zoteroFailed")
7485
8015
  });
7486
8016
  }).catch((error) => {
7487
8017
  const message = messageOf(error);
7488
8018
  setToast(tpl(t("pdf.zoteroFailed"), { message }));
7489
8019
  setRisFallback({
7490
- ids,
8020
+ ids: [...paperIds],
7491
8021
  message
7492
8022
  });
7493
8023
  });
7494
8024
  };
8025
+ const downloadMany = () => downloadIds(ids);
8026
+ const zoteroMany = () => zoteroIds(ids);
7495
8027
  if (taskId === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7496
8028
  style: panelStyle,
7497
8029
  children: t("task.notFound")
@@ -7519,6 +8051,10 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask }) {
7519
8051
  const failed = detail.result.sources.filter((source) => !source.ok && !source.skipped);
7520
8052
  const skipped = detail.result.sources.filter((source) => source.skipped);
7521
8053
  const page = view.page;
8054
+ const coverage = overview?.fullText;
8055
+ const overviewAvailable = coverage === void 0 ? 0 : coverage.available + coverage.downloaded + coverage.htmlOnly;
8056
+ const overviewMissing = coverage === void 0 ? 0 : coverage.unknown + coverage.paywalled + coverage.blocked + coverage.failed;
8057
+ const overviewRate = overview === null || overview.loaded === 0 ? 0 : Math.round(overviewAvailable / overview.loaded * 100);
7522
8058
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7523
8059
  style: panelStyle,
7524
8060
  children: [
@@ -7663,6 +8199,14 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask }) {
7663
8199
  },
7664
8200
  children: t("task.rerun")
7665
8201
  }),
8202
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
8203
+ variant: "outline",
8204
+ size: "sm",
8205
+ type: "button",
8206
+ title: t("task.insertToChatHint"),
8207
+ onClick: insertTask,
8208
+ children: t("task.insertToChat")
8209
+ }),
7666
8210
  active && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
7667
8211
  variant: "ghost",
7668
8212
  size: "sm",
@@ -7755,6 +8299,38 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask }) {
7755
8299
  ]
7756
8300
  }),
7757
8301
  tab === "results" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
8302
+ overview !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
8303
+ style: COVERAGE_WRAP_STYLE,
8304
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
8305
+ style: COVERAGE_ROW_STYLE,
8306
+ children: [
8307
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: tpl(t("fulltext.coverageBar"), {
8308
+ available: overviewAvailable,
8309
+ total: overview.loaded,
8310
+ rate: overviewRate,
8311
+ missing: overviewMissing
8312
+ }) }),
8313
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
8314
+ type: "button",
8315
+ style: filters.missingFullText === true ? QUICK_FILTER_ACTIVE_STYLE : QUICK_FILTER_STYLE,
8316
+ onClick: () => setFilters((previous) => ({
8317
+ ...previous,
8318
+ missingFullText: previous.missingFullText === true ? void 0 : true
8319
+ })),
8320
+ children: t("fulltext.quickFilter")
8321
+ }),
8322
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
8323
+ type: "button",
8324
+ style: linkButtonStyle,
8325
+ onClick: () => setTab("fulltext"),
8326
+ children: t("fulltext.openTab")
8327
+ })
8328
+ ]
8329
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
8330
+ style: COVERAGE_HINT_STYLE,
8331
+ children: t("fulltext.barHint")
8332
+ })]
8333
+ }),
7758
8334
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TaskFilters, {
7759
8335
  t,
7760
8336
  value: filters,
@@ -7803,6 +8379,7 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask }) {
7803
8379
  },
7804
8380
  onDownload: downloadPaper,
7805
8381
  onZotero: zoteroPaper,
8382
+ onManageGroups: () => setTab("groups"),
7806
8383
  ...onOpenPaper === void 0 ? {} : { onOpenPaper: (item) => onOpenPaper(taskId, item.paperId, item.title) }
7807
8384
  }) }, paper.paperId))
7808
8385
  }), page.papers.length < page.total && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -7842,13 +8419,13 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask }) {
7842
8419
  taskId,
7843
8420
  rev: detail.rev,
7844
8421
  states: detail.states,
8422
+ stats: overview,
7845
8423
  onDownload: downloadPaper,
7846
8424
  onZotero: zoteroPaper,
7847
- onShowMissing: () => {
7848
- setFilters((previous) => ({
7849
- ...previous,
7850
- missingFullText: true
7851
- }));
8425
+ onBatchDownload: (papers) => downloadIds(papers.map((paper) => paper.paperId)),
8426
+ onBatchZotero: (papers) => zoteroIds(papers.map((paper) => paper.paperId)),
8427
+ onShowInResults: (viewKey) => {
8428
+ setFilters(viewKey === "missing" ? { missingFullText: true } : { pdfStatus: viewKey });
7852
8429
  setTab("results");
7853
8430
  }
7854
8431
  }) : tab === "groups" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GroupsTab, {
@@ -7900,7 +8477,11 @@ function TaskPane({ t, useTabInfo, inputActions, onOpenPaper, onOpenTask }) {
7900
8477
  open: showTaskList,
7901
8478
  onClose: () => setShowTaskList(false),
7902
8479
  currentTaskId: taskId,
7903
- onOpenTask: onOpenTask ?? (() => void 0)
8480
+ onOpenTask: onOpenTask ?? (() => void 0),
8481
+ onCurrentRemoved: (fallback) => {
8482
+ if (fallback !== null) onOpenTask?.(fallback.id, fallback.title);
8483
+ else onCloseTask?.(taskId);
8484
+ }
7904
8485
  })
7905
8486
  ]
7906
8487
  });
@@ -8018,8 +8599,9 @@ function BulkBar({ t, taskId, count, ids, pageIds, groups, busy, filters, onClea
8018
8599
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
8019
8600
  style: BULK_SELECT_STYLE,
8020
8601
  value: "",
8021
- disabled: busy || groups.length === 0,
8602
+ disabled: busy,
8022
8603
  "aria-label": t("bulk.setGroup"),
8604
+ title: groups.length === 0 ? t("annotate.groupHint") : t("bulk.setGroup"),
8023
8605
  onChange: (event) => {
8024
8606
  const value = event.target.value;
8025
8607
  if (value !== "") onAnnotate({ groupId: value === "__none__" ? null : value });
@@ -8145,8 +8727,10 @@ const TAB_ACTIVE_STYLE = {
8145
8727
  };
8146
8728
  const SCREENING_BAR_STYLE = {
8147
8729
  display: "flex",
8148
- flexDirection: "column",
8149
- gap: 4,
8730
+ alignItems: "center",
8731
+ justifyContent: "space-between",
8732
+ flexWrap: "wrap",
8733
+ gap: 8,
8150
8734
  padding: "2px 0 4px",
8151
8735
  borderBottom: "1px solid rgba(127,127,127,0.12)"
8152
8736
  };
@@ -8174,8 +8758,10 @@ const SCREENING_PILL_ACTIVE = {
8174
8758
  const EXPORT_ROW_STYLE = {
8175
8759
  display: "flex",
8176
8760
  alignItems: "center",
8177
- flexWrap: "wrap",
8761
+ flexWrap: "nowrap",
8762
+ whiteSpace: "nowrap",
8178
8763
  gap: 8,
8764
+ marginLeft: "auto",
8179
8765
  fontSize: 11
8180
8766
  };
8181
8767
  const EXPORT_LABEL_STYLE = { opacity: .66 };
@@ -8208,6 +8794,41 @@ const BULK_SELECT_STYLE = {
8208
8794
  color: "inherit",
8209
8795
  maxWidth: 140
8210
8796
  };
8797
+ const COVERAGE_WRAP_STYLE = {
8798
+ display: "flex",
8799
+ flexDirection: "column",
8800
+ gap: 2,
8801
+ padding: "5px 8px",
8802
+ borderRadius: 6,
8803
+ background: "rgba(80,140,255,0.08)",
8804
+ border: "1px solid rgba(80,140,255,0.25)",
8805
+ fontSize: 12
8806
+ };
8807
+ const COVERAGE_ROW_STYLE = {
8808
+ display: "flex",
8809
+ alignItems: "center",
8810
+ flexWrap: "wrap",
8811
+ gap: 8
8812
+ };
8813
+ const COVERAGE_HINT_STYLE = {
8814
+ fontSize: 11,
8815
+ opacity: .66
8816
+ };
8817
+ const QUICK_FILTER_STYLE = {
8818
+ padding: "1px 7px",
8819
+ borderRadius: 10,
8820
+ border: "1px solid rgba(127,127,127,0.3)",
8821
+ background: "transparent",
8822
+ color: "inherit",
8823
+ fontSize: 11,
8824
+ cursor: "pointer"
8825
+ };
8826
+ const QUICK_FILTER_ACTIVE_STYLE = {
8827
+ ...QUICK_FILTER_STYLE,
8828
+ borderColor: "#508cff",
8829
+ color: "#508cff",
8830
+ fontWeight: 600
8831
+ };
8211
8832
  // src/client/index.ts
8212
8833
  /**
8213
8834
  * dsh-plugin-litsearch —— 客户端入口。
@@ -8268,17 +8889,32 @@ function apply(ctx) {
8268
8889
  priority: "extension",
8269
8890
  keepMounted: true
8270
8891
  }), "dsh-plugin-litsearch: paper tab");
8892
+ /** 当前会话里已打开的任务 tab。 */
8893
+ const taskTabsIn = (sessionId) => ctx.sidebarRight.tabsIn(sessionId).filter((tab) => tab.kind === TASK_TAB_ID);
8271
8894
  /** 打开 / 聚焦某任务的 tab;先把标题记进缓存,布局恢复后不靠 params 也能显示。 */
8272
8895
  const openTask = (id, title) => {
8273
8896
  const address = `${TASK_ADDRESS_PREFIX}${encodeURIComponent(id)}`;
8274
8897
  if (title !== void 0 && title !== "") rememberTaskTitle(id, title);
8275
8898
  activateTask(id).catch(() => void 0);
8276
- ctx.sidebarRight.openResource(address, title === void 0 || title === "" ? void 0 : { params: { title } });
8899
+ const sessionId = ctx.sidebarRight.mounted.getSnapshot();
8900
+ const tabs = sessionId === void 0 ? [] : taskTabsIn(sessionId);
8901
+ const previous = tabs.find((tab) => taskIdFromAddress(tab.contentId) !== id);
8902
+ ctx.sidebarRight.openResource(address, {
8903
+ ...title === void 0 || title === "" ? {} : { params: { title } },
8904
+ ...previous === void 0 ? {} : { replaceTab: previous.id }
8905
+ });
8906
+ for (const tab of tabs) if (tab.id !== previous?.id && taskIdFromAddress(tab.contentId) !== id) ctx.sidebarRight.close(tab.id);
8277
8907
  };
8278
8908
  const Body = () => (0, react.createElement)(LitsearchPage, {
8279
8909
  t,
8280
8910
  onOpenTask: (id, title) => openTask(id, title)
8281
8911
  });
8912
+ /** 关闭某个任务 tab;任务被删除且没有可切换的任务时用来清空窗口。 */
8913
+ const closeTask = (id) => {
8914
+ const sessionId = ctx.sidebarRight.mounted.getSnapshot();
8915
+ if (sessionId === void 0) return;
8916
+ for (const tab of taskTabsIn(sessionId)) if (taskIdFromAddress(tab.contentId) === id) ctx.sidebarRight.close(tab.id);
8917
+ };
8282
8918
  ctx.effect(() => ctx.slots.register({
8283
8919
  name: "sidebar.right.pane.tab",
8284
8920
  key: TAB_ID
@@ -8293,7 +8929,8 @@ function apply(ctx) {
8293
8929
  useTabInfo: props.useTabInfo,
8294
8930
  inputActions: props.inputActions,
8295
8931
  onOpenPaper: openPaper,
8296
- onOpenTask: (id, title) => openTask(id, title)
8932
+ onOpenTask: (id, title) => openTask(id, title),
8933
+ onCloseTask: (id) => closeTask(id)
8297
8934
  });
8298
8935
  /** tab 标题:按地址里的任务 id 读缓存,缺了补取一次详情。 */
8299
8936
  const TaskTitle = (props) => {
@@ -8359,7 +8996,28 @@ function apply(ctx) {
8359
8996
  locale: NS,
8360
8997
  inject: () => controller.inject()
8361
8998
  }, SettingsSection))), "dsh-plugin-litsearch: settings section");
8362
- ctx.effect(() => startAutoOpen({ open: (id, title) => openTask(id, title) }), "dsh-plugin-litsearch: auto open");
8999
+ ctx.effect(() => startAutoOpen({
9000
+ open: (id, title) => openTask(id, title),
9001
+ sessionId: () => ctx.sidebarRight.mounted.getSnapshot()
9002
+ }), "dsh-plugin-litsearch: auto open");
9003
+ ctx.effect(() => {
9004
+ const sync = () => {
9005
+ const sessionId = ctx.sidebarRight.mounted.getSnapshot();
9006
+ if (sessionId === void 0) return;
9007
+ const tabs = taskTabsIn(sessionId);
9008
+ if (tabs.length === 0) return;
9009
+ const activeId = ctx.sidebarRight.active()?.id;
9010
+ const tab = tabs.find((entry) => entry.id === activeId) ?? tabs[0];
9011
+ for (const extra of tabs) if (extra.id !== tab.id) ctx.sidebarRight.close(extra.id);
9012
+ const id = taskIdFromAddress(tab.contentId);
9013
+ if (id !== null) activateTask(id).catch(() => void 0);
9014
+ };
9015
+ const unsubscribe = ctx.sidebarRight.mounted.subscribe(sync);
9016
+ sync();
9017
+ return () => {
9018
+ unsubscribe();
9019
+ };
9020
+ }, "dsh-plugin-litsearch: current task follows conversation");
8363
9021
  ctx.effect(() => {
8364
9022
  refreshHealth();
8365
9023
  const timer = setInterval(() => {