@musnows/scriverse 0.5.4 → 0.5.6

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.
@@ -7,6 +7,7 @@ import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineN
7
7
  import { MODEL_PURPOSE_OPTIONS, isKimiModelId, modelFormValues, modelOptionLabel, modelPayload } from "/model-config.js?v=20260723-kimi-temperature";
8
8
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
9
9
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260726-cache-hit-percent";
10
+ import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
10
11
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260713-cross-day-time";
11
12
  import { formatAiContextUsageTooltip } from "/ai-context-meter.js?v=20260718-layered-context";
12
13
  import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-markdown";
@@ -33,10 +34,10 @@ import {
33
34
  timelineStatusLabel,
34
35
  characterStateFieldLabel
35
36
  } from "/display-labels.js?v=20260726-anthropic-messages-v2";
36
- import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260723-knowledge-editor-page";
37
+ import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260727-ai-usage";
37
38
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
38
39
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
39
- import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260726-race-filter-order";
40
+ import { eligibleRaceParents, orderRaceFilterOptions, paginateRaceForest, racePathLabel } from "/race-hierarchy.js?v=20260727-race-tree-pagination-v1";
40
41
  import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
41
42
  import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260724-outline-title";
42
43
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
@@ -44,6 +45,7 @@ import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global
44
45
  import { resolveGlobalSearchTarget } from "/global-search.js?v=20260726-search-result-details";
45
46
  import { filterCharacters, paginateCharacters } from "/character-filters.js?v=20260725-character-filters";
46
47
  import { filterRelationships } from "/relationship-filters.js?v=20260726-relationship-filters";
48
+ import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=20260726-background-task-center-v1";
47
49
  import {
48
50
  clampCropRect,
49
51
  containImageRect,
@@ -56,7 +58,14 @@ import {
56
58
  } from "/avatar-crop.js?v=20260725-avatar-crop";
57
59
 
58
60
  const defaultPageSizes = Object.freeze({
61
+ settings: 30,
59
62
  characters: 30,
63
+ races: 30,
64
+ organizations: 30,
65
+ timeline: 30,
66
+ outlines: 30,
67
+ relationships: 30,
68
+ reviews: 30,
60
69
  analysisTasks: 30,
61
70
  fileVersions: 30
62
71
  });
@@ -122,11 +131,23 @@ let presenceHeartbeatQueued = null;
122
131
  let presenceHeartbeatRequest = 0;
123
132
  const acknowledgedCollaborativeChangeIds = new Set();
124
133
  let collaborativeChangePromptOpen = false;
125
- let peerPageStale = false;
134
+ let relationshipPresenceId = null;
126
135
  let collaborationAutoSaveDisabled = false;
136
+ let chapterAnnotations = [];
137
+ let workAuditRecords = [];
138
+ let workAuditNextPage = null;
139
+ const chapterBatchSelectedIds = new Set();
140
+ let chapterMovePending = false;
127
141
 
128
142
  let timelineMultiSelectEnabled = false;
129
143
  let taskProgressRefreshTimer = null;
144
+ let relationshipSearchIndexRefreshTimer = null;
145
+ let backgroundTaskCenterTimer = null;
146
+ let backgroundTaskCenterRequest = 0;
147
+ let backgroundTaskCenterWorkId = null;
148
+ let backgroundTaskCenterTasksInitialized = false;
149
+ let backgroundTaskCenterTaskSnapshots = new Map();
150
+ let backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
130
151
  const taskProgressRefreshInterval = 2_500;
131
152
  const taskStatusSnapshots = new Map();
132
153
 
@@ -168,6 +189,10 @@ function analysisTaskStatusLabel(status) {
168
189
  })[String(status)] ?? "未知状态";
169
190
  }
170
191
 
192
+ function canRerunAnalysisTask(task) {
193
+ return ["review", "completed", "partial", "expired", "cancelled"].includes(String(task?.status ?? ""));
194
+ }
195
+
171
196
  function normalizedAnalysisTaskStatus(status) {
172
197
  const value = String(status);
173
198
  return ["pending", "running", "review", "completed", "partial", "expired", "cancelled"].includes(value)
@@ -257,6 +282,7 @@ function applyWorkAccessMode() {
257
282
  }
258
283
  $("#module-nav [data-work-settings]").classList.toggle("permission-hidden", Boolean(state.work) && !canManageWork());
259
284
  $("#new-volume-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
285
+ $("#chapter-batch-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
260
286
  $("#welcome-new-work").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
261
287
  $("#import-file-button").setAttribute("aria-disabled", String(proseReadOnly));
262
288
  $("#import-file-button").setAttribute("title", proseReadOnly ? "当前权限不能导入正文" : "导入 TXT / DOCX");
@@ -265,6 +291,7 @@ function applyWorkAccessMode() {
265
291
  $("#ai-prompt").readOnly = aiReadOnly;
266
292
  $("#ai-prompt").setAttribute("aria-readonly", String(aiReadOnly));
267
293
  $("#ai-send").classList.toggle("permission-hidden", aiReadOnly);
294
+ updateBackgroundTaskCenterVisibility();
268
295
  if (proseReadOnly) {
269
296
  chapterEditorReadOnly = true;
270
297
  cancelChapterAutoSave();
@@ -338,6 +365,7 @@ let workScopedUiGeneration = 0;
338
365
  let importHistoryRecords = [];
339
366
  let importHistoryNextPage = null;
340
367
  let importHistoryRequestId = 0;
368
+ let chapterInsightRequestId = 0;
341
369
 
342
370
  const shelfOnboardingSteps = [
343
371
  { selector: "#home-button", eyebrow: "作品入口", title: "这里是你的创作书架", description: "点击左上角的叙界标志,可以随时回到书架,在不同作品之间切换。", placement: "bottom" },
@@ -538,11 +566,12 @@ function replacePageRoute(route) {
538
566
  }
539
567
 
540
568
  function presencePageForRoute(route = currentPageRoute()) {
541
- if (!state.work || route.view === "shelf" || route.view === "platform-ai") return null;
569
+ if (!state.work || route.view === "shelf" || route.view === "platform-ai" || route.view === "platform-usage") return null;
570
+ if (relationshipPresenceId) return { kind: "entity-editor", module: "relationship", resourceId: relationshipPresenceId };
542
571
  if (route.view === "editor") return { kind: "editor", resourceId: String(route.chapterId ?? "") || undefined };
543
572
  if (route.view === "entity-editor") return { kind: "entity-editor", module: route.entity, resourceId: String(route.entityId ?? "") || undefined };
544
573
  if (route.view === "module") return { kind: "module", module: route.module };
545
- if (route.view === "settings" || route.view === "platform-ai") return { kind: "settings" };
574
+ if (route.view === "settings" || route.view === "platform-ai" || route.view === "platform-usage") return { kind: "settings" };
546
575
  return { kind: "welcome" };
547
576
  }
548
577
 
@@ -628,7 +657,7 @@ async function refreshPresence() {
628
657
  presenceParticipants = Array.isArray(payload) ? payload : (payload?.participants ?? []);
629
658
  renderPresence();
630
659
  const recentChanges = Array.isArray(payload) ? [] : (payload?.recentChanges ?? []);
631
- void handleCollaborativeChanges(recentChanges);
660
+ void handleRelationshipCollaborativeChanges(recentChanges);
632
661
  }
633
662
  } catch {
634
663
  if (state.work?.id === workId) renderPresence();
@@ -638,14 +667,10 @@ async function refreshPresence() {
638
667
  return presenceParticipants;
639
668
  }
640
669
 
641
- function localEditingDirty() {
642
- return Boolean(state.dirty || entityEditorDirty || characterSectionEditorDirty || knowledgeSectionEditorDirty);
643
- }
644
-
645
- async function handleCollaborativeChanges(recentChanges) {
670
+ async function handleRelationshipCollaborativeChanges(recentChanges) {
646
671
  if (!Array.isArray(recentChanges) || !recentChanges.length || collaborativeChangePromptOpen) return;
647
672
  const localKey = presencePageKey(presencePageForRoute());
648
- if (!localKey) return;
673
+ if (!localKey.startsWith("entity-editor:relationship:")) return;
649
674
  const selfId = state.user?.userId;
650
675
  const incoming = recentChanges.filter((change) => (
651
676
  change
@@ -662,14 +687,10 @@ async function handleCollaborativeChanges(recentChanges) {
662
687
  const oldest = acknowledgedCollaborativeChangeIds.values().next().value;
663
688
  acknowledgedCollaborativeChangeIds.delete(oldest);
664
689
  }
665
- peerPageStale = true;
666
690
  collaborativeChangePromptOpen = true;
667
- const dirtyHint = localEditingDirty()
668
- ? "你当前页面还有未保存修改。请先把正在编辑的内容复制保存到别处(本页无法代为另存),再刷新页面后重新提交。"
669
- : "请先确认本地没有需要保留的草稿;如有,请复制保存到别处(本页无法代为另存),再刷新页面后继续编辑。";
670
691
  try {
671
- const shouldReload = await confirmToast(`${latest.actorDisplayName || "协作者"}已更新「${latest.label || "当前页面"}」。${dirtyHint}`, {
672
- title: "协作者已更新",
692
+ const shouldReload = await confirmToast(`${latest.actorDisplayName || "协作者"}已更新当前人物关系。请先确认本地没有需要保留的修改,再刷新页面继续查看。`, {
693
+ title: "人物关系已更新",
673
694
  confirmLabel: "刷新页面",
674
695
  cancelLabel: "稍后处理"
675
696
  });
@@ -679,17 +700,6 @@ async function handleCollaborativeChanges(recentChanges) {
679
700
  }
680
701
  }
681
702
 
682
- async function confirmPeerStaleSave() {
683
- if (!peerPageStale) return true;
684
- const confirmed = await confirmToast("协作者已更新当前页面。请先把正在编辑的内容复制保存到别处(本页无法代为另存),再刷新页面后重新提交。继续保存可能覆盖对方修改或触发冲突。", {
685
- title: "页面内容已过期",
686
- confirmLabel: "仍然保存",
687
- cancelLabel: "暂不保存"
688
- });
689
- if (confirmed) peerPageStale = false;
690
- return confirmed;
691
- }
692
-
693
703
  function schedulePresenceHeartbeat() {
694
704
  if (presenceHeartbeatQueued !== null) clearTimeout(presenceHeartbeatQueued);
695
705
  presenceHeartbeatQueued = setTimeout(() => {
@@ -698,9 +708,15 @@ function schedulePresenceHeartbeat() {
698
708
  }, 80);
699
709
  }
700
710
 
711
+ function setRelationshipPresence(relationshipId) {
712
+ const nextId = relationshipId ? String(relationshipId) : null;
713
+ if (relationshipPresenceId === nextId) return;
714
+ relationshipPresenceId = nextId;
715
+ void refreshPresence();
716
+ }
717
+
701
718
  async function confirmConcurrentSave() {
702
719
  await refreshPresence();
703
- if (!(await confirmPeerStaleSave())) return false;
704
720
  const localKey = presencePageKey(presencePageForRoute());
705
721
  const peers = presenceParticipants.filter((participant) => participant.clientId !== presenceClientId && participant.page.key === localKey);
706
722
  if (!peers.length) return true;
@@ -720,6 +736,7 @@ function currentPageRoute() {
720
736
  }
721
737
  if (!$("#settings-hub-view").classList.contains("hidden")) return { view: "settings", workId, ...settingsRouteContext() };
722
738
  if (!$("#platform-ai-view").classList.contains("hidden")) return { view: "platform-ai", workId, ...settingsRouteContext() };
739
+ if (!$("#platform-usage-view").classList.contains("hidden")) return { view: "platform-usage", workId, ...settingsRouteContext() };
723
740
  if (!$("#shelf-view").classList.contains("hidden")) return { view: "shelf" };
724
741
  if (!workId) return { view: "shelf" };
725
742
  if (!$("#editor-view").classList.contains("hidden")) return { view: "editor", workId, chapterId: state.chapter?.id ?? null };
@@ -843,6 +860,16 @@ let entityEditorReadOnly = false;
843
860
  let chapterEditorReadOnly = true;
844
861
  let characterListPage = 1;
845
862
  let taskListPage = 1;
863
+ const moduleListPages = {
864
+ settings: 1,
865
+ races: 1,
866
+ organizations: 1,
867
+ timeline: 1,
868
+ outlinePlans: 1,
869
+ foreshadows: 1,
870
+ relationships: 1,
871
+ reviews: 1
872
+ };
846
873
  const characterFilters = { raceIds: [], organizationIds: [] };
847
874
  let characterFiltersPanelOpen = false;
848
875
  const relationshipFilters = { fromCharacterIds: [], toCharacterIds: [] };
@@ -876,6 +903,8 @@ function applyChapterEditorMode() {
876
903
  $("#chapter-title").setAttribute("aria-readonly", String(viewOnly));
877
904
  $("#chapter-content").setAttribute("aria-readonly", String(viewOnly));
878
905
  $("#chapter-edit-button").classList.toggle("hidden", permissionBlocked || !chapterEditorReadOnly || !state.chapter);
906
+ $("#chapter-delete-button").classList.toggle("hidden", permissionBlocked || !state.chapter);
907
+ $("#chapter-annotations-button").classList.toggle("hidden", !state.chapter);
879
908
  if (viewOnly) cancelChapterAutoSave();
880
909
  }
881
910
 
@@ -1714,6 +1743,96 @@ function addSelectedLinesAsCitation() {
1714
1743
  toast(`已引用《${citation.chapterTitle}》第 ${citation.startLine}${citation.startLine === citation.endLine ? "" : `-${citation.endLine}`} 行`);
1715
1744
  }
1716
1745
 
1746
+ async function createSelectedLineAnnotation(kind) {
1747
+ if (!state.chapter || !chapterLineSelection || !canEditProse()) return;
1748
+ const selection = selectedChapterLinePayload(chapterLineSelection.start, chapterLineSelection.end);
1749
+ closeLineCitationMenu();
1750
+ const note = await inputToast(kind === "todo" ? "描述需要后续处理的事项" : "写下这段正文的批注", {
1751
+ title: kind === "todo" ? "添加正文待办" : "添加正文批注",
1752
+ inputLabel: kind === "todo" ? "待办内容" : "批注内容",
1753
+ confirmLabel: "添加",
1754
+ maxLength: 2000
1755
+ });
1756
+ if (!note) return;
1757
+ try {
1758
+ await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}/annotations`, {
1759
+ method: "POST",
1760
+ body: { kind, startLine: selection.safeStart + 1, endLine: selection.safeEnd + 1, note }
1761
+ });
1762
+ toast(kind === "todo" ? "正文待办已添加" : "正文批注已添加");
1763
+ await openChapterAnnotationsDialog();
1764
+ } catch (error) {
1765
+ toast(error.message, "error");
1766
+ }
1767
+ }
1768
+
1769
+ function renderChapterAnnotations() {
1770
+ const host = $("#chapter-annotations-list");
1771
+ host.innerHTML = chapterAnnotations.length ? chapterAnnotations.map((annotation) => `<article class="chapter-annotation-card ${annotation.status === "resolved" ? "is-resolved" : ""}" data-annotation-id="${esc(annotation.id)}">
1772
+ <header><span class="chapter-annotation-kind">${annotation.kind === "todo" ? "待办" : "批注"}</span><strong>${annotation.startLine === annotation.endLine ? `第 ${annotation.startLine} 行` : `第 ${annotation.startLine}-${annotation.endLine} 行`}</strong><em>${annotation.status === "resolved" ? "已完成" : "处理中"}</em></header>
1773
+ <blockquote>${esc(annotation.quote || "空白行")}</blockquote>
1774
+ <p>${esc(annotation.note)}</p>
1775
+ <small>${esc(annotation.actor)} · ${esc(formatDateTime(annotation.updatedAt))} · v${Number(annotation.versionNo)}</small>
1776
+ <footer><button type="button" data-annotation-locate>定位原文</button>${canEditProse() ? `<button type="button" data-annotation-edit>编辑说明</button><button type="button" data-annotation-status>${annotation.status === "resolved" ? "重新打开" : "完成待办"}</button><button class="danger-button" type="button" data-annotation-delete>删除</button>` : ""}</footer>
1777
+ </article>`).join("") : '<p class="entity-history-empty">本章还没有批注或待办。选择正文行并打开右键菜单即可添加。</p>';
1778
+ host.querySelectorAll("[data-annotation-id]").forEach((card) => {
1779
+ const annotation = chapterAnnotations.find((item) => item.id === card.dataset.annotationId);
1780
+ if (!annotation) return;
1781
+ card.querySelector("[data-annotation-locate]")?.addEventListener("click", () => {
1782
+ $("#chapter-annotations-dialog").close();
1783
+ paintChapterLineSelection(annotation.startLine - 1, annotation.endLine - 1);
1784
+ selectChapterLines(annotation.startLine - 1, annotation.endLine - 1);
1785
+ });
1786
+ card.querySelector("[data-annotation-status]")?.addEventListener("click", async () => {
1787
+ try {
1788
+ await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "PATCH", body: { status: annotation.status === "resolved" ? "open" : "resolved", expectedVersionNo: annotation.versionNo } });
1789
+ await loadChapterAnnotations();
1790
+ } catch (error) { toast(error.message, "error"); }
1791
+ });
1792
+ card.querySelector("[data-annotation-edit]")?.addEventListener("click", async () => {
1793
+ const dialog = $("#chapter-annotations-dialog");
1794
+ dialog.close();
1795
+ const note = await inputToast("修改批注或待办说明", { title: "编辑说明", inputLabel: "说明", placeholder: annotation.note, confirmLabel: "保存", maxLength: 2000 });
1796
+ if (!note) return dialog.showModal();
1797
+ try {
1798
+ await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "PATCH", body: { note, expectedVersionNo: annotation.versionNo } });
1799
+ await loadChapterAnnotations();
1800
+ } catch (error) { toast(error.message, "error"); }
1801
+ dialog.showModal();
1802
+ });
1803
+ card.querySelector("[data-annotation-delete]")?.addEventListener("click", async () => {
1804
+ const dialog = $("#chapter-annotations-dialog");
1805
+ dialog.close();
1806
+ const confirmed = await confirmToast("删除后不会在本章清单中显示,但历史版本仍会保留。", { title: "删除批注或待办", confirmLabel: "确认删除" });
1807
+ if (!confirmed) return dialog.showModal();
1808
+ try {
1809
+ await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "DELETE", body: { expectedVersionNo: annotation.versionNo } });
1810
+ await loadChapterAnnotations();
1811
+ dialog.showModal();
1812
+ } catch (error) { dialog.showModal(); toast(error.message, "error"); }
1813
+ });
1814
+ });
1815
+ }
1816
+
1817
+ async function loadChapterAnnotations() {
1818
+ if (!state.chapter) return;
1819
+ chapterAnnotations = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}/annotations`);
1820
+ renderChapterAnnotations();
1821
+ }
1822
+
1823
+ async function openChapterAnnotationsDialog() {
1824
+ if (!state.chapter) return;
1825
+ $("#chapter-annotations-meta").textContent = `《${state.chapter.title}》的正文批注与待办`;
1826
+ $("#chapter-annotations-list").innerHTML = '<p class="entity-history-empty">正在加载批注与待办…</p>';
1827
+ if (!$("#chapter-annotations-dialog").open) $("#chapter-annotations-dialog").showModal();
1828
+ try {
1829
+ await loadChapterAnnotations();
1830
+ } catch (error) {
1831
+ $("#chapter-annotations-dialog").close();
1832
+ toast(error.message, "error");
1833
+ }
1834
+ }
1835
+
1717
1836
  function closeLineCitationMenu() {
1718
1837
  $("#line-citation-menu").classList.add("hidden");
1719
1838
  }
@@ -1725,6 +1844,8 @@ function showLineCitationMenu(event, lineIndex) {
1725
1844
  selectChapterLines(lineIndex, lineIndex);
1726
1845
  }
1727
1846
  const menu = $("#line-citation-menu");
1847
+ $("#add-line-annotation").classList.toggle("hidden", !canEditProse());
1848
+ $("#add-line-todo").classList.toggle("hidden", !canEditProse());
1728
1849
  const { start, end } = chapterLineSelection;
1729
1850
  $("#line-citation-label").textContent = start === end ? `第 ${start + 1} 行` : `第 ${start + 1}-${end + 1} 行`;
1730
1851
  menu.classList.remove("hidden");
@@ -2077,6 +2198,40 @@ function pageSizeFor(module) {
2077
2198
  return normalizePageSize(state.uiSettings.pageSizes[module], defaultPageSizes[module] ?? 30);
2078
2199
  }
2079
2200
 
2201
+ function paginateModuleItems(items, page, sizeKey) {
2202
+ const limit = pageSizeFor(sizeKey);
2203
+ const total = items.length;
2204
+ const pageCount = Math.max(1, Math.ceil(total / limit));
2205
+ const safePage = Math.min(Math.max(1, Number(page) || 1), pageCount);
2206
+ const start = (safePage - 1) * limit;
2207
+ return {
2208
+ items: items.slice(start, start + limit),
2209
+ page: safePage,
2210
+ limit,
2211
+ total,
2212
+ pageCount,
2213
+ hasMore: safePage < pageCount,
2214
+ nextPage: safePage < pageCount ? safePage + 1 : null
2215
+ };
2216
+ }
2217
+
2218
+ function renderModulePagination(pageResult, pageKey, label) {
2219
+ if (pageResult.pageCount <= 1) return "";
2220
+ return `<nav class="module-pagination" aria-label="${esc(label)}分页">
2221
+ <button type="button" data-module-page-key="${esc(pageKey)}" data-module-page="${pageResult.page - 1}" ${pageResult.page <= 1 ? "disabled" : ""}>上一页</button>
2222
+ <span>第 ${pageResult.page}/${pageResult.pageCount} 页 · 本页 ${pageResult.itemCount ?? pageResult.items.length} 条 · 共 ${pageResult.total} 条</span>
2223
+ <button type="button" data-module-page-key="${esc(pageKey)}" data-module-page="${pageResult.nextPage ?? pageResult.page + 1}" ${pageResult.hasMore ? "" : "disabled"}>下一页</button>
2224
+ </nav>`;
2225
+ }
2226
+
2227
+ function bindModulePagination(pageKey, renderPage) {
2228
+ $("#module-content").querySelectorAll(`[data-module-page-key="${CSS.escape(pageKey)}"]`).forEach((button) => button.addEventListener("click", async () => {
2229
+ if (button.disabled) return;
2230
+ $("#module-content").querySelectorAll(`[data-module-page-key="${CSS.escape(pageKey)}"]`).forEach((control) => { control.disabled = true; });
2231
+ await renderPage(Number(button.dataset.modulePage));
2232
+ }));
2233
+ }
2234
+
2080
2235
  async function loadPlatformUiSettings() {
2081
2236
  try {
2082
2237
  applyPlatformUiSettings(await api("/api/ui-settings"));
@@ -2110,6 +2265,16 @@ function raiseToastRegion() {
2110
2265
  region.showPopover();
2111
2266
  }
2112
2267
 
2268
+ function dismissChapterInsightToast() {
2269
+ chapterInsightRequestId += 1;
2270
+ const region = $("#toast-region");
2271
+ region.querySelector(".chapter-insight-toast")?.remove();
2272
+ $("#insight-button").setAttribute("aria-expanded", "false");
2273
+ if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) {
2274
+ region.hidePopover();
2275
+ }
2276
+ }
2277
+
2113
2278
  function toast(message, type = "info") {
2114
2279
  const region = $("#toast-region");
2115
2280
  const element = document.createElement("div");
@@ -2467,6 +2632,11 @@ async function initializePage() {
2467
2632
  if (route.view === "platform-ai") {
2468
2633
  await showPlatformAi();
2469
2634
  settingsReturnContext = restoredSettingsReturnContext(route);
2635
+ return;
2636
+ }
2637
+ if (route.view === "platform-usage") {
2638
+ await showPlatformUsage();
2639
+ settingsReturnContext = restoredSettingsReturnContext(route);
2470
2640
  }
2471
2641
  } finally {
2472
2642
  document.body.classList.remove("auth-pending");
@@ -2477,12 +2647,15 @@ async function initializePage() {
2477
2647
  }
2478
2648
 
2479
2649
  function showShelf() {
2650
+ stopBackgroundTaskCenter();
2651
+ dismissChapterInsightToast();
2480
2652
  state.dirty = false;
2481
2653
  settingsReturnContext = null;
2482
2654
  updateDocumentTitle();
2483
2655
  $("#app").classList.add("shelf-mode");
2484
2656
  $("#shelf-view").classList.remove("hidden");
2485
2657
  $("#platform-ai-view").classList.add("hidden");
2658
+ $("#platform-usage-view").classList.add("hidden");
2486
2659
  $("#settings-hub-view").classList.add("hidden");
2487
2660
  $("#welcome-view").classList.add("hidden");
2488
2661
  $("#editor-view").classList.add("hidden");
@@ -2509,9 +2682,12 @@ function renderSettingsHub() {
2509
2682
  const canReadAggregate = hasWork && canReadAggregateContent();
2510
2683
  const isAdmin = state.user?.role === "admin";
2511
2684
  $("#platform-ai-button").classList.toggle("hidden", !isAdmin);
2685
+ $("#platform-usage-button").classList.toggle("hidden", !isAdmin);
2512
2686
  $("#user-management-button").classList.toggle("hidden", !isAdmin);
2513
2687
  $("#platform-ui-settings-button").classList.toggle("hidden", !isAdmin);
2514
2688
  $("#collaboration-button").disabled = !canManageWork;
2689
+ $("#writing-progress-button").disabled = !hasWork || !canReadModule("editor");
2690
+ $("#work-audit-button").disabled = !canManageWork;
2515
2691
  $("#top-search-button").disabled = !canReadAggregate;
2516
2692
  $("#export-button").disabled = !canReadAggregate;
2517
2693
  $("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
@@ -2520,6 +2696,115 @@ function renderSettingsHub() {
2520
2696
  : "当前未选择作品;打开作品后可使用导出。";
2521
2697
  }
2522
2698
 
2699
+ function renderWritingProgress(progress) {
2700
+ $("#writing-current-words").textContent = Number(progress.currentWords).toLocaleString("zh-CN");
2701
+ $("#writing-today-words").textContent = Number(progress.todayWords).toLocaleString("zh-CN");
2702
+ $("#writing-daily-completion").textContent = `${Math.round(Number(progress.dailyCompletion) * 100)}%`;
2703
+ $("#writing-total-completion").textContent = `${Math.round(Number(progress.totalCompletion) * 100)}%`;
2704
+ $("#writing-daily-goal").value = String(progress.goal.dailyGoal);
2705
+ $("#writing-total-goal").value = String(progress.goal.targetTotal);
2706
+ $("#writing-deadline").value = progress.goal.deadline ?? "";
2707
+ $("#writing-goal-save").disabled = !canEditProse();
2708
+ const maxWords = Math.max(1, ...progress.trend.map((item) => Number(item.words)));
2709
+ $("#writing-trend-chart").innerHTML = progress.trend.map((item, index) => {
2710
+ const height = Math.max(3, Math.round(Number(item.words) / maxWords * 100));
2711
+ const label = `${item.date}:${Number(item.words).toLocaleString("zh-CN")} 字,较前日 ${Number(item.delta) >= 0 ? "+" : ""}${Number(item.delta).toLocaleString("zh-CN")}`;
2712
+ return `<span class="writing-trend-bar${index === progress.trend.length - 1 ? " is-today" : ""}" style="--bar-height:${height}%" title="${esc(label)}" aria-label="${esc(label)}"><i></i><small>${index % 5 === 0 || index === progress.trend.length - 1 ? esc(item.date.slice(5)) : ""}</small></span>`;
2713
+ }).join("");
2714
+ }
2715
+
2716
+ async function loadWritingProgress() {
2717
+ if (!state.work) return;
2718
+ renderWritingProgress(await api(`/api/works/${encodeURIComponent(state.work.id)}/writing-progress`));
2719
+ }
2720
+
2721
+ async function openWritingProgressDialog() {
2722
+ if (!state.work || !canReadModule("editor")) return;
2723
+ $("#writing-progress-dialog").showModal();
2724
+ try {
2725
+ await loadWritingProgress();
2726
+ } catch (error) {
2727
+ $("#writing-progress-dialog").close();
2728
+ toast(error.message, "error");
2729
+ }
2730
+ }
2731
+
2732
+ const workAuditActionLabels = {
2733
+ "work.created": "创建作品",
2734
+ "work.updated": "更新作品",
2735
+ "volume.created": "创建分卷",
2736
+ "volume.updated": "更新分卷",
2737
+ "volume.deleted": "删除分卷",
2738
+ "chapter.created": "创建章节",
2739
+ "chapter.saved": "保存章节",
2740
+ "chapter.moved": "移动章节",
2741
+ "chapter.deleted": "删除章节",
2742
+ "chapter.restored": "恢复章节",
2743
+ "work.imported": "导入正文"
2744
+ };
2745
+
2746
+ function workAuditEntityLabel(type) {
2747
+ return ({ work: "作品", volume: "分卷", chapter: "章节", user: "用户" })[type] ?? type;
2748
+ }
2749
+
2750
+ function workAuditDetailText(detail) {
2751
+ const entries = Object.entries(detail ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== "").slice(0, 6);
2752
+ return entries.map(([key, value]) => `${key}: ${typeof value === "object" ? JSON.stringify(value) : String(value)}`).join(" · ");
2753
+ }
2754
+
2755
+ function renderWorkAuditRecords() {
2756
+ $("#work-audit-list").innerHTML = workAuditRecords.length ? workAuditRecords.map((record) => `<article class="work-audit-row">
2757
+ <time>${esc(formatDateTime(record.createdAt))}</time>
2758
+ <div><strong>${esc(workAuditActionLabels[record.action] ?? record.action)}</strong><span>${esc(record.actor)} · ${esc(workAuditEntityLabel(record.entityType))}${record.entityId ? ` · ${esc(record.entityId)}` : ""}</span>${workAuditDetailText(record.detail) ? `<small>${esc(workAuditDetailText(record.detail))}</small>` : ""}</div>
2759
+ </article>`).join("") : '<p class="entity-history-empty">当前作品还没有操作记录。</p>';
2760
+ $("#work-audit-load-more").classList.toggle("hidden", workAuditNextPage === null);
2761
+ }
2762
+
2763
+ async function loadWorkAuditPage(page = 1, append = false) {
2764
+ if (!state.work) return;
2765
+ const result = await apiPage(`/api/works/${encodeURIComponent(state.work.id)}/audit-logs`, page, 30);
2766
+ workAuditRecords = append ? [...workAuditRecords, ...result.items] : result.items;
2767
+ workAuditNextPage = result.nextPage;
2768
+ renderWorkAuditRecords();
2769
+ }
2770
+
2771
+ async function openWorkAuditDialog() {
2772
+ if (!state.work || !["admin", "owner"].includes(String(state.work.accessRole))) return;
2773
+ workAuditRecords = [];
2774
+ workAuditNextPage = null;
2775
+ $("#work-audit-list").innerHTML = '<p class="entity-history-empty">正在加载操作记录…</p>';
2776
+ $("#work-audit-dialog").showModal();
2777
+ try {
2778
+ await loadWorkAuditPage();
2779
+ } catch (error) {
2780
+ $("#work-audit-dialog").close();
2781
+ toast(error.message, "error");
2782
+ }
2783
+ }
2784
+
2785
+ async function saveWritingGoal(event) {
2786
+ event.preventDefault();
2787
+ if (!state.work || !canEditProse()) return;
2788
+ const button = $("#writing-goal-save");
2789
+ button.disabled = true;
2790
+ try {
2791
+ const progress = await api(`/api/works/${encodeURIComponent(state.work.id)}/writing-goal`, {
2792
+ method: "PUT",
2793
+ body: {
2794
+ dailyGoal: Number($("#writing-daily-goal").value),
2795
+ targetTotal: Number($("#writing-total-goal").value),
2796
+ deadline: $("#writing-deadline").value || null
2797
+ }
2798
+ });
2799
+ renderWritingProgress(progress);
2800
+ toast("写作目标已保存");
2801
+ } catch (error) {
2802
+ toast(error.message, "error");
2803
+ } finally {
2804
+ button.disabled = !canEditProse();
2805
+ }
2806
+ }
2807
+
2523
2808
  function renderUsers(users) {
2524
2809
  const currentUserId = state.user?.userId;
2525
2810
  $("#users-list").innerHTML = users.map((user) => `<article class="access-row" data-user-row="${esc(user.userId)}">
@@ -2570,7 +2855,14 @@ async function openPlatformUiSettingsDialog() {
2570
2855
  const settings = await api("/api/platform/ui-settings");
2571
2856
  $("#toast-position").value = settings.toastPosition === "top-right" ? "top-right" : "bottom-right";
2572
2857
  const pageSizes = normalizePageSizes(settings.pageSizes);
2858
+ $("#page-size-settings").value = String(pageSizes.settings);
2573
2859
  $("#page-size-characters").value = String(pageSizes.characters);
2860
+ $("#page-size-races").value = String(pageSizes.races);
2861
+ $("#page-size-organizations").value = String(pageSizes.organizations);
2862
+ $("#page-size-timeline").value = String(pageSizes.timeline);
2863
+ $("#page-size-outlines").value = String(pageSizes.outlines);
2864
+ $("#page-size-relationships").value = String(pageSizes.relationships);
2865
+ $("#page-size-reviews").value = String(pageSizes.reviews);
2574
2866
  $("#page-size-analysis-tasks").value = String(pageSizes.analysisTasks);
2575
2867
  $("#page-size-file-versions").value = String(pageSizes.fileVersions);
2576
2868
  $("#platform-ui-settings-dialog").showModal();
@@ -2727,7 +3019,9 @@ async function openSearchResult(result) {
2727
3019
  const target = resolveGlobalSearchTarget(result);
2728
3020
  if (!target) throw new Error("无法打开该搜索结果");
2729
3021
  $("#search-dialog").close();
2730
- const inSettings = !$("#settings-hub-view").classList.contains("hidden") || !$("#platform-ai-view").classList.contains("hidden");
3022
+ const inSettings = !$("#settings-hub-view").classList.contains("hidden")
3023
+ || !$("#platform-ai-view").classList.contains("hidden")
3024
+ || !$("#platform-usage-view").classList.contains("hidden");
2731
3025
  if (inSettings) await returnFromSettings();
2732
3026
  if (target.kind === "chapter") {
2733
3027
  await selectChapter(target.id);
@@ -2743,16 +3037,20 @@ async function openSearchResult(result) {
2743
3037
  }
2744
3038
 
2745
3039
  async function showSettingsHub() {
2746
- const alreadyInSettings = !$("#settings-hub-view").classList.contains("hidden") || !$("#platform-ai-view").classList.contains("hidden");
3040
+ const alreadyInSettings = !$("#settings-hub-view").classList.contains("hidden")
3041
+ || !$("#platform-ai-view").classList.contains("hidden")
3042
+ || !$("#platform-usage-view").classList.contains("hidden");
2747
3043
  if (!alreadyInSettings) {
2748
3044
  if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入设置将放弃本地修改。是否继续?"))) return false;
2749
3045
  settingsReturnContext = captureSettingsReturnContext();
2750
3046
  state.dirty = false;
2751
3047
  }
3048
+ dismissChapterInsightToast();
2752
3049
  updateDocumentTitle(state.work);
2753
3050
  $("#app").classList.add("shelf-mode");
2754
3051
  $("#shelf-view").classList.add("hidden");
2755
3052
  $("#platform-ai-view").classList.add("hidden");
3053
+ $("#platform-usage-view").classList.add("hidden");
2756
3054
  $("#settings-hub-view").classList.remove("hidden");
2757
3055
  $("#welcome-view").classList.add("hidden");
2758
3056
  $("#editor-view").classList.add("hidden");
@@ -2778,6 +3076,7 @@ async function returnFromSettings() {
2778
3076
  $("#settings-button").removeAttribute("aria-current");
2779
3077
  $("#settings-hub-view").classList.add("hidden");
2780
3078
  $("#platform-ai-view").classList.add("hidden");
3079
+ $("#platform-usage-view").classList.add("hidden");
2781
3080
  if (context.view === "shelf" || !state.work) return showShelf();
2782
3081
  $("#app").classList.remove("shelf-mode");
2783
3082
  $("#shelf-view").classList.add("hidden");
@@ -2791,10 +3090,12 @@ async function returnFromSettings() {
2791
3090
  async function showPlatformAi() {
2792
3091
  if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入平台 AI 管理将放弃本地修改。是否继续?"))) return false;
2793
3092
  state.dirty = false;
3093
+ dismissChapterInsightToast();
2794
3094
  updateDocumentTitle();
2795
3095
  $("#app").classList.add("shelf-mode");
2796
3096
  $("#shelf-view").classList.add("hidden");
2797
3097
  $("#platform-ai-view").classList.remove("hidden");
3098
+ $("#platform-usage-view").classList.add("hidden");
2798
3099
  $("#settings-hub-view").classList.add("hidden");
2799
3100
  $("#welcome-view").classList.add("hidden");
2800
3101
  $("#editor-view").classList.add("hidden");
@@ -2807,6 +3108,26 @@ async function showPlatformAi() {
2807
3108
  return true;
2808
3109
  }
2809
3110
 
3111
+ async function showPlatformUsage() {
3112
+ if (state.dirty && !(await confirmDiscardChanges("当前章节有未保存修改,进入 Token 用量面板将放弃本地修改。是否继续?"))) return false;
3113
+ state.dirty = false;
3114
+ updateDocumentTitle();
3115
+ $("#app").classList.add("shelf-mode");
3116
+ $("#shelf-view").classList.add("hidden");
3117
+ $("#platform-ai-view").classList.add("hidden");
3118
+ $("#platform-usage-view").classList.remove("hidden");
3119
+ $("#settings-hub-view").classList.add("hidden");
3120
+ $("#welcome-view").classList.add("hidden");
3121
+ $("#editor-view").classList.add("hidden");
3122
+ $("#module-view").classList.add("hidden");
3123
+ $("#work-meta").textContent = "Token 用量";
3124
+ $("#settings-button").setAttribute("aria-current", "page");
3125
+ setSaveState("Token 用量");
3126
+ await renderPlatformTokenUsage();
3127
+ replacePageRoute({ view: "platform-usage", workId: state.work?.id ?? null, ...settingsRouteContext() });
3128
+ return true;
3129
+ }
3130
+
2810
3131
  function renderShelf() {
2811
3132
  const shelf = $("#book-shelf");
2812
3133
  shelf.innerHTML = `${state.works.map((work) => `
@@ -2830,6 +3151,7 @@ function renderShelf() {
2830
3151
  }
2831
3152
 
2832
3153
  function resetWorkScopedUiCaches() {
3154
+ stopBackgroundTaskCenter();
2833
3155
  workScopedUiGeneration += 1;
2834
3156
  loadedAiModelsWorkId = null;
2835
3157
  loadedAiReferencesWorkId = null;
@@ -2844,6 +3166,7 @@ function resetWorkScopedUiCaches() {
2844
3166
  state.characters = [];
2845
3167
  state.settings = [];
2846
3168
  characterListPage = 1;
3169
+ Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
2847
3170
  relationshipFilters.fromCharacterIds = [];
2848
3171
  relationshipFilters.toCharacterIds = [];
2849
3172
  taskListPage = 1;
@@ -2877,6 +3200,7 @@ async function selectWork(workId, preferredChapterId = null) {
2877
3200
  $("#app").classList.remove("shelf-mode");
2878
3201
  $("#shelf-view").classList.add("hidden");
2879
3202
  $("#platform-ai-view").classList.add("hidden");
3203
+ $("#platform-usage-view").classList.add("hidden");
2880
3204
  $("#settings-hub-view").classList.add("hidden");
2881
3205
  $("#settings-button").removeAttribute("aria-current");
2882
3206
  settingsReturnContext = null;
@@ -2885,6 +3209,7 @@ async function selectWork(workId, preferredChapterId = null) {
2885
3209
  chapterEditorReadOnly = true;
2886
3210
  if (!canReadModule(state.module)) state.module = firstReadableUiModule(state.work) ?? "editor";
2887
3211
  applyWorkAccessMode();
3212
+ startBackgroundTaskCenter(nextWork.id);
2888
3213
  updateDocumentTitle(state.work);
2889
3214
  $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
2890
3215
  $("#top-search-button").disabled = !canReadAggregateContent();
@@ -2908,12 +3233,12 @@ function renderTree() {
2908
3233
  $("#novel-tree").innerHTML = state.work.volumes.map((volume) => `
2909
3234
  <div class="volume-node ${state.collapsedVolumeIds.has(volume.id) ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
2910
3235
  <div class="volume-title">
2911
- <button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${state.collapsedVolumeIds.has(volume.id) ? "false" : "true"}" title="左键折叠,右键设置分卷"><span>${esc(volume.title)}</span><span>${volume.chapters.length} 章</span></button>
3236
+ <button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${state.collapsedVolumeIds.has(volume.id) ? "false" : "true"}" title="左键折叠,右键设置分卷;可将章节拖到这里追加"><span>${esc(volume.title)}</span><span>${volume.chapters.length} 章</span></button>
2912
3237
  ${proseEditable ? `<button class="add-button chapter-add-button" type="button" data-new-chapter-volume="${esc(volume.id)}" aria-label="在“${esc(volume.title)}”中新建章节" title="在“${esc(volume.title)}”中新建章节">+</button>` : ""}
2913
3238
  </div>
2914
3239
  <div class="volume-chapters">
2915
3240
  ${volume.chapters.map((chapter) => `
2916
- <button class="chapter-node ${state.chapter?.id === chapter.id ? "active" : ""}" type="button" data-chapter-id="${esc(chapter.id)}">
3241
+ <button class="chapter-node ${state.chapter?.id === chapter.id ? "active" : ""}" type="button" data-chapter-id="${esc(chapter.id)}" draggable="${proseEditable ? "true" : "false"}" title="${proseEditable ? "拖拽排序;Alt+方向键排序,Alt+Shift+方向键跨卷" : ""}">
2917
3242
  <span>${esc(chapter.title)}</span><span class="chapter-node-meta">${chapter.chapterType && chapter.chapterType !== "正文" ? `<em class="chapter-type-badge">${esc(chapter.chapterType)}</em>` : ""}<small>${Number(chapter.wordCount ?? 0).toLocaleString("zh-CN")}</small></span>
2918
3243
  </button>`).join("")}
2919
3244
  </div>
@@ -2930,13 +3255,30 @@ function renderTree() {
2930
3255
  event.preventDefault();
2931
3256
  openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeToggle));
2932
3257
  });
3258
+ if (proseEditable) {
3259
+ button.addEventListener("dragover", (event) => {
3260
+ if (!event.dataTransfer?.types.includes("text/plain")) return;
3261
+ event.preventDefault();
3262
+ button.closest(".volume-node")?.classList.add("is-drag-target");
3263
+ });
3264
+ button.addEventListener("dragleave", () => button.closest(".volume-node")?.classList.remove("is-drag-target"));
3265
+ button.addEventListener("drop", async (event) => {
3266
+ event.preventDefault();
3267
+ button.closest(".volume-node")?.classList.remove("is-drag-target");
3268
+ const chapterId = event.dataTransfer?.getData("text/plain");
3269
+ const volume = state.work?.volumes.find((item) => item.id === button.dataset.volumeToggle);
3270
+ if (chapterId && volume) await moveChapterInTree(chapterId, volume.id, volume.chapters.length);
3271
+ });
3272
+ }
2933
3273
  });
2934
3274
  $("#novel-tree").querySelectorAll("[data-new-chapter-volume]").forEach((button) => {
2935
3275
  button.addEventListener("click", () => openChapterDialog(button.dataset.newChapterVolume));
2936
3276
  });
2937
3277
  $("#novel-tree").querySelectorAll("[data-chapter-id]").forEach((button) => {
2938
- button.addEventListener("click", () => {
2939
- selectChapter(button.dataset.chapterId);
3278
+ button.addEventListener("click", async () => {
3279
+ const chapterId = button.dataset.chapterId;
3280
+ await selectChapter(chapterId);
3281
+ $("#novel-tree").querySelector(`[data-chapter-id="${CSS.escape(chapterId)}"]`)?.focus();
2940
3282
  if (isMobileViewport()) {
2941
3283
  panelLayout.leftCollapsed = true;
2942
3284
  applyPanelLayout(true);
@@ -2947,9 +3289,172 @@ function renderTree() {
2947
3289
  event.preventDefault();
2948
3290
  openChapterTypeMenu(button.dataset.chapterId, event.clientX, event.clientY);
2949
3291
  });
3292
+ if (proseEditable) {
3293
+ button.addEventListener("dragstart", (event) => {
3294
+ event.dataTransfer?.setData("text/plain", button.dataset.chapterId);
3295
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
3296
+ button.classList.add("is-dragging");
3297
+ });
3298
+ button.addEventListener("dragend", () => {
3299
+ button.classList.remove("is-dragging");
3300
+ $("#novel-tree").querySelectorAll(".is-drag-over, .is-drag-target").forEach((node) => node.classList.remove("is-drag-over", "drop-after", "is-drag-target"));
3301
+ });
3302
+ button.addEventListener("dragover", (event) => {
3303
+ event.preventDefault();
3304
+ const after = event.clientY >= button.getBoundingClientRect().top + button.offsetHeight / 2;
3305
+ button.classList.toggle("drop-after", after);
3306
+ button.classList.add("is-drag-over");
3307
+ });
3308
+ button.addEventListener("dragleave", () => button.classList.remove("is-drag-over", "drop-after"));
3309
+ button.addEventListener("drop", async (event) => {
3310
+ event.preventDefault();
3311
+ event.stopPropagation();
3312
+ const chapterId = event.dataTransfer?.getData("text/plain");
3313
+ const target = findChapterLocation(button.dataset.chapterId);
3314
+ const after = button.classList.contains("drop-after");
3315
+ button.classList.remove("is-drag-over", "drop-after");
3316
+ if (!chapterId || !target) return;
3317
+ const targetChapters = target.volume.chapters.filter((chapter) => chapter.id !== chapterId);
3318
+ const targetIndex = targetChapters.findIndex((chapter) => chapter.id === button.dataset.chapterId);
3319
+ await moveChapterInTree(chapterId, target.volume.id, Math.max(0, targetIndex + (after ? 1 : 0)));
3320
+ });
3321
+ button.addEventListener("keydown", async (event) => {
3322
+ if (!event.altKey || !["ArrowUp", "ArrowDown"].includes(event.key)) return;
3323
+ event.preventDefault();
3324
+ await moveChapterByKeyboard(button.dataset.chapterId, event.key === "ArrowDown" ? 1 : -1, event.shiftKey);
3325
+ });
3326
+ }
2950
3327
  });
2951
3328
  }
2952
3329
 
3330
+ function renderChapterBatchDialog() {
3331
+ const chapters = state.work?.volumes.flatMap((volume) => volume.chapters.map((chapter) => ({ ...chapter, volumeTitle: volume.title }))) ?? [];
3332
+ for (const chapterId of chapterBatchSelectedIds) {
3333
+ if (!chapters.some((chapter) => chapter.id === chapterId)) chapterBatchSelectedIds.delete(chapterId);
3334
+ }
3335
+ $("#chapter-batch-list").innerHTML = chapters.length ? chapters.map((chapter) => `<label class="chapter-batch-item">
3336
+ <input type="checkbox" value="${esc(chapter.id)}" ${chapterBatchSelectedIds.has(chapter.id) ? "checked" : ""}>
3337
+ <span><strong>${esc(chapter.title)}</strong><small>${esc(chapter.volumeTitle)} · ${Number(chapter.wordCount ?? 0).toLocaleString("zh-CN")} 字 · ${esc(chapter.chapterType || "正文")}</small></span>
3338
+ </label>`).join("") : '<p class="entity-history-empty">当前作品还没有章节。</p>';
3339
+ $("#chapter-batch-list").querySelectorAll('input[type="checkbox"]').forEach((input) => input.addEventListener("change", () => {
3340
+ if (input.checked) chapterBatchSelectedIds.add(input.value);
3341
+ else chapterBatchSelectedIds.delete(input.value);
3342
+ updateChapterBatchControls();
3343
+ }));
3344
+ $("#chapter-batch-volume").innerHTML = (state.work?.volumes ?? []).map((volume) => `<option value="${esc(volume.id)}">${esc(volume.title)}</option>`).join("");
3345
+ updateChapterBatchControls();
3346
+ }
3347
+
3348
+ function updateChapterBatchControls() {
3349
+ const count = chapterBatchSelectedIds.size;
3350
+ const action = $("#chapter-batch-action").value;
3351
+ $("#chapter-batch-count").textContent = `已选择 ${count} 章`;
3352
+ $("#chapter-batch-apply").disabled = count === 0;
3353
+ $("#chapter-batch-volume-field").classList.toggle("hidden", action !== "move");
3354
+ $("#chapter-batch-type-field").classList.toggle("hidden", action !== "setType");
3355
+ $("#chapter-batch-apply").textContent = action === "delete" ? "软删除所选章节" : "应用到所选章节";
3356
+ }
3357
+
3358
+ function openChapterBatchDialog() {
3359
+ if (!state.work || !canEditProse()) return;
3360
+ chapterBatchSelectedIds.clear();
3361
+ renderChapterBatchDialog();
3362
+ $("#chapter-batch-dialog").showModal();
3363
+ }
3364
+
3365
+ async function submitChapterBatch(event) {
3366
+ event.preventDefault();
3367
+ if (!state.work || !chapterBatchSelectedIds.size) return;
3368
+ const chapters = state.work.volumes.flatMap((volume) => volume.chapters).filter((chapter) => chapterBatchSelectedIds.has(chapter.id));
3369
+ const actionValue = $("#chapter-batch-action").value;
3370
+ const action = actionValue === "move"
3371
+ ? { type: "move", volumeId: $("#chapter-batch-volume").value }
3372
+ : actionValue === "setType"
3373
+ ? { type: "setType", chapterType: $("#chapter-batch-type").value }
3374
+ : actionValue === "exclude" || actionValue === "include"
3375
+ ? { type: "setAnalysisExclusion", excludedFromAnalysis: actionValue === "exclude" }
3376
+ : { type: "delete" };
3377
+ const dialog = $("#chapter-batch-dialog");
3378
+ if (action.type === "delete") {
3379
+ dialog.close();
3380
+ const confirmed = await confirmToast(`所选 ${chapters.length} 个章节的正文、版本和关联资料会保留,后续可以恢复。仍要删除吗?`, {
3381
+ title: "批量删除需要再次确认",
3382
+ confirmLabel: "确认软删除"
3383
+ });
3384
+ if (!confirmed) {
3385
+ dialog.showModal();
3386
+ return;
3387
+ }
3388
+ }
3389
+ $("#chapter-batch-apply").disabled = true;
3390
+ try {
3391
+ await api(`/api/works/${encodeURIComponent(state.work.id)}/chapters/batch`, {
3392
+ method: "POST",
3393
+ body: { chapters: chapters.map((chapter) => ({ id: chapter.id, expectedVersionNo: chapter.versionNo })), action }
3394
+ });
3395
+ const workId = state.work.id;
3396
+ state.work = await api(`/api/works/${encodeURIComponent(workId)}`);
3397
+ const currentStillExists = state.chapter && state.work.volumes.some((volume) => volume.chapters.some((chapter) => chapter.id === state.chapter.id));
3398
+ if (state.chapter && currentStillExists) state.chapter = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}`);
3399
+ if (state.chapter && !currentStillExists) {
3400
+ state.chapter = null;
3401
+ showWelcome(true);
3402
+ } else renderTree();
3403
+ if (dialog.open) dialog.close();
3404
+ chapterBatchSelectedIds.clear();
3405
+ toast(`已批量处理 ${chapters.length} 个章节`);
3406
+ } catch (error) {
3407
+ if (!dialog.open) dialog.showModal();
3408
+ $("#chapter-batch-apply").disabled = false;
3409
+ toast(error.message, "error");
3410
+ }
3411
+ }
3412
+
3413
+ function findChapterLocation(chapterId) {
3414
+ for (const [volumeIndex, volume] of (state.work?.volumes ?? []).entries()) {
3415
+ const chapterIndex = volume.chapters.findIndex((chapter) => chapter.id === chapterId);
3416
+ if (chapterIndex >= 0) return { volume, volumeIndex, chapterIndex, chapter: volume.chapters[chapterIndex] };
3417
+ }
3418
+ return null;
3419
+ }
3420
+
3421
+ async function moveChapterInTree(chapterId, volumeId, sortOrder) {
3422
+ const location = findChapterLocation(chapterId);
3423
+ if (!location || chapterMovePending) return;
3424
+ const samePosition = location.volume.id === volumeId && location.chapterIndex === sortOrder;
3425
+ if (samePosition) return;
3426
+ chapterMovePending = true;
3427
+ try {
3428
+ const moved = await api(`/api/chapters/${encodeURIComponent(chapterId)}/move`, {
3429
+ method: "POST",
3430
+ body: { volumeId, sortOrder, expectedVersionNo: location.chapter.versionNo }
3431
+ });
3432
+ state.work = await api(`/api/works/${encodeURIComponent(state.work.id)}`);
3433
+ if (state.chapter?.id === chapterId) state.chapter = { ...state.chapter, ...moved };
3434
+ renderTree();
3435
+ $("#novel-tree").querySelector(`[data-chapter-id="${CSS.escape(chapterId)}"]`)?.focus();
3436
+ toast(location.volume.id === volumeId ? "章节顺序已更新" : "章节已移动到目标分卷");
3437
+ } catch (error) {
3438
+ toast(error.message, "error");
3439
+ } finally {
3440
+ chapterMovePending = false;
3441
+ }
3442
+ }
3443
+
3444
+ async function moveChapterByKeyboard(chapterId, direction, crossVolume) {
3445
+ const location = findChapterLocation(chapterId);
3446
+ if (!location || !state.work) return;
3447
+ if (crossVolume) {
3448
+ const targetVolume = state.work.volumes[location.volumeIndex + direction];
3449
+ if (!targetVolume) return toast("已经是最前或最后一个分卷");
3450
+ await moveChapterInTree(chapterId, targetVolume.id, direction < 0 ? targetVolume.chapters.length : 0);
3451
+ return;
3452
+ }
3453
+ const targetIndex = location.chapterIndex + direction;
3454
+ if (targetIndex < 0 || targetIndex >= location.volume.chapters.length) return toast("已经是本卷首章或末章");
3455
+ await moveChapterInTree(chapterId, location.volume.id, targetIndex);
3456
+ }
3457
+
2953
3458
  function closeChapterTypeMenu() {
2954
3459
  state.contextChapterId = null;
2955
3460
  $("#chapter-type-menu").classList.add("hidden");
@@ -2971,6 +3476,57 @@ function openChapterTypeMenu(chapterId, clientX, clientY) {
2971
3476
  menu.style.top = `${Math.max(8, Math.min(clientY, window.innerHeight - rect.height - 8))}px`;
2972
3477
  }
2973
3478
 
3479
+ async function deleteChapter(chapterId) {
3480
+ if (!state.work || !canEditProse()) return toast("当前权限不能删除正文", "error");
3481
+ const chaptersBeforeDelete = state.work.volumes.flatMap((volume) => volume.chapters);
3482
+ const chapterIndex = chaptersBeforeDelete.findIndex((chapter) => chapter.id === chapterId);
3483
+ const chapter = chaptersBeforeDelete[chapterIndex];
3484
+ if (!chapter) return toast("章节不存在或已被删除", "error");
3485
+ const deletingCurrentChapter = state.chapter?.id === chapterId;
3486
+ if (deletingCurrentChapter) cancelChapterAutoSave();
3487
+ const resumeAutoSave = () => {
3488
+ if (deletingCurrentChapter && state.dirty && !chapterEditorReadOnly) scheduleChapterAutoSave();
3489
+ };
3490
+ const dirtyWarning = deletingCurrentChapter && state.dirty ? ",当前未保存修改不会保留" : "";
3491
+ if (!await confirmToast(`确认删除章节“${chapter.title}”吗?删除后将从正文目录隐藏${dirtyWarning}。`, {
3492
+ title: "删除章节",
3493
+ confirmLabel: "继续删除"
3494
+ })) {
3495
+ resumeAutoSave();
3496
+ return;
3497
+ }
3498
+ if (!await confirmToast(`章节“${chapter.title}”的正文、版本和关联资料会保留,后续可以恢复。仍要删除吗?`, {
3499
+ title: "删除操作需要再次确认",
3500
+ confirmLabel: "确认删除"
3501
+ })) {
3502
+ resumeAutoSave();
3503
+ return;
3504
+ }
3505
+ try {
3506
+ const deletingSelectedChapter = state.chapter?.id === chapterId;
3507
+ const expectedVersionNo = deletingSelectedChapter ? state.chapter.versionNo : chapter.versionNo;
3508
+ await api(`/api/chapters/${chapterId}`, { method: "DELETE", body: { expectedVersionNo } });
3509
+ const workId = state.work.id;
3510
+ state.work = await api(`/api/works/${workId}`);
3511
+ if (deletingSelectedChapter) {
3512
+ state.chapter = null;
3513
+ state.dirty = false;
3514
+ lastSavedChapterSnapshot = null;
3515
+ setSaveState("已删除");
3516
+ const remainingChapters = state.work.volumes.flatMap((volume) => volume.chapters);
3517
+ const nextChapter = remainingChapters[Math.min(Math.max(chapterIndex, 0), remainingChapters.length - 1)];
3518
+ if (nextChapter) await selectChapter(nextChapter.id);
3519
+ else showWelcome(true);
3520
+ } else {
3521
+ renderTree();
3522
+ }
3523
+ toast(`已删除章节“${chapter.title}”,正文和版本记录已保留`);
3524
+ } catch (error) {
3525
+ resumeAutoSave();
3526
+ toast(error.message, "error");
3527
+ }
3528
+ }
3529
+
2974
3530
  async function selectChapter(chapterId, { editMode = false } = {}) {
2975
3531
  if (state.chapter?.id !== chapterId && !(await confirmDiscardChanges("当前章节有未保存修改,仍要切换吗?"))) return;
2976
3532
  cancelChapterAutoSave();
@@ -2993,7 +3549,7 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
2993
3549
  $("#chapter-content").value = normalizedContent;
2994
3550
  clearChapterLineSelection();
2995
3551
  scheduleChapterLineNumbers();
2996
- $("#chapter-insight").classList.add("hidden");
3552
+ dismissChapterInsightToast();
2997
3553
  updateChapterStats();
2998
3554
  if (!canEditProse()) setSaveState("正文只读");
2999
3555
  else if (chapterEditorReadOnly) setSaveState("阅读模式");
@@ -3028,6 +3584,7 @@ function tidyChapterBlankLines() {
3028
3584
  }
3029
3585
 
3030
3586
  function showWelcome(hasWork = false) {
3587
+ dismissChapterInsightToast();
3031
3588
  $("#editor-view").classList.add("hidden");
3032
3589
  $("#module-view").classList.add("hidden");
3033
3590
  $("#welcome-view").classList.remove("hidden");
@@ -3078,6 +3635,7 @@ async function showModule(module) {
3078
3635
  }
3079
3636
  return;
3080
3637
  }
3638
+ dismissChapterInsightToast();
3081
3639
  $("#welcome-view").classList.add("hidden");
3082
3640
  $("#editor-view").classList.add("hidden");
3083
3641
  $("#module-view").classList.remove("hidden");
@@ -3508,19 +4066,22 @@ function renderSettingRows(records) {
3508
4066
  }).join("")}</div>`;
3509
4067
  }
3510
4068
 
3511
- async function renderSettings() {
4069
+ async function renderSettings(page = moduleListPages.settings) {
3512
4070
  const records = await apiAllPages(`/api/works/${state.work.id}/settings`);
3513
4071
  state.settings = records;
3514
4072
  mountModuleCount(records.length);
4073
+ const pageResult = paginateModuleItems(records, page, "settings");
4074
+ moduleListPages.settings = pageResult.page;
3515
4075
  const layout = readModuleLayout();
3516
4076
  if (records.length) mountModuleLayoutToggle(layout, "设定列表样式");
3517
4077
  $("#module-content").innerHTML = records.length
3518
- ? `${layout === "rows" ? renderSettingRows(records) : renderSettingCards(records)}`
4078
+ ? `${layout === "rows" ? renderSettingRows(pageResult.items) : renderSettingCards(pageResult.items)}${renderModulePagination(pageResult, "settings", "设定库")}`
3519
4079
  : emptyModule("还没有世界观设定", "新建规则、地点、组织、科技或创作约束。AI 提取的候选也会进入这里。");
3520
- bindModuleLayoutToggle(renderSettings);
4080
+ bindModuleLayoutToggle(() => renderSettings(pageResult.page));
4081
+ bindModulePagination("settings", renderSettings);
3521
4082
  const openSetting = async (id, readOnly) => openSettingEditor(await api(`/api/settings/${encodeURIComponent(id)}`), { readOnly });
3522
4083
  $("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => { void openSetting(button.dataset.editSetting, false); }));
3523
- bindEntityHistoryButtons(async () => { await renderSettings(); await loadAiReferences(); });
4084
+ bindEntityHistoryButtons(async () => { await renderSettings(pageResult.page); await loadAiReferences(); });
3524
4085
  }
3525
4086
 
3526
4087
  async function renderCharacters(page = characterListPage) {
@@ -3536,14 +4097,15 @@ async function renderCharacters(page = characterListPage) {
3536
4097
  : characterSource;
3537
4098
  if (!characterPage.items.length && page > 1) return renderCharacters(page - 1);
3538
4099
  characterListPage = characterPage.page;
3539
- [state.characters, state.races, state.organizations] = [characterPage.items, races, organizations];
4100
+ const pageCharacters = characterPage.items;
4101
+ [state.races, state.organizations] = [races, organizations];
3540
4102
  mountModuleCount(characterPage.total);
3541
4103
  const layout = readModuleLayout();
3542
4104
  const characterActions = (item) => recordCardEditButton("edit-character", item.id, `角色“${item.name}”`);
3543
4105
  const characterLockBadge = (item) => item.lockedFields.length
3544
4106
  ? `<span class="character-lock-badge" aria-label="${item.lockedFields.length} 个锁定字段" title="锁定字段:${esc(item.lockedFields.join("、"))}"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"></rect><path d="M8 10V7a4 4 0 0 1 8 0v3"></path></svg><span>${item.lockedFields.length}</span></span>`
3545
4107
  : "";
3546
- const characterCards = () => `<div class="card-grid">${state.characters.map((item) => {
4108
+ const characterCards = () => `<div class="card-grid">${pageCharacters.map((item) => {
3547
4109
  const details = normalizeCharacterDetails(item.attributes?.details);
3548
4110
  return `
3549
4111
  <article class="record-card character-card preview-record-card has-card-edit" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}
@@ -3558,7 +4120,7 @@ async function renderCharacters(page = characterListPage) {
3558
4120
  ${item.profileSectionCount ? `<small class="character-section-count">${item.profileSectionCount} 个设定章节</small>` : ""}
3559
4121
  </article>`;
3560
4122
  }).join("")}</div>`;
3561
- const characterRows = () => `<div class="module-row-list">${state.characters.map((item) => {
4123
+ const characterRows = () => `<div class="module-row-list">${pageCharacters.map((item) => {
3562
4124
  const preview = moduleRowPreview(item.profile?.summary || item.attributes?.identity || Object.entries(item.currentState).map(([key, value]) => `${characterStateFieldLabel(key)}:${value}`).join(" ") || "尚未记录当前状态");
3563
4125
  const meta = [
3564
4126
  item.code ? `编号 ${item.code}` : "",
@@ -3574,10 +4136,10 @@ async function renderCharacters(page = characterListPage) {
3574
4136
  <div class="card-actions">${characterActions(item)}</div>
3575
4137
  </article>`;
3576
4138
  }).join("")}</div>`;
3577
- const pagination = state.characters.length && (characterPage.page > 1 || characterPage.hasMore)
4139
+ const pagination = pageCharacters.length && (characterPage.page > 1 || characterPage.hasMore)
3578
4140
  ? `<nav class="module-pagination" aria-label="角色列表分页">
3579
4141
  <button type="button" data-character-page="${characterPage.page - 1}" ${characterPage.page <= 1 ? "disabled" : ""}>上一页</button>
3580
- <span>第 ${characterPage.page}/${Math.ceil(characterPage.total / characterPage.limit)} 页 · 本页 ${state.characters.length} 个角色 · 共 ${characterPage.total} 个角色</span>
4142
+ <span>第 ${characterPage.page}/${Math.ceil(characterPage.total / characterPage.limit)} 页 · 本页 ${pageCharacters.length} 个角色 · 共 ${characterPage.total} 个角色</span>
3581
4143
  <button type="button" data-character-page="${characterPage.nextPage ?? characterPage.page + 1}" ${characterPage.hasMore ? "" : "disabled"}>下一页</button>
3582
4144
  </nav>`
3583
4145
  : "";
@@ -3596,8 +4158,8 @@ async function renderCharacters(page = characterListPage) {
3596
4158
  <div class="character-filter-toolbar-actions">${hasCharacterFilters ? `<span class="character-filter-result-count" aria-live="polite">筛选后剩余 ${characterPage.total} 个角色</span>` : ""}<button id="clear-character-filters" class="ghost-button" type="button" ${hasCharacterFilters ? "" : "disabled"}>重置筛选</button></div>
3597
4159
  </section>`;
3598
4160
  mountCharacterFilterToggle();
3599
- if (state.characters.length) mountModuleLayoutToggle(layout, "角色列表样式");
3600
- $("#module-content").innerHTML = filterToolbar + (state.characters.length
4161
+ if (pageCharacters.length) mountModuleLayoutToggle(layout, "角色列表样式");
4162
+ $("#module-content").innerHTML = filterToolbar + (pageCharacters.length
3601
4163
  ? `${layout === "rows" ? characterRows() : characterCards()}${pagination}`
3602
4164
  : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
3603
4165
  bindModuleLayoutToggle(renderCharacters);
@@ -3626,14 +4188,18 @@ async function renderCharacters(page = characterListPage) {
3626
4188
  $("#module-content").querySelectorAll("[data-character-page]").forEach((control) => { control.disabled = true; });
3627
4189
  await renderCharacters(Number(button.dataset.characterPage));
3628
4190
  }));
3629
- bindRecordPreview("[data-open-character]", (id) => openCharacterEditor(state.characters.find((item) => item.id === id), { readOnly: true }));
3630
- $("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterEditor(state.characters.find((item) => item.id === button.dataset.editCharacter))));
4191
+ bindRecordPreview("[data-open-character]", (id) => openCharacterEditor(pageCharacters.find((item) => item.id === id), { readOnly: true }));
4192
+ $("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterEditor(pageCharacters.find((item) => item.id === button.dataset.editCharacter))));
3631
4193
  }
3632
4194
 
3633
- async function renderRaces() {
4195
+ async function renderRaces(page = moduleListPages.races) {
3634
4196
  state.races = await apiAllPages(`/api/works/${state.work.id}/races`);
3635
4197
  mountModuleCount(state.races.length);
3636
4198
  const layout = readModuleLayout();
4199
+ const pageResult = layout === "rows"
4200
+ ? paginateModuleItems(state.races, page, "races")
4201
+ : paginateRaceForest(state.races, page, pageSizeFor("races"));
4202
+ moduleListPages.races = pageResult.page;
3637
4203
  const canEditRaces = canEditModule("races");
3638
4204
  const raceActions = (item) => canEditRaces
3639
4205
  ? recordCardEditButton("edit-race", item.id, `种族“${item.name}”`)
@@ -3654,7 +4220,7 @@ async function renderRaces() {
3654
4220
  ${item.children.length ? `<div class="race-tree-children">${item.children.map(renderRaceNode).join("")}</div>` : ""}
3655
4221
  </div>
3656
4222
  </details>`;
3657
- const raceRows = () => `<div class="module-row-list">${state.races.map((item) => {
4223
+ const raceRows = () => `<div class="module-row-list">${pageResult.items.map((item) => {
3658
4224
  const preview = moduleRowPreview(item.description || "尚未填写种族简介");
3659
4225
  const meta = `${item.memberIds.length} 位直接角色 · ${(item.settingsCount ?? item.settings?.length ?? 0) ? "已填写共同设定" : "暂无共同设定"}`;
3660
4226
  return `
@@ -3668,22 +4234,25 @@ async function renderRaces() {
3668
4234
  if (state.races.length) mountModuleLayoutToggle(layout, "种族列表样式");
3669
4235
  if (state.races.length && layout !== "rows") mountRaceTreeExpandToggle();
3670
4236
  $("#module-content").innerHTML = state.races.length
3671
- ? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>`}`
4237
+ ? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级">${pageResult.items.map(renderRaceNode).join("")}</section>`}${renderModulePagination(pageResult, "races", "种族列表")}`
3672
4238
  : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
3673
- bindModuleLayoutToggle(renderRaces);
4239
+ bindModuleLayoutToggle(() => renderRaces(pageResult.page));
4240
+ bindModulePagination("races", renderRaces);
3674
4241
  bindRaceTreeExpandToggle();
3675
4242
  bindRaceTreeNodeToggles();
3676
4243
  const openRace = async (id, readOnly) => openRaceDialog(await api(`/api/races/${encodeURIComponent(id)}`), { readOnly });
3677
4244
  $("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => { void openRace(button.dataset.editRace, false); }));
3678
- bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
4245
+ bindEntityHistoryButtons(async () => { await renderRaces(pageResult.page); await loadAiReferences(); });
3679
4246
  }
3680
4247
 
3681
- async function renderOrganizations() {
4248
+ async function renderOrganizations(page = moduleListPages.organizations) {
3682
4249
  [state.organizations, state.characters] = await Promise.all([
3683
4250
  apiAllPages(`/api/works/${state.work.id}/organizations`),
3684
4251
  canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
3685
4252
  ]);
3686
4253
  mountModuleCount(state.organizations.length);
4254
+ const pageResult = paginateModuleItems(state.organizations, page, "organizations");
4255
+ moduleListPages.organizations = pageResult.page;
3687
4256
  const layout = readModuleLayout();
3688
4257
  const canEditOrganizations = canEditModule("organizations");
3689
4258
  const organizationActions = (item) => canEditOrganizations
@@ -3692,14 +4261,14 @@ async function renderOrganizations() {
3692
4261
  const organizationCardActions = (item) => canEditOrganizations
3693
4262
  ? organizationActions(item)
3694
4263
  : `<div class="card-actions">${organizationActions(item)}</div>`;
3695
- const organizationCards = () => `<div class="card-grid organization-grid">${state.organizations.map((item) => `
4264
+ const organizationCards = () => `<div class="card-grid organization-grid">${pageResult.items.map((item) => `
3696
4265
  <article class="record-card organization-card preview-record-card${canEditOrganizations ? " has-card-edit" : ""}" data-open-organization="${esc(item.id)}" role="button" tabindex="0" aria-label="查看组织 ${esc(item.name)}"><small>${item.memberIds.length} 位成员 · ${(item.settingsCount ?? item.settings?.length ?? 0) ? "已填写组织设定" : "暂无组织设定"}</small>
3697
4266
  <h3>${esc(item.name)}</h3><p>${esc(item.description || "尚未填写组织简介")}</p>
3698
4267
  <div class="organization-settings">${item.settingsCount ? `<span class="pill">${item.settingsCount} 条组织设定,打开查看详情</span>` : '<span class="pill">暂无组织设定</span>'}</div>
3699
4268
  <p class="organization-members">成员:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
3700
4269
  ${organizationCardActions(item)}
3701
4270
  </article>`).join("")}</div>`;
3702
- const organizationRows = () => `<div class="module-row-list">${state.organizations.map((item) => {
4271
+ const organizationRows = () => `<div class="module-row-list">${pageResult.items.map((item) => {
3703
4272
  const preview = moduleRowPreview(item.description || "尚未填写组织简介");
3704
4273
  const members = item.members.length ? item.members.map((member) => member.name).join("、") : "暂无绑定角色";
3705
4274
  return `
@@ -3712,12 +4281,13 @@ async function renderOrganizations() {
3712
4281
  }).join("")}</div>`;
3713
4282
  if (state.organizations.length) mountModuleLayoutToggle(layout, "组织列表样式");
3714
4283
  $("#module-content").innerHTML = state.organizations.length
3715
- ? `${layout === "rows" ? organizationRows() : organizationCards()}`
4284
+ ? `${layout === "rows" ? organizationRows() : organizationCards()}${renderModulePagination(pageResult, "organizations", "组织列表")}`
3716
4285
  : emptyModule("还没有组织", "创建国家、机构、阵营或团队,并维护组织设定与成员。");
3717
- bindModuleLayoutToggle(renderOrganizations);
4286
+ bindModuleLayoutToggle(() => renderOrganizations(pageResult.page));
4287
+ bindModulePagination("organizations", renderOrganizations);
3718
4288
  const openOrganization = async (id, readOnly) => openOrganizationDialog(await api(`/api/organizations/${encodeURIComponent(id)}`), { readOnly });
3719
4289
  $("#module-content").querySelectorAll("[data-edit-organization]").forEach((button) => button.addEventListener("click", () => { void openOrganization(button.dataset.editOrganization, false); }));
3720
- bindEntityHistoryButtons(async () => { await renderOrganizations(); await loadAiReferences(); });
4290
+ bindEntityHistoryButtons(async () => { await renderOrganizations(pageResult.page); await loadAiReferences(); });
3721
4291
  }
3722
4292
 
3723
4293
  function updateTimelineMultiSelectControls() {
@@ -3742,12 +4312,14 @@ function setTimelineMultiSelectMode(enabled) {
3742
4312
  updateTimelineMultiSelectControls();
3743
4313
  }
3744
4314
 
3745
- async function renderTimeline() {
4315
+ async function renderTimeline(page = moduleListPages.timeline) {
3746
4316
  const [events, tracks] = await Promise.all([
3747
4317
  apiAllPages(`/api/works/${state.work.id}/timeline`),
3748
4318
  apiAllPages(`/api/works/${state.work.id}/timeline-tracks`)
3749
4319
  ]);
3750
4320
  mountModuleCount(events.length);
4321
+ const pageResult = paginateModuleItems(events, page, "timeline");
4322
+ moduleListPages.timeline = pageResult.page;
3751
4323
  timelineMultiSelectEnabled = false;
3752
4324
  $("#timeline-tools")?.remove();
3753
4325
  $("#module-header-actions").insertAdjacentHTML("beforeend", `<div id="timeline-tools" class="timeline-tools" data-module-header-action="timeline-tools" role="group" aria-label="时间轴操作"><button id="create-timeline-track" class="ghost-button" type="button">新建独立时间轴</button><button id="timeline-multi-select-toggle" class="ghost-button" type="button" aria-pressed="false">多选</button>${events.length > 1 ? '<button id="merge-events" class="ghost-button" type="button" hidden>合并所选事件</button>' : ""}</div>`);
@@ -3755,9 +4327,10 @@ async function renderTimeline() {
3755
4327
  const lanes = [...tracks, { id: "", name: "未分组时间轴", description: "尚未归入独立大事件的时间节点。", sortOrder: Number.MAX_SAFE_INTEGER }];
3756
4328
  const eventCard = (item) => `<article class="timeline-kanban-card"><div class="timeline-card-meta"><input type="checkbox" data-event-select="${esc(item.id)}" aria-label="选择 ${esc(item.name)}" hidden><small>${esc(item.timeLabel)} · ${esc(timelineStatusLabel(item.status))}</small></div><h4>${esc(item.name)}</h4><p>${esc(item.description || "暂无说明")}</p>${item.location ? `<span>地点:${esc(item.location)}</span>` : ""}<div class="card-actions"><button data-edit-event="${esc(item.id)}">编辑与排序</button><button data-split-event="${esc(item.id)}">拆分</button><button data-entity-history="timeline-event" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button></div></article>`;
3757
4329
  $("#module-content").innerHTML = `<div class="timeline-kanban" data-testid="timeline-kanban">${lanes.map((track) => {
3758
- const laneEvents = events.filter((item) => (item.trackId ?? "") === track.id);
4330
+ const laneEvents = pageResult.items.filter((item) => (item.trackId ?? "") === track.id);
3759
4331
  return `<section class="timeline-lane" data-track-id="${esc(track.id)}"><header><div><small>${laneEvents.length} 个节点</small><h3>${esc(track.name)}</h3></div>${track.id ? `<div class="timeline-track-actions"><button class="timeline-track-menu" data-edit-timeline-track="${esc(track.id)}" type="button">编辑</button><button class="timeline-track-menu" data-entity-history="timeline-track" data-entity-id="${esc(track.id)}" data-entity-title="${esc(track.name)}" type="button">历史</button></div>` : ""}</header><p class="timeline-track-description">${esc(track.description || "暂无说明")}</p><div class="timeline-lane-events">${laneEvents.map(eventCard).join("") || '<div class="timeline-lane-empty">还没有时间节点</div>'}</div><button class="timeline-add-event" data-add-event-track="${esc(track.id)}" type="button">添加事件</button></section>`;
3760
- }).join("")}</div>`;
4332
+ }).join("")}</div>${renderModulePagination(pageResult, "timeline", "时间线事件")}`;
4333
+ bindModulePagination("timeline", renderTimeline);
3761
4334
  $("#create-timeline-track").addEventListener("click", () => openTimelineTrackDialog());
3762
4335
  $("#timeline-multi-select-toggle").addEventListener("click", () => setTimelineMultiSelectMode(!timelineMultiSelectEnabled));
3763
4336
  $("#module-content").querySelectorAll("[data-event-select]").forEach((input) => input.addEventListener("change", updateTimelineMultiSelectControls));
@@ -3766,7 +4339,7 @@ async function renderTimeline() {
3766
4339
  $("#module-content").querySelectorAll("[data-add-event-track]").forEach((button) => button.addEventListener("click", () => openTimelineDialog(null, button.dataset.addEventTrack || null)));
3767
4340
  $("#module-content").querySelectorAll("[data-edit-event]").forEach((button) => button.addEventListener("click", () => openTimelineDialog(events.find((item) => item.id === button.dataset.editEvent))));
3768
4341
  $("#module-content").querySelectorAll("[data-split-event]").forEach((button) => button.addEventListener("click", () => openTimelineSplitDialog(events.find((item) => item.id === button.dataset.splitEvent))));
3769
- bindEntityHistoryButtons(renderTimeline);
4342
+ bindEntityHistoryButtons(() => renderTimeline(pageResult.page));
3770
4343
  $("#merge-events")?.addEventListener("click", () => {
3771
4344
  const eventIds = [...$("#module-content").querySelectorAll("[data-event-select]:checked")].map((input) => input.dataset.eventSelect);
3772
4345
  if (eventIds.length < 2) return toast("请至少选择两个时间事件", "error");
@@ -3777,30 +4350,34 @@ async function renderTimeline() {
3777
4350
  description: form.get("description") || undefined,
3778
4351
  expectedVersionNos: Object.fromEntries(eventIds.map((eventId) => [eventId, Number(events.find((event) => event.id === eventId)?.versionNo)]))
3779
4352
  } });
3780
- await renderTimeline();
4353
+ await renderTimeline(pageResult.page);
3781
4354
  }, "保留参与者与证据");
3782
4355
  });
3783
4356
  }
3784
4357
 
3785
- async function renderOutlines() {
4358
+ async function renderOutlines(outlinePage = moduleListPages.outlinePlans, foreshadowPage = moduleListPages.foreshadows) {
3786
4359
  const currentChapterId = state.chapter?.id;
3787
4360
  const [outlines, foreshadows] = await Promise.all([
3788
4361
  apiAllPages(`/api/works/${state.work.id}/outlines`),
3789
4362
  apiAllPages(`/api/works/${state.work.id}/foreshadows?status=all${currentChapterId ? `&currentChapterId=${encodeURIComponent(currentChapterId)}` : ""}`)
3790
4363
  ]);
3791
4364
  mountModuleCount(outlines.length + foreshadows.length);
4365
+ const outlinePageResult = paginateModuleItems(outlines, outlinePage, "outlines");
4366
+ const foreshadowPageResult = paginateModuleItems(foreshadows, foreshadowPage, "outlines");
4367
+ moduleListPages.outlinePlans = outlinePageResult.page;
4368
+ moduleListPages.foreshadows = foreshadowPageResult.page;
3792
4369
  const layout = readModuleLayout();
3793
4370
  const unresolved = foreshadows.filter((item) => item.unresolved);
3794
4371
  const overdue = unresolved.filter((item) => item.overdue);
3795
4372
  const foreshadowActions = (item) => `<button data-edit-foreshadow="${esc(item.id)}">编辑伏笔</button><button data-entity-history="foreshadow" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.title)}">版本历史</button>`;
3796
- const foreshadowCards = () => `<div class="card-grid foreshadow-grid">${foreshadows.map((item) => `
4373
+ const foreshadowCards = () => `<div class="card-grid foreshadow-grid">${foreshadowPageResult.items.map((item) => `
3797
4374
  <article class="record-card foreshadow-card ${item.overdue ? "is-overdue" : ""}">
3798
4375
  <small>${esc(levelLabel(item.importance))} · ${esc(foreshadowStatusLabel(item.status))}${item.overdue ? " · 已逾期" : ""}</small>
3799
4376
  <h3>${esc(item.title)}</h3><p>${esc(item.description || "暂无说明")}</p>
3800
4377
  <div class="foreshadow-links">${item.occurrences.length ? item.occurrences.map((link) => `<span class="pill">${esc(occurrenceRoleLabel(link.role))} · ${esc(link.volumeTitle)} / ${esc(link.chapterTitle)}</span>`).join("") : '<span class="pill">尚未关联章节</span>'}</div>
3801
4378
  <div class="card-actions">${foreshadowActions(item)}</div>
3802
4379
  </article>`).join("")}</div>`;
3803
- const foreshadowRows = () => `<div class="module-row-list">${foreshadows.map((item) => {
4380
+ const foreshadowRows = () => `<div class="module-row-list">${foreshadowPageResult.items.map((item) => {
3804
4381
  const preview = moduleRowPreview(item.description || "暂无说明");
3805
4382
  const links = item.occurrences.length
3806
4383
  ? item.occurrences.map((link) => `${occurrenceRoleLabel(link.role)} · ${link.volumeTitle} / ${link.chapterTitle}`).join(";")
@@ -3814,28 +4391,32 @@ async function renderOutlines() {
3814
4391
  </article>`;
3815
4392
  }).join("")}</div>`;
3816
4393
  const foreshadowHtml = foreshadows.length
3817
- ? `${layout === "rows" ? foreshadowRows() : foreshadowCards()}`
4394
+ ? `${layout === "rows" ? foreshadowRows() : foreshadowCards()}${renderModulePagination(foreshadowPageResult, "foreshadows", "伏笔列表")}`
3818
4395
  : emptyModule("还没有伏笔", "创建伏笔并关联埋设、提醒与回收章节,未回收项会持续显示。\n");
3819
4396
  if (foreshadows.length) mountModuleLayoutToggle(layout, "伏笔列表样式");
3820
- const outlineHtml = outlines.length ? `<div class="outline-list">${outlines.map((item) => `
4397
+ const outlineHtml = outlines.length ? `<div class="outline-list">${outlinePageResult.items.map((item) => `
3821
4398
  <article class="outline-row ${item.status === "completed" ? "is-complete" : ""}">
3822
4399
  <div><small>${esc(item.volumeTitle)} · ${esc(outlineStatusLabel(item.status))}</small><h3>${esc(item.chapterTitle)}</h3></div>
3823
4400
  <div><b>目标</b><p>${esc(item.goal || "未填写")}</p></div>
3824
4401
  <div><b>冲突</b><p>${esc(item.conflict || "未填写")}</p></div>
3825
4402
  <div><b>转折</b><p>${esc(item.turningPoint || "未填写")}</p></div>
3826
4403
  <div class="outline-actions">${item.unresolvedForeshadowCount ? `<span>${item.unresolvedForeshadowCount} 个未回收伏笔</span>` : ""}<button data-edit-outline="${esc(item.chapterId)}">编辑</button><button data-entity-history="chapter-outline" data-entity-id="${esc(item.chapterId)}" data-entity-title="${esc(item.chapterTitle)}">版本历史</button></div>
3827
- </article>`).join("")}</div>` : emptyModule("还没有章节", "先创建章节,再为每章维护目标、冲突和转折。\n");
4404
+ </article>`).join("")}</div>${renderModulePagination(outlinePageResult, "outlinePlans", "章节规划")}` : emptyModule("还没有章节", "先创建章节,再为每章维护目标、冲突和转折。\n");
3828
4405
  $("#module-content").innerHTML = `<div class="outline-summary"><article><strong>${outlines.length}</strong><span>章节规划</span></article><article><strong>${unresolved.length}</strong><span>未回收伏笔</span></article><article class="${overdue.length ? "danger-text" : ""}"><strong>${overdue.length}</strong><span>已逾期</span></article></div><section class="planning-section"><div class="section-title"><div><span class="eyebrow">伏笔追踪</span><h2>尚未回收与历史伏笔</h2></div></div>${foreshadowHtml}</section><section class="planning-section"><div class="section-title"><div><span class="eyebrow">逐章规划</span><h2>章节目标、冲突与转折</h2></div></div>${outlineHtml}</section>`;
3829
- bindModuleLayoutToggle(renderOutlines);
4406
+ bindModuleLayoutToggle(() => renderOutlines(outlinePageResult.page, foreshadowPageResult.page));
4407
+ bindModulePagination("foreshadows", (page) => renderOutlines(outlinePageResult.page, page));
4408
+ bindModulePagination("outlinePlans", (page) => renderOutlines(page, foreshadowPageResult.page));
3830
4409
  $("#module-content").querySelectorAll("[data-edit-outline]").forEach((button) => button.addEventListener("click", () => openOutlineDialog(outlines.find((item) => item.chapterId === button.dataset.editOutline))));
3831
4410
  $("#module-content").querySelectorAll("[data-edit-foreshadow]").forEach((button) => button.addEventListener("click", () => openForeshadowDialog(foreshadows.find((item) => item.id === button.dataset.editForeshadow))));
3832
- bindEntityHistoryButtons(renderOutlines);
4411
+ bindEntityHistoryButtons(() => renderOutlines(outlinePageResult.page, foreshadowPageResult.page));
3833
4412
  }
3834
4413
 
3835
- async function renderRelationships() {
4414
+ async function renderRelationships(page = moduleListPages.relationships) {
3836
4415
  state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
3837
4416
  const relationships = await apiAllPages(`/api/works/${state.work.id}/relationships`);
3838
4417
  const filteredRelationships = filterRelationships(relationships, relationshipFilters);
4418
+ const pageResult = paginateModuleItems(filteredRelationships, page, "relationships");
4419
+ moduleListPages.relationships = pageResult.page;
3839
4420
  const hasRelationshipFilters = relationshipFilters.fromCharacterIds.length > 0 || relationshipFilters.toCharacterIds.length > 0;
3840
4421
  const canEditRelationships = canEditModule("relationships");
3841
4422
  mountModuleCount(filteredRelationships.length);
@@ -3859,10 +4440,11 @@ async function renderRelationships() {
3859
4440
  if ($("#relationship-map-dialog").open) $("#relationship-map-dialog").close();
3860
4441
  const graph = buildRelationshipGraph(state.characters, relationships);
3861
4442
  state.relationshipGraph = graph;
3862
- const relationshipList = filteredRelationships.length ? `<table class="table-list relationship-table"><thead><tr><th>人物</th><th>关系</th><th>关键词</th><th>证据</th><th>置信度</th><th>状态</th><th>操作</th></tr></thead><tbody>${filteredRelationships.map((item) => `
4443
+ const relationshipList = filteredRelationships.length ? `<table class="table-list relationship-table"><thead><tr><th>人物</th><th>关系</th><th>关键词</th><th>证据</th><th>置信度</th><th>状态</th><th>操作</th></tr></thead><tbody>${pageResult.items.map((item) => `
3863
4444
  <tr><td>${esc(nameOf(item.fromCharacterId))} ${item.directed ? "→" : "—"} ${esc(nameOf(item.toCharacterId))}</td>
3864
4445
  <td>${esc(relationshipCategoryLabel(item.category))} / ${esc(item.subtype || "未细分")}</td><td>${(item.keywords ?? []).map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || "—"}</td><td>${item.evidence.length}</td><td>${Math.round(item.confidence * 100)}%</td><td>${esc(relationshipConfirmationLabel(item.confirmationStatus))}</td><td class="relationship-actions">${canEditRelationships ? `<button data-edit-relationship="${esc(item.id)}">编辑</button>` : ""}<button data-entity-history="relationship" data-entity-id="${esc(item.id)}" data-entity-title="${esc(`${nameOf(item.fromCharacterId)} / ${nameOf(item.toCharacterId)}`)}">历史</button></td></tr>`).join("")}</tbody></table>` : relationships.length ? '<div class="relationship-empty-note">没有符合当前筛选条件的关系。</div>' : '<div class="relationship-empty-note">尚无关系边;孤立角色仍显示在力导向图谱中。可人工新建关系,或运行全书人物关系分析。</div>';
3865
- $("#module-content").innerHTML = `${filterToolbar}<div id="relationship-map-host"></div>${relationshipList}`;
4446
+ $("#module-content").innerHTML = `${filterToolbar}<div id="relationship-map-host"></div>${relationshipList}${renderModulePagination(pageResult, "relationships", "人物关系列表")}`;
4447
+ bindModulePagination("relationships", renderRelationships);
3866
4448
  const openGalaxy = () => {
3867
4449
  state.galaxy?.destroy();
3868
4450
  state.galaxy = createGalaxyRenderer($("#relationship-galaxy-dialog"), graph, { workId: state.work.id });
@@ -3882,24 +4464,24 @@ async function renderRelationships() {
3882
4464
  $("#relationship-from-character-filter").addEventListener("change", async () => {
3883
4465
  relationshipFiltersPanelOpen = true;
3884
4466
  relationshipFilters.fromCharacterIds = readSelectedValues("#relationship-from-character-filter");
3885
- await renderRelationships();
4467
+ await renderRelationships(1);
3886
4468
  });
3887
4469
  $("#relationship-to-character-filter").addEventListener("change", async () => {
3888
4470
  relationshipFiltersPanelOpen = true;
3889
4471
  relationshipFilters.toCharacterIds = readSelectedValues("#relationship-to-character-filter");
3890
- await renderRelationships();
4472
+ await renderRelationships(1);
3891
4473
  });
3892
4474
  $("#clear-relationship-filters")?.addEventListener("click", async () => {
3893
4475
  relationshipFiltersPanelOpen = true;
3894
4476
  relationshipFilters.fromCharacterIds = [];
3895
4477
  relationshipFilters.toCharacterIds = [];
3896
- await renderRelationships();
4478
+ await renderRelationships(1);
3897
4479
  });
3898
4480
  $("#module-content").querySelectorAll("[data-edit-relationship]").forEach((button) => button.addEventListener("click", () => openRelationshipDialog(filteredRelationships.find((item) => item.id === button.dataset.editRelationship))));
3899
- bindEntityHistoryButtons(async () => { await renderRelationships(); await loadAiReferences(); });
4481
+ bindEntityHistoryButtons(async () => { await renderRelationships(pageResult.page); await loadAiReferences(); });
3900
4482
  }
3901
4483
 
3902
- async function renderReviews() {
4484
+ async function renderReviews(page = moduleListPages.reviews) {
3903
4485
  const canReadCharacters = canReadModule("characters");
3904
4486
  const canResolveReview = canEditModule("reviews");
3905
4487
  const canMergeCharacters = canResolveReview
@@ -3909,6 +4491,8 @@ async function renderReviews() {
3909
4491
  canReadCharacters ? apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`) : Promise.resolve([])
3910
4492
  ]);
3911
4493
  mountModuleCount(reviews.length);
4494
+ const pageResult = paginateModuleItems(reviews, page, "reviews");
4495
+ moduleListPages.reviews = pageResult.page;
3912
4496
  const characterById = new Map(characters.map((character) => [character.id, character]));
3913
4497
  const duplicateCard = (item) => {
3914
4498
  const refs = (item.entityRefs ?? []).filter((reference) => reference?.type === "character" && characterById.has(reference.id));
@@ -3946,13 +4530,14 @@ async function renderReviews() {
3946
4530
  };
3947
4531
  if (reviews.length) mountModuleLayoutToggle(layout, "审核列表样式");
3948
4532
  $("#module-content").innerHTML = reviews.length
3949
- ? `${layout === "rows" ? `<div class="module-row-list">${reviews.map(reviewRow).join("")}</div>` : `<div class="card-grid">${reviews.map(reviewCard).join("")}</div>`}`
4533
+ ? `${layout === "rows" ? `<div class="module-row-list">${pageResult.items.map(reviewRow).join("")}</div>` : `<div class="card-grid">${pageResult.items.map(reviewCard).join("")}</div>`}${renderModulePagination(pageResult, "reviews", "审核列表")}`
3950
4534
  : emptyModule("没有待审核事项", "候选设定、冲突与低置信度结论会集中显示在这里。");
3951
- bindModuleLayoutToggle(renderReviews);
4535
+ bindModuleLayoutToggle(() => renderReviews(pageResult.page));
4536
+ bindModulePagination("reviews", renderReviews);
3952
4537
  bindRecordPreview("[data-open-review]", (id) => openReviewDetailDialog(reviews.find((item) => item.id === id)));
3953
4538
  $("#module-content").querySelectorAll("[data-review-id]").forEach((button) => button.addEventListener("click", async () => {
3954
4539
  await api(`/api/reviews/${button.dataset.reviewId}`, { method: "PATCH", body: { status: button.dataset.reviewStatus } });
3955
- await renderReviews();
4540
+ await renderReviews(pageResult.page);
3956
4541
  }));
3957
4542
  $("#module-content").querySelectorAll("[data-merge-review]").forEach((button) => button.addEventListener("click", async () => {
3958
4543
  const target = characterById.get(button.dataset.mergeTarget);
@@ -3971,7 +4556,7 @@ async function renderReviews() {
3971
4556
  expectedSourceVersionNo: Number(button.dataset.sourceVersion)
3972
4557
  } });
3973
4558
  toast(`已将“${source.name}”合并到“${target.name}”`);
3974
- await renderReviews();
4559
+ await renderReviews(pageResult.page);
3975
4560
  await loadAiReferences();
3976
4561
  } catch (error) {
3977
4562
  toast(error.message, "error");
@@ -3983,7 +4568,7 @@ async function renderReviews() {
3983
4568
  try {
3984
4569
  await api(`/api/reviews/${button.dataset.keepCharactersSeparate}/character-resolution`, { method: "POST", body: { action: "keep-separate" } });
3985
4570
  toast("已确认这两个档案属于不同角色");
3986
- await renderReviews();
4571
+ await renderReviews(pageResult.page);
3987
4572
  } catch (error) {
3988
4573
  toast(error.message, "error");
3989
4574
  button.disabled = false;
@@ -4097,6 +4682,7 @@ async function renderTasks(page = taskListPage) {
4097
4682
  try {
4098
4683
  const result = await api(`/api/works/${state.work.id}/tasks/auto-run`, { method: "POST", body: {} });
4099
4684
  toast(`已开始下一轮,队列中还有 ${result.pendingCount} 个待执行任务`);
4685
+ await refreshBackgroundTaskCenter({ announce: false });
4100
4686
  await renderTasks();
4101
4687
  } catch (error) {
4102
4688
  toast(error.message, "error");
@@ -4135,6 +4721,7 @@ async function renderTasks(page = taskListPage) {
4135
4721
  scheduleTaskProgressRefresh(workId, 1);
4136
4722
  const completed = await api(`/api/tasks/${button.dataset.runTask}/run`, { method: "POST", body: {} });
4137
4723
  toast(completed.status === "cancelled" ? "分析任务已取消" : completed.status === "expired" ? "正文已变化,本次分析已过期" : "分析已完成");
4724
+ await refreshBackgroundTaskCenter({ announce: false });
4138
4725
  if (state.module === "tasks" && state.work?.id === workId) await renderTasks();
4139
4726
  } catch (error) {
4140
4727
  toast(error.message, "error");
@@ -4146,6 +4733,7 @@ async function renderTasks(page = taskListPage) {
4146
4733
  try {
4147
4734
  await api(`/api/tasks/${button.dataset.cancelTask}/cancel`, { method: "POST", body: {} });
4148
4735
  toast("分析任务已取消");
4736
+ await refreshBackgroundTaskCenter({ announce: false });
4149
4737
  if (state.module === "tasks") await renderTasks();
4150
4738
  } catch (error) {
4151
4739
  toast(error.message, "error");
@@ -4155,6 +4743,23 @@ async function renderTasks(page = taskListPage) {
4155
4743
  scheduleTaskProgressRefresh(state.work.id, runningCount);
4156
4744
  }
4157
4745
 
4746
+ async function rerunAnalysisTask(taskId, button, { closeDetail = false } = {}) {
4747
+ const workId = state.work?.id;
4748
+ button.disabled = true;
4749
+ const originalLabel = button.textContent;
4750
+ button.textContent = "正在创建";
4751
+ try {
4752
+ const rerun = await api(`/api/tasks/${encodeURIComponent(taskId)}/rerun`, { method: "POST", body: {} });
4753
+ if (closeDetail) $("#form-dialog").close();
4754
+ toast(`已按原配置创建新任务 ${rerun.id}`);
4755
+ if (state.module === "tasks" && state.work?.id === workId) await renderTasks();
4756
+ } catch (error) {
4757
+ toast(error.message, "error");
4758
+ button.disabled = false;
4759
+ button.textContent = originalLabel;
4760
+ }
4761
+ }
4762
+
4158
4763
  function stopTaskProgressRefresh() {
4159
4764
  if (taskProgressRefreshTimer === null) return;
4160
4765
  window.clearTimeout(taskProgressRefreshTimer);
@@ -4373,6 +4978,30 @@ function renderTaskResultItem(item) {
4373
4978
  </article>`;
4374
4979
  }
4375
4980
 
4981
+ function renderRelationshipChangePreview(task, result) {
4982
+ const preview = result.relationshipChangePreview && typeof result.relationshipChangePreview === "object"
4983
+ ? result.relationshipChangePreview
4984
+ : null;
4985
+ if (!preview) return "";
4986
+ const totalCount = Number(preview.totalCount ?? 0);
4987
+ const counts = `新增 ${Number(preview.createdCount ?? 0)} 项 · 更新 ${Number(preview.updatedCount ?? 0)} 项 · 删除 ${Number(preview.deletedCount ?? 0)} 项`;
4988
+ if (preview.status === "pending") {
4989
+ return `<section class="relationship-change-preview is-pending" aria-label="人物关系变更待确认">
4990
+ <div><small>写入前预览</small><h4>人物关系库尚未修改</h4><p>${esc(counts)}。请先核对下方关系和原文证据,再决定是否应用。</p></div>
4991
+ ${Number(preview.deletedCount ?? 0) > 0 ? `<p class="relationship-change-preview-warning">确认应用后将删除 ${Number(preview.deletedCount)} 条已有关系;若资料已被他人修改,系统会阻止过期预览覆盖新版本。</p>` : ""}
4992
+ <div class="relationship-change-preview-actions">
4993
+ ${totalCount > 0 && canEditModule("relationships") ? `<button class="primary-button" type="button" data-apply-relationship-changes="${esc(task.id)}">确认并应用 ${totalCount} 项变更</button>` : ""}
4994
+ <button class="ghost-button" type="button" data-discard-relationship-changes="${esc(task.id)}">${totalCount > 0 ? "放弃本次变更" : "结束无变更预览"}</button>
4995
+ ${!canEditModule("relationships") && totalCount > 0 ? "<small>当前账号没有编辑人物关系的权限,只能查看或放弃本次变更。</small>" : ""}
4996
+ </div>
4997
+ </section>`;
4998
+ }
4999
+ const applied = preview.status === "applied";
5000
+ return `<section class="relationship-change-preview ${applied ? "is-applied" : "is-discarded"}" aria-label="人物关系变更状态">
5001
+ <div><small>写入前预览</small><h4>${applied ? "变更已应用" : "本次变更已放弃"}</h4><p>${esc(counts)}。${applied ? "人物关系库已按确认结果更新。" : "人物关系库未因本次预览发生修改。"}</p></div>
5002
+ </section>`;
5003
+ }
5004
+
4376
5005
  function renderTaskResult(task) {
4377
5006
  const result = task.resultSummary && typeof task.resultSummary === "object" ? task.resultSummary : {};
4378
5007
  const metrics = Array.isArray(result.metrics) ? result.metrics : [];
@@ -4385,6 +5014,7 @@ function renderTaskResult(task) {
4385
5014
  <p class="task-result-summary">${esc(result.summary || "任务尚未产生分析结果。")}</p>
4386
5015
  ${result.restricted ? '<p class="task-result-warning">部分结果因当前账号权限受限而隐藏。</p>' : ""}
4387
5016
  </section>
5017
+ ${renderRelationshipChangePreview(task, result)}
4388
5018
  <section class="task-result-section">
4389
5019
  <h4>结果保存位置</h4>
4390
5020
  <p><strong>作品</strong> ${esc(state.work?.title || "当前作品")}</p>
@@ -4426,6 +5056,111 @@ function bindTaskResultActions(container) {
4426
5056
  button.textContent = "重新加载完整 JSON";
4427
5057
  }
4428
5058
  }));
5059
+ container.querySelectorAll("[data-apply-relationship-changes], [data-discard-relationship-changes]").forEach((button) => button.addEventListener("click", async () => {
5060
+ if (button.disabled) return;
5061
+ const taskId = button.dataset.applyRelationshipChanges || button.dataset.discardRelationshipChanges;
5062
+ const action = button.dataset.applyRelationshipChanges ? "apply" : "discard";
5063
+ const controls = button.closest(".relationship-change-preview")?.querySelectorAll("button") ?? [];
5064
+ controls.forEach((control) => { control.disabled = true; });
5065
+ button.textContent = action === "apply" ? "正在应用变更" : "正在放弃变更";
5066
+ try {
5067
+ await api(`/api/tasks/${encodeURIComponent(taskId)}/relationship-changes/${action}`, { method: "POST", body: {} });
5068
+ $("#form-dialog").close();
5069
+ toast(action === "apply" ? "人物关系变更已应用" : "本次人物关系变更已放弃");
5070
+ if (state.module === "tasks") await renderTasks();
5071
+ } catch (error) {
5072
+ toast(error.message, "error");
5073
+ controls.forEach((control) => { control.disabled = false; });
5074
+ button.textContent = action === "apply" ? "重新确认并应用" : "重新放弃本次变更";
5075
+ }
5076
+ }));
5077
+ }
5078
+
5079
+ function relationshipIdentityRepairFailure(task) {
5080
+ if (task?.taskType !== "relationship-analysis" || !Array.isArray(task.failures)) return null;
5081
+ return task.failures.find((failure) =>
5082
+ failure?.code === "RELATIONSHIP_MATCH_CANDIDATES_EXCEEDED"
5083
+ && failure.details
5084
+ && typeof failure.details === "object"
5085
+ && typeof failure.details.characterId === "string"
5086
+ ) ?? null;
5087
+ }
5088
+
5089
+ function relationshipIdentityRepairMetrics(details) {
5090
+ const metrics = [];
5091
+ if (Number.isFinite(Number(details.candidateCount))) {
5092
+ metrics.push(["疑似来源", `${Number(details.candidateCount)} / 上限 ${Number(details.maximum)}`]);
5093
+ }
5094
+ if (Number.isFinite(Number(details.fuzzyReferenceCount))) {
5095
+ metrics.push(["名称与别名", `${Number(details.fuzzyReferenceCount)} / 上限 ${Number(details.maximumFuzzyReferences)}`]);
5096
+ }
5097
+ if (Number.isFinite(Number(details.scannedCharacters))) {
5098
+ metrics.push(["待核对文本", `${Number(details.scannedCharacters).toLocaleString("zh-CN")} / 上限 ${Number(details.maximumScannedCharacters).toLocaleString("zh-CN")} 字符`]);
5099
+ }
5100
+ if (Number.isFinite(Number(details.fuzzyMatchCount))) {
5101
+ metrics.push(["疑似写法", `${Number(details.fuzzyMatchCount)} / 上限 ${Number(details.maximumFuzzyMatches)}`]);
5102
+ }
5103
+ metrics.push(["身份锚点", `${Number(details.identityAnchorCount ?? 0)} 个`]);
5104
+ return metrics;
5105
+ }
5106
+
5107
+ async function openRelationshipIdentityRepairDialog(task, failure) {
5108
+ const details = failure.details && typeof failure.details === "object" ? failure.details : {};
5109
+ const character = await api(`/api/characters/${encodeURIComponent(details.characterId)}`);
5110
+ const anchors = [
5111
+ character.code,
5112
+ character.attributes?.identity,
5113
+ character.race?.name || character.species,
5114
+ ...(Array.isArray(character.organizations) ? character.organizations.map((organization) => organization.name) : [])
5115
+ ].map((value) => String(value ?? "").trim()).filter(Boolean);
5116
+ const metrics = relationshipIdentityRepairMetrics(details);
5117
+ const metricHtml = metrics.map(([label, value]) => `<div><dt>${esc(label)}</dt><dd>${esc(value)}</dd></div>`).join("");
5118
+ const anchorHtml = anchors.length
5119
+ ? anchors.map((anchor) => `<span>${esc(anchor)}</span>`).join("")
5120
+ : "<em>尚未登记可用于消歧的身份锚点</em>";
5121
+ openDialog("修复人物身份资料",
5122
+ `<section class="relationship-identity-repair-summary">
5123
+ <div><span>匹配诊断</span><h3>${esc(character.name)}</h3><p>${esc(failure.message)}</p></div>
5124
+ <dl>${metricHtml}</dl>
5125
+ <div class="relationship-identity-anchor-list"><strong>当前身份锚点</strong><div>${anchorHtml}</div></div>
5126
+ </section>
5127
+ <p class="relationship-identity-repair-guidance">把原文中确实指向该人物的稳定称呼登记为别名;编号和身份定位应填写会在正文或设定中共同出现的辨识词。保存后,别名命中会转为精确匹配,身份锚点可收窄过多的拼音疑似来源。</p>
5128
+ <label>角色标准名<input type="text" value="${esc(character.name)}" readonly></label>
5129
+ ${field("aliases", "确认别名", "item-list", character.aliases ?? [])}
5130
+ ${field("code", "人物编号或代号", "text", character.code)}
5131
+ ${field("identity", "身份与定位", "text", character.attributes?.identity)}
5132
+ <button class="ghost-button relationship-identity-full-profile" type="button" data-open-identity-character-profile>打开完整人物档案</button>`,
5133
+ async (form) => {
5134
+ const aliases = form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean);
5135
+ const code = String(form.get("code") ?? "").trim();
5136
+ const identity = String(form.get("identity") ?? "").trim();
5137
+ const saved = await api(`/api/characters/${encodeURIComponent(character.id)}`, {
5138
+ method: "PATCH",
5139
+ body: {
5140
+ aliases,
5141
+ code,
5142
+ attributes: { ...(character.attributes ?? {}), identity },
5143
+ expectedVersionNo: character.versionNo,
5144
+ changeNote: `修复人物关系来源匹配:${failure.message}`.slice(0, 500)
5145
+ }
5146
+ });
5147
+ await loadAiReferences();
5148
+ if (state.module === "tasks") await renderTasks(taskListPage);
5149
+ toast(`“${saved.name}”的身份资料已保存为 v${saved.versionNo},可重新运行原分析配置`);
5150
+ },
5151
+ "人物关系匹配修复",
5152
+ {
5153
+ submitLabel: "保存身份修复",
5154
+ pendingLabel: "保存中…",
5155
+ pendingMessage: "正在保存人物身份资料",
5156
+ errorPrefix: "身份修复失败:",
5157
+ wide: true
5158
+ });
5159
+ $("#dialog-fields").querySelector("[data-open-identity-character-profile]")?.addEventListener("click", async () => {
5160
+ $("#form-dialog").close();
5161
+ await openCharacterEditor(character);
5162
+ activateCharacterEditorTab("profile");
5163
+ });
4429
5164
  }
4430
5165
 
4431
5166
  function openTaskDetailDialog(task, trace) {
@@ -4454,6 +5189,15 @@ function openTaskDetailDialog(task, trace) {
4454
5189
  const failureHtml = failures.length
4455
5190
  ? `<ul>${failures.map((item) => `<li>${esc(item.message || JSON.stringify(item))}</li>`).join("")}</ul>`
4456
5191
  : "<p>无</p>";
5192
+ const identityRepairFailure = relationshipIdentityRepairFailure(task);
5193
+ const identityRepairHtml = identityRepairFailure
5194
+ ? `<section class="relationship-identity-repair-entry">
5195
+ <div><strong>可修复的人物身份匹配问题</strong><p>系统已定位到触发来源超限的角色。补充准确别名或身份锚点后,再按原配置重试。</p></div>
5196
+ ${canEditModule("characters")
5197
+ ? '<button class="primary-button" type="button" data-task-identity-repair>修复人物身份资料</button>'
5198
+ : "<small>当前账户没有人物模块编辑权限。</small>"}
5199
+ </section>`
5200
+ : "";
4457
5201
  const resultPreview = renderTaskResult(task);
4458
5202
  openDialog("任务详情",
4459
5203
  `<div class="task-detail">
@@ -4464,8 +5208,9 @@ function openTaskDetailDialog(task, trace) {
4464
5208
  <p><strong>状态</strong> ${esc(analysisTaskStatusLabel(task.status))} · 进度 ${Number(task.progress ?? 0)}%</p>
4465
5209
  <p><strong>范围摘要</strong> ${esc(task.scopeSummary || "未指定")}</p>
4466
5210
  <div><strong>范围详情</strong><ul>${detailHtml}</ul></div>
4467
- <div><strong>失败信息</strong>${failureHtml}</div>
5211
+ <div><strong>失败信息</strong>${failureHtml}${identityRepairHtml}</div>
4468
5212
  <div><strong>结果摘要</strong>${resultPreview}</div>
5213
+ ${canRerunAnalysisTask(task) ? `<div class="task-detail-actions"><button class="primary-button" type="button" data-rerun-task-detail="${esc(task.id)}">按原配置重新执行</button><small>新任务会重新读取当前正文、设定和人物资料,旧任务记录保持不变。</small></div>` : ""}
4469
5214
  </section>
4470
5215
  ${renderTaskTraceVisualization(trace, task.id)}
4471
5216
  </div>`,
@@ -4474,6 +5219,19 @@ function openTaskDetailDialog(task, trace) {
4474
5219
  { submitLabel: "关闭", wide: true, trace: true });
4475
5220
  bindTaskTraceCallActions($("#dialog-fields"));
4476
5221
  bindTaskResultActions($("#dialog-fields"));
5222
+ $("#dialog-fields").querySelector("[data-rerun-task-detail]")?.addEventListener("click", async (event) => {
5223
+ await rerunAnalysisTask(event.currentTarget.dataset.rerunTaskDetail, event.currentTarget, { closeDetail: true });
5224
+ });
5225
+ $("#dialog-fields").querySelector("[data-task-identity-repair]")?.addEventListener("click", async (event) => {
5226
+ const button = event.currentTarget;
5227
+ button.disabled = true;
5228
+ $("#form-dialog").close();
5229
+ try {
5230
+ await openRelationshipIdentityRepairDialog(task, identityRepairFailure);
5231
+ } catch (error) {
5232
+ toast(`身份修复向导加载失败:${error.message}`, "error");
5233
+ }
5234
+ });
4477
5235
  }
4478
5236
 
4479
5237
  function renderProviderCards(providers, models) {
@@ -4518,6 +5276,266 @@ function renderTaskDefaults(models, providers, taskDefaults) {
4518
5276
  </section>` : emptyModule("尚未配置平台模型", "请先在平台 AI 管理中添加并测试供应商模型。");
4519
5277
  }
4520
5278
 
5279
+ const relationshipIndexStatusLabels = Object.freeze({
5280
+ queued: "等待同步",
5281
+ building: "正在同步",
5282
+ ready: "已索引",
5283
+ failed: "同步失败"
5284
+ });
5285
+
5286
+ const relationshipIndexSourceLabels = Object.freeze({
5287
+ work: "作品资料",
5288
+ chapter: "正文",
5289
+ setting: "设定",
5290
+ character: "人物",
5291
+ race: "种族",
5292
+ organization: "组织",
5293
+ "timeline-track": "时间轴",
5294
+ "timeline-event": "时间线事件",
5295
+ relationship: "人物关系",
5296
+ "chapter-outline": "章节大纲",
5297
+ foreshadow: "伏笔",
5298
+ review: "审核项"
5299
+ });
5300
+
5301
+ function relationshipIndexStatusMarkup(status) {
5302
+ const queuedSources = Array.isArray(status.queuedSources) ? status.queuedSources : [];
5303
+ const statusName = relationshipIndexStatusLabels[status.status] ?? "状态未知";
5304
+ const queueMarkup = queuedSources.length
5305
+ ? queuedSources.map((item) => `<span class="relationship-index-queue-chip"><span>${esc(relationshipIndexSourceLabels[item.sourceType] ?? item.sourceType)}</span><strong>${esc(String(item.count))}</strong></span>`).join("")
5306
+ : '<span class="relationship-index-queue-empty">队列为空</span>';
5307
+ const updatedAt = formatDateTime(status.updatedAt) || "尚未构建";
5308
+ const errorMarkup = status.error
5309
+ ? `<p class="relationship-index-error">${esc(status.error)}</p>`
5310
+ : "";
5311
+ return `<div class="relationship-index-summary">
5312
+ <div class="relationship-index-state-row"><span class="relationship-index-state is-${esc(String(status.status ?? "unknown"))}">${esc(statusName)}</span><span>索引代次 <strong>${esc(String(status.generation ?? 0))}</strong></span><span>最后更新 <strong>${esc(updatedAt)}</strong></span></div>
5313
+ <dl class="relationship-index-metrics"><div><dt>待同步任务</dt><dd>${esc(String(status.queuedSourceCount ?? 0))}</dd></div><div><dt>已索引正文段落</dt><dd>${esc(String(status.indexedParagraphCount ?? 0))}</dd></div><div><dt>已索引设定来源</dt><dd>${esc(String(status.indexedSourceCount ?? 0))}</dd></div></dl>
5314
+ <div class="relationship-index-queue"><div class="relationship-index-queue-heading"><strong>增量任务队列</strong><span>仅包含新增、修改或删除后待同步的来源</span></div><div class="relationship-index-queue-list">${queueMarkup}</div></div>
5315
+ ${errorMarkup}
5316
+ </div>`;
5317
+ }
5318
+
5319
+ function updateBackgroundTaskCenterVisibility() {
5320
+ const button = $("#background-task-button");
5321
+ if (!button) return;
5322
+ const visible = Boolean(state.work)
5323
+ && (canReadModule("tasks") || canReadModule("ai-settings"));
5324
+ button.classList.toggle("hidden", !visible);
5325
+ if (!visible) {
5326
+ $("#background-task-count")?.classList.add("hidden");
5327
+ return;
5328
+ }
5329
+ const activityCount = backgroundTaskActivityCount(
5330
+ backgroundTaskCenterSnapshot.taskPage,
5331
+ backgroundTaskCenterSnapshot.relationshipIndex
5332
+ );
5333
+ const badge = $("#background-task-count");
5334
+ badge.textContent = activityCount > 99 ? "99+" : String(activityCount);
5335
+ badge.classList.toggle("hidden", activityCount === 0);
5336
+ button.classList.toggle("is-active", activityCount > 0);
5337
+ button.setAttribute("aria-label", activityCount > 0 ? `后台任务中心,${activityCount} 项进行中` : "后台任务中心");
5338
+ button.setAttribute("title", activityCount > 0 ? `后台任务中心 · ${activityCount} 项进行中` : "后台任务中心");
5339
+ }
5340
+
5341
+ function backgroundTaskTransitionMessage(transition) {
5342
+ const label = analysisTaskTypeLabel(transition.task.taskType);
5343
+ if (transition.status === "partial") return { message: `${label}部分失败,请打开任务详情查看`, type: "error" };
5344
+ if (transition.status === "expired") return { message: `${label}已过期,正文可能已发生变化`, type: "error" };
5345
+ if (transition.status === "cancelled") return { message: `${label}已取消`, type: "info" };
5346
+ return { message: `${label}已完成`, type: "info" };
5347
+ }
5348
+
5349
+ function backgroundTaskListMarkup(taskPage, error) {
5350
+ if (!canReadModule("tasks")) {
5351
+ return '<section class="background-task-section"><div class="background-task-section-heading"><div><strong>AI 分析任务</strong><small>当前账户无权查看此模块</small></div></div></section>';
5352
+ }
5353
+ if (!taskPage) {
5354
+ return `<section class="background-task-section"><div class="background-task-section-heading"><div><strong>AI 分析任务</strong><small>${esc(error || "正在读取任务队列")}</small></div></div></section>`;
5355
+ }
5356
+ const tasks = Array.isArray(taskPage.items) ? taskPage.items.slice(0, 10) : [];
5357
+ const pendingCount = Number(taskPage.stats?.pendingCount ?? 0);
5358
+ const runningCount = Number(taskPage.stats?.runningCount ?? 0);
5359
+ return `<section class="background-task-section">
5360
+ <div class="background-task-section-heading"><div><strong>AI 分析任务</strong><small>待执行 ${pendingCount} 个 · 运行中 ${runningCount} 个 · 共 ${Number(taskPage.stats?.total ?? taskPage.total ?? tasks.length)} 个</small></div></div>
5361
+ ${error ? `<p class="background-task-error">${esc(error)}</p>` : ""}
5362
+ ${tasks.length ? `<div class="background-task-list">${tasks.map((item) => {
5363
+ const status = normalizedAnalysisTaskStatus(item.status);
5364
+ return `<article class="background-task-row">
5365
+ <span class="task-status-badge is-${esc(status)}"><span class="task-status-indicator" aria-hidden="true"></span><span>${esc(analysisTaskStatusLabel(item.status))}</span></span>
5366
+ <div><strong>${esc(analysisTaskTypeLabel(item.taskType))}</strong><small>${esc(item.scopeSummary || "未指定范围")} · ${esc(item.model?.displayName || "默认模型")}</small></div>
5367
+ <span class="background-task-progress">${analysisTaskProgressValue(item.progress)}%</span>
5368
+ <button class="ghost-button" type="button" data-background-task-detail="${esc(item.id)}">详情</button>
5369
+ </article>`;
5370
+ }).join("")}</div>` : '<p class="background-task-empty">还没有 AI 分析任务。</p>'}
5371
+ </section>`;
5372
+ }
5373
+
5374
+ function backgroundIndexMarkup(relationshipIndex, error) {
5375
+ if (!canReadModule("ai-settings")) {
5376
+ return '<section class="background-task-section"><div class="background-task-section-heading"><div><strong>人物关系拼音索引</strong><small>当前账户无权查看此模块</small></div></div></section>';
5377
+ }
5378
+ if (!relationshipIndex) {
5379
+ return `<section class="background-task-section"><div class="background-task-section-heading"><div><strong>人物关系拼音索引</strong><small>${esc(error || "正在读取索引队列")}</small></div></div></section>`;
5380
+ }
5381
+ const editable = canEditModule("ai-settings");
5382
+ return `<section class="background-task-section">
5383
+ <div class="background-task-section-heading">
5384
+ <div><strong>人物关系拼音索引</strong><small>可在任何模块查看增量同步状态</small></div>
5385
+ <div class="background-index-actions ${editable ? "" : "hidden"}">
5386
+ <button class="primary-button" type="button" data-background-index-action="sync">同步增量队列</button>
5387
+ <button class="ghost-button" type="button" data-background-index-action="rebuild">完整重建</button>
5388
+ </div>
5389
+ </div>
5390
+ ${error ? `<p class="background-task-error">${esc(error)}</p>` : ""}
5391
+ ${relationshipIndexStatusMarkup(relationshipIndex)}
5392
+ </section>`;
5393
+ }
5394
+
5395
+ function renderBackgroundTaskCenter() {
5396
+ updateBackgroundTaskCenterVisibility();
5397
+ const content = $("#background-task-content");
5398
+ if (!content) return;
5399
+ content.innerHTML = `${backgroundTaskListMarkup(
5400
+ backgroundTaskCenterSnapshot.taskPage,
5401
+ backgroundTaskCenterSnapshot.errors.tasks
5402
+ )}${backgroundIndexMarkup(
5403
+ backgroundTaskCenterSnapshot.relationshipIndex,
5404
+ backgroundTaskCenterSnapshot.errors.index
5405
+ )}`;
5406
+ $("#background-task-dialog-meta").textContent = state.work
5407
+ ? `《${state.work.title}》的分析任务与索引队列`
5408
+ : "当前作品的分析任务与索引队列";
5409
+ $("#background-task-open-analysis").classList.toggle("hidden", !canReadModule("tasks"));
5410
+ content.querySelectorAll("[data-background-task-detail]").forEach((button) => button.addEventListener("click", async () => {
5411
+ button.disabled = true;
5412
+ try {
5413
+ const taskId = encodeURIComponent(button.dataset.backgroundTaskDetail);
5414
+ const [task, trace] = await Promise.all([
5415
+ api(`/api/tasks/${taskId}/detail`),
5416
+ api(`/api/tasks/${taskId}/trace`).catch((error) => {
5417
+ if (error.code === "WORK_MODULE_READ_DENIED") return { restricted: true, captured: false, calls: [] };
5418
+ throw error;
5419
+ })
5420
+ ]);
5421
+ $("#background-task-dialog").close();
5422
+ openTaskDetailDialog(task, trace);
5423
+ } catch (error) {
5424
+ toast(error.message, "error");
5425
+ button.disabled = false;
5426
+ }
5427
+ }));
5428
+ content.querySelectorAll("[data-background-index-action]").forEach((button) => button.addEventListener("click", async () => {
5429
+ if (!backgroundTaskCenterWorkId) return;
5430
+ button.disabled = true;
5431
+ const action = button.dataset.backgroundIndexAction;
5432
+ try {
5433
+ const status = await api(`/api/works/${encodeURIComponent(backgroundTaskCenterWorkId)}/ai-settings/relationship-search-index/${action}`, {
5434
+ method: "POST"
5435
+ });
5436
+ backgroundTaskCenterSnapshot.relationshipIndex = status;
5437
+ renderBackgroundTaskCenter();
5438
+ toast(action === "rebuild"
5439
+ ? `已将全部来源加入索引队列,共 ${status.queuedSourceCount} 项`
5440
+ : Number(status.queuedSourceCount) > 0
5441
+ ? `开始同步 ${status.queuedSourceCount} 项增量任务`
5442
+ : "增量任务队列为空,索引已是最新状态");
5443
+ scheduleBackgroundTaskCenterRefresh();
5444
+ } catch (error) {
5445
+ toast(error.message, "error");
5446
+ button.disabled = false;
5447
+ }
5448
+ }));
5449
+ }
5450
+
5451
+ function scheduleBackgroundTaskCenterRefresh() {
5452
+ if (backgroundTaskCenterTimer !== null) window.clearTimeout(backgroundTaskCenterTimer);
5453
+ backgroundTaskCenterTimer = null;
5454
+ if (!backgroundTaskCenterWorkId) return;
5455
+ const activityCount = backgroundTaskActivityCount(
5456
+ backgroundTaskCenterSnapshot.taskPage,
5457
+ backgroundTaskCenterSnapshot.relationshipIndex
5458
+ );
5459
+ const delay = backgroundTaskPollDelay(activityCount, Boolean($("#background-task-dialog")?.open));
5460
+ backgroundTaskCenterTimer = window.setTimeout(() => {
5461
+ backgroundTaskCenterTimer = null;
5462
+ void refreshBackgroundTaskCenter();
5463
+ }, delay);
5464
+ }
5465
+
5466
+ async function refreshBackgroundTaskCenter({ announce = true } = {}) {
5467
+ const workId = backgroundTaskCenterWorkId;
5468
+ if (!workId || state.work?.id !== workId) return;
5469
+ if (backgroundTaskCenterTimer !== null) window.clearTimeout(backgroundTaskCenterTimer);
5470
+ backgroundTaskCenterTimer = null;
5471
+ const requestId = ++backgroundTaskCenterRequest;
5472
+ const readTasks = canReadModule("tasks");
5473
+ const readIndex = canReadModule("ai-settings");
5474
+ const [taskResult, indexResult] = await Promise.all([
5475
+ readTasks
5476
+ ? api(`/api/works/${encodeURIComponent(workId)}/tasks?page=1&limit=30`)
5477
+ .then((value) => ({ value }))
5478
+ .catch((error) => ({ error }))
5479
+ : Promise.resolve(null),
5480
+ readIndex
5481
+ ? api(`/api/works/${encodeURIComponent(workId)}/ai-settings/relationship-search-index`)
5482
+ .then((value) => ({ value }))
5483
+ .catch((error) => ({ error }))
5484
+ : Promise.resolve(null)
5485
+ ]);
5486
+ if (requestId !== backgroundTaskCenterRequest || backgroundTaskCenterWorkId !== workId || state.work?.id !== workId) return;
5487
+ const errors = {};
5488
+ if (taskResult?.value) {
5489
+ const transitionResult = collectBackgroundTaskTransitions(
5490
+ backgroundTaskCenterTaskSnapshots,
5491
+ taskResult.value.items,
5492
+ backgroundTaskCenterTasksInitialized
5493
+ );
5494
+ backgroundTaskCenterTaskSnapshots = transitionResult.snapshots;
5495
+ backgroundTaskCenterSnapshot.taskPage = taskResult.value;
5496
+ if (backgroundTaskCenterTasksInitialized && announce && state.module !== "tasks") {
5497
+ for (const transition of transitionResult.transitions) {
5498
+ const notification = backgroundTaskTransitionMessage(transition);
5499
+ toast(notification.message, notification.type);
5500
+ }
5501
+ }
5502
+ backgroundTaskCenterTasksInitialized = true;
5503
+ } else if (taskResult?.error) {
5504
+ errors.tasks = taskResult.error.message;
5505
+ console.error("Failed to refresh background analysis tasks", taskResult.error);
5506
+ }
5507
+ if (indexResult?.value) {
5508
+ backgroundTaskCenterSnapshot.relationshipIndex = indexResult.value;
5509
+ } else if (indexResult?.error) {
5510
+ errors.index = indexResult.error.message;
5511
+ console.error("Failed to refresh relationship search index status", indexResult.error);
5512
+ }
5513
+ backgroundTaskCenterSnapshot.errors = errors;
5514
+ renderBackgroundTaskCenter();
5515
+ scheduleBackgroundTaskCenterRefresh();
5516
+ }
5517
+
5518
+ function stopBackgroundTaskCenter() {
5519
+ if (backgroundTaskCenterTimer !== null) window.clearTimeout(backgroundTaskCenterTimer);
5520
+ backgroundTaskCenterTimer = null;
5521
+ backgroundTaskCenterRequest += 1;
5522
+ backgroundTaskCenterWorkId = null;
5523
+ backgroundTaskCenterTasksInitialized = false;
5524
+ backgroundTaskCenterTaskSnapshots = new Map();
5525
+ backgroundTaskCenterSnapshot = { taskPage: null, relationshipIndex: null, errors: {} };
5526
+ const dialog = $("#background-task-dialog");
5527
+ if (dialog?.open) dialog.close();
5528
+ $("#background-task-button")?.classList.add("hidden");
5529
+ $("#background-task-count")?.classList.add("hidden");
5530
+ }
5531
+
5532
+ function startBackgroundTaskCenter(workId) {
5533
+ stopBackgroundTaskCenter();
5534
+ backgroundTaskCenterWorkId = String(workId);
5535
+ renderBackgroundTaskCenter();
5536
+ void refreshBackgroundTaskCenter();
5537
+ }
5538
+
4521
5539
  async function renderPlatformAiConfig() {
4522
5540
  const [providers, models, settings] = await Promise.all([
4523
5541
  api("/api/platform/ai/providers"),
@@ -4541,16 +5559,116 @@ async function renderPlatformAiConfig() {
4541
5559
  bindPlatformProviderActions(host, providers, models);
4542
5560
  }
4543
5561
 
5562
+ function tokenUsageDateLabel(date) {
5563
+ return new Intl.DateTimeFormat("zh-CN", {
5564
+ year: "numeric",
5565
+ month: "short",
5566
+ day: "numeric",
5567
+ weekday: "short"
5568
+ }).format(new Date(`${date}T00:00:00`));
5569
+ }
5570
+
5571
+ function tokenUsageCalendarMarkup(daily) {
5572
+ const calendar = buildUsageCalendar(daily);
5573
+ const cells = calendar.cells.map((cell) => {
5574
+ const label = `${tokenUsageDateLabel(cell.date)}:${Number(cell.totalTokens).toLocaleString("zh-CN")} Token`;
5575
+ return `<span class="usage-calendar-cell${cell.future ? " is-future" : ""}" data-level="${cell.level}" role="gridcell" aria-label="${esc(label)}" title="${esc(label)}" ${cell.future ? 'aria-disabled="true"' : 'tabindex="0"'}></span>`;
5576
+ }).join("");
5577
+ const months = calendar.months.map((month) => `<span style="grid-column:${month.week + 1}">${esc(month.label)}</span>`).join("");
5578
+ return `<div class="usage-calendar-scroll" tabindex="0" aria-label="每日 Token 用量日历,可横向滚动">
5579
+ <div class="usage-calendar-frame" style="--usage-week-count:${calendar.weekCount}">
5580
+ <div class="usage-calendar-months" aria-hidden="true">${months}</div>
5581
+ <div class="usage-calendar-body">
5582
+ <div class="usage-calendar-weekdays" aria-hidden="true"><span>一</span><span>三</span><span>五</span></div>
5583
+ <div class="usage-calendar-grid" role="grid" aria-label="过去 53 周每日 Token 用量">${cells}</div>
5584
+ </div>
5585
+ </div>
5586
+ </div>
5587
+ <div class="usage-calendar-legend"><span>少</span>${[0, 1, 2, 3, 4].map((level) => `<i data-level="${level}" aria-hidden="true"></i>`).join("")}<span>多</span></div>`;
5588
+ }
5589
+
5590
+ function scrollUsageCalendarsToLatest(root) {
5591
+ window.requestAnimationFrame(() => {
5592
+ root.querySelectorAll(".usage-calendar-scroll").forEach((calendar) => {
5593
+ calendar.scrollLeft = calendar.scrollWidth;
5594
+ });
5595
+ });
5596
+ }
5597
+
5598
+ function tokenUsageOverviewMarkup(usage, { title, description, showWorks = false } = {}) {
5599
+ const summary = usage?.summary ?? {};
5600
+ const totalTokens = Number(summary.totalTokens) || 0;
5601
+ const exactTotal = totalTokens.toLocaleString("zh-CN");
5602
+ const estimatedRequests = Number(summary.estimatedRequestCount) || 0;
5603
+ const requestCount = Number(summary.requestCount) || 0;
5604
+ const cachedInputTokens = Number(summary.cachedInputTokens) || 0;
5605
+ const cacheEligibleInputTokens = Number(summary.cacheEligibleInputTokens) || 0;
5606
+ const cacheDescription = summary.cacheHitRate === null || summary.cacheHitRate === undefined
5607
+ ? "供应商尚未返回可计算的缓存明细"
5608
+ : `${cachedInputTokens.toLocaleString("zh-CN")} / ${cacheEligibleInputTokens.toLocaleString("zh-CN")} 个可统计输入 Token 命中缓存`;
5609
+ const estimateNote = estimatedRequests > 0
5610
+ ? `其中 ${estimatedRequests.toLocaleString("zh-CN")} 次调用包含历史或供应商缺失用量时的估算。`
5611
+ : "全部用量均来自供应商返回的 Token 统计。";
5612
+ const works = Array.isArray(usage?.works) ? usage.works : [];
5613
+ const workRows = works.map((work) => `<tr>
5614
+ <th scope="row">${esc(work.workTitle)}</th>
5615
+ <td title="${esc(Number(work.totalTokens || 0).toLocaleString("zh-CN"))} Token">${esc(formatTokenCount(work.totalTokens))}</td>
5616
+ <td>${esc(formatTokenCount(work.inputTokens))}</td>
5617
+ <td>${esc(formatTokenCount(work.outputTokens))}</td>
5618
+ <td>${esc(formatCacheHitRate(work.cacheHitRate))}</td>
5619
+ <td>${Number(work.requestCount || 0).toLocaleString("zh-CN")}</td>
5620
+ </tr>`).join("");
5621
+ return `<section class="usage-overview" aria-labelledby="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">
5622
+ <div class="config-section-header"><div><h2 id="${showWorks ? "platform-usage-overview-title" : "work-usage-overview-title"}">${esc(title || "Token 用量")}</h2><p>${esc(description || "统计该范围内的全部 AI 调用。")}</p></div></div>
5623
+ <div class="usage-stat-grid">
5624
+ <article class="usage-stat is-primary"><span>总消耗</span><strong title="${esc(exactTotal)} Token">${esc(formatTokenCount(totalTokens))}</strong><small>${esc(exactTotal)} Token</small></article>
5625
+ <article class="usage-stat"><span>输入 Token</span><strong>${esc(formatTokenCount(summary.inputTokens))}</strong><small>${Number(summary.inputTokens || 0).toLocaleString("zh-CN")}</small></article>
5626
+ <article class="usage-stat"><span>输出 Token</span><strong>${esc(formatTokenCount(summary.outputTokens))}</strong><small>${Number(summary.outputTokens || 0).toLocaleString("zh-CN")}</small></article>
5627
+ <article class="usage-stat"><span>缓存命中率</span><strong>${esc(formatCacheHitRate(summary.cacheHitRate))}</strong><small>${esc(cacheDescription)}</small></article>
5628
+ </div>
5629
+ <p class="usage-measurement-note">${requestCount.toLocaleString("zh-CN")} 次有用量记录的调用。${esc(estimateNote)}</p>
5630
+ <section class="usage-calendar-section" aria-labelledby="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">
5631
+ <header><div><h3 id="${showWorks ? "platform-usage-calendar-title" : "work-usage-calendar-title"}">每日用量</h3><p>GitHub 风格网格展示过去 53 周;颜色越深,当天消耗越高。</p></div></header>
5632
+ ${tokenUsageCalendarMarkup(usage?.daily)}
5633
+ </section>
5634
+ ${showWorks ? `<section class="usage-work-section" aria-labelledby="usage-work-title"><header><div><h3 id="usage-work-title">各作品用量</h3><p>按 Token 总消耗从高到低排列,包含尚未使用 AI 的作品。</p></div></header><div class="usage-work-table-scroll"><table class="usage-work-table"><thead><tr><th>作品</th><th>总消耗</th><th>输入</th><th>输出</th><th>缓存命中率</th><th>调用</th></tr></thead><tbody>${workRows || '<tr><td colspan="6">还没有作品用量记录。</td></tr>'}</tbody></table></div></section>` : ""}
5635
+ </section>`;
5636
+ }
5637
+
5638
+ async function renderPlatformTokenUsage() {
5639
+ const host = $("#platform-usage-content");
5640
+ host.innerHTML = '<div class="empty-state">正在汇总 Token 用量……</div>';
5641
+ const timezoneOffset = -new Date().getTimezoneOffset();
5642
+ const usage = await api(`/api/platform/ai/usage?timezoneOffset=${timezoneOffset}`);
5643
+ host.innerHTML = tokenUsageOverviewMarkup(usage, {
5644
+ title: "项目累计用量",
5645
+ description: "汇总所有作品迄今产生的输入与输出 Token;缓存命中率仅基于供应商返回了缓存明细的调用。",
5646
+ showWorks: true
5647
+ });
5648
+ scrollUsageCalendarsToLatest(host);
5649
+ }
5650
+
4544
5651
  async function renderBookAiSettings() {
4545
- const [settings, providers, models, taskDefaults] = await Promise.all([
5652
+ if (relationshipSearchIndexRefreshTimer) {
5653
+ clearTimeout(relationshipSearchIndexRefreshTimer);
5654
+ relationshipSearchIndexRefreshTimer = null;
5655
+ }
5656
+ const [settings, providers, models, taskDefaults, relationshipIndex, usage] = await Promise.all([
4546
5657
  api(`/api/works/${state.work.id}/ai-settings`),
4547
5658
  api("/api/platform/ai/providers"),
4548
5659
  api(`/api/works/${state.work.id}/models`),
4549
- api(`/api/works/${state.work.id}/task-defaults`)
5660
+ api(`/api/works/${state.work.id}/task-defaults`),
5661
+ api(`/api/works/${state.work.id}/ai-settings/relationship-search-index`),
5662
+ api(`/api/works/${state.work.id}/ai-settings/usage?timezoneOffset=${-new Date().getTimezoneOffset()}`)
4550
5663
  ]);
4551
5664
  const host = $("#module-content");
5665
+ const workId = String(state.work.id);
4552
5666
  const agentTools = new Set(settings.agentTools ?? ["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections"]);
4553
- host.innerHTML = `<section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名或关键词子串匹配设定、人物、组织、时间线、关系、大纲和伏笔;非语义检索。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults)}`;
5667
+ host.innerHTML = `<section class="config-section">${tokenUsageOverviewMarkup(usage, {
5668
+ title: "本书 Token 用量",
5669
+ description: `仅统计《${state.work.title}》迄今产生的 AI Token 消耗与缓存命中情况。`
5670
+ })}</section><section class="config-section"><div class="config-section-header"><div><h2>本书系统提示词</h2><p>会追加在内置系统提示词和平台全局系统提示词之后,只影响《${esc(state.work.title)}》的 AI 请求。</p></div></div><div class="field-label"><textarea id="work-system-prompt" rows="8" aria-label="本书系统提示词" placeholder="例如:叙事使用第三人称,哥斯拉不得离开地球。">${esc(settings.systemPrompt)}</textarea></div><div class="card-actions"><button id="save-work-system-prompt" class="ghost-button config-save-button" type="button">保存本书提示词</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>人物关系拼音索引</h2><p>平时由系统记录增量任务;“同步增量队列”只处理发生变化的来源,“完整重建索引”会将本书全部正文和设定来源重新排队。</p></div></div><div id="relationship-search-index-status" role="status" aria-live="polite">${relationshipIndexStatusMarkup(relationshipIndex)}</div><div class="relationship-index-actions"><button id="sync-relationship-search-index" class="primary-button config-save-button" type="button">同步增量队列</button><button id="refresh-relationship-search-index" class="ghost-button" type="button">刷新状态</button><button id="rebuild-relationship-search-index" class="ghost-button config-save-button" type="button">完整重建索引</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>全书概要引用配额</h2><p>引用全书概要时按分卷保留覆盖,并优先加入与当前问题相关的章节概要;该比例控制概要可使用的上下文预算。</p></div></div><div class="config-inline-save"><label class="book-summary-context-percent-field">上下文占比(%)<input id="book-summary-context-percent" type="number" min="1" max="90" value="${esc(String(settings.bookSummaryContextPercent ?? 50))}" aria-label="全书概要引用上下文占比"></label><button id="save-book-summary-context-percent" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>对话上下文 Compact</h2><p>对话 context 使用独立预算。达到该百分比阈值时先提醒;继续发送会对较早消息执行 compact,压缩上下文占用,并尽量保留最近八条原文。</p></div></div><div class="config-inline-save"><label class="context-compact-threshold-field">Compact 阈值(%)<input id="context-compact-threshold" type="number" min="50" max="90" value="${esc(String(settings.contextCompactThreshold ?? 85))}" aria-label="对话上下文 compact 阈值"></label><button id="save-context-compact-threshold" class="ghost-button config-save-button" type="button">保存</button></div></section><section class="config-section"><div class="config-section-header"><div><h2>AI 查询工具</h2><p>工具默认可用,作为已有上下文的补充。关闭后模型不会看到对应能力;所有工具只读且有数量、篇幅与调用轮次限制。</p></div></div><div class="ai-agent-tools"><label><input name="agent-tool" type="checkbox" value="story_index" ${agentTools.has("story_index") ? "checked" : ""}><span><strong>作品目录与章节概要</strong><small>分页获取卷章、章节 ID 和当前概要,不返回正文。</small></span></label><label><input name="agent-tool" type="checkbox" value="read_chapters" ${agentTools.has("read_chapters") ? "checked" : ""}><span><strong>读取章节</strong><small>按章节 ID 获取概要或正文,每次最多 3 章。</small></span></label><label><input name="agent-tool" type="checkbox" value="search_story_entities" ${agentTools.has("search_story_entities") ? "checked" : ""}><span><strong>搜索作品实体</strong><small>按实体名或关键词子串匹配设定、人物、组织、时间线、关系、大纲和伏笔;非语义检索。</small></span></label></div><div class="card-actions"><button id="save-agent-tools" class="ghost-button config-save-button" type="button">保存工具设置</button></div></section>${renderTaskDefaults(models, providers, taskDefaults)}`;
5671
+ scrollUsageCalendarsToLatest(host);
4554
5672
  host.querySelector('input[name="agent-tool"][value="search_story_entities"]').closest("label").insertAdjacentHTML(
4555
5673
  "beforebegin",
4556
5674
  `<label><input name="agent-tool" type="checkbox" value="grep" ${agentTools.has("grep") ? "checked" : ""}><span><strong>查询正文关键字</strong><small>从段落索引查询关键字,默认返回前 20 条完整段落和章节信息。</small></span></label>`
@@ -4563,6 +5681,33 @@ async function renderBookAiSettings() {
4563
5681
  host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
4564
5682
  host.querySelectorAll(".config-save-button").forEach((button) => button.classList.add("permission-hidden"));
4565
5683
  }
5684
+ const isCurrentRelationshipIndexPanel = () => state.module === "ai-settings"
5685
+ && String(state.work?.id ?? "") === workId
5686
+ && Boolean($("#relationship-search-index-status"));
5687
+ const updateRelationshipIndexStatus = (status) => {
5688
+ const statusHost = $("#relationship-search-index-status");
5689
+ if (!statusHost) return;
5690
+ statusHost.innerHTML = relationshipIndexStatusMarkup(status);
5691
+ if (relationshipSearchIndexRefreshTimer) clearTimeout(relationshipSearchIndexRefreshTimer);
5692
+ relationshipSearchIndexRefreshTimer = null;
5693
+ if (!["queued", "building"].includes(String(status.status))) return;
5694
+ relationshipSearchIndexRefreshTimer = setTimeout(async () => {
5695
+ relationshipSearchIndexRefreshTimer = null;
5696
+ if (!isCurrentRelationshipIndexPanel()) return;
5697
+ try {
5698
+ const nextStatus = await api(`/api/works/${workId}/ai-settings/relationship-search-index`);
5699
+ if (isCurrentRelationshipIndexPanel()) updateRelationshipIndexStatus(nextStatus);
5700
+ } catch {
5701
+ // 后台轮询失败时保留当前状态,用户仍可手动刷新。
5702
+ }
5703
+ }, 1_200);
5704
+ };
5705
+ const refreshRelationshipIndexStatus = async () => {
5706
+ const status = await api(`/api/works/${workId}/ai-settings/relationship-search-index`);
5707
+ updateRelationshipIndexStatus(status);
5708
+ return status;
5709
+ };
5710
+ updateRelationshipIndexStatus(relationshipIndex);
4566
5711
  $("#save-work-system-prompt").addEventListener("click", async () => {
4567
5712
  const button = $("#save-work-system-prompt");
4568
5713
  button.disabled = true;
@@ -4576,6 +5721,46 @@ async function renderBookAiSettings() {
4576
5721
  button.disabled = false;
4577
5722
  }
4578
5723
  });
5724
+ $("#sync-relationship-search-index").addEventListener("click", async () => {
5725
+ const button = $("#sync-relationship-search-index");
5726
+ button.disabled = true;
5727
+ try {
5728
+ const status = await api(`/api/works/${workId}/ai-settings/relationship-search-index/sync`, { method: "POST" });
5729
+ updateRelationshipIndexStatus(status);
5730
+ toast(Number(status.queuedSourceCount) > 0
5731
+ ? `开始同步 ${status.queuedSourceCount} 项增量任务`
5732
+ : "增量任务队列为空,索引已是最新状态");
5733
+ } catch (error) {
5734
+ toast(error.message, "error");
5735
+ } finally {
5736
+ button.disabled = false;
5737
+ }
5738
+ });
5739
+ $("#refresh-relationship-search-index").addEventListener("click", async () => {
5740
+ const button = $("#refresh-relationship-search-index");
5741
+ button.disabled = true;
5742
+ try {
5743
+ await refreshRelationshipIndexStatus();
5744
+ toast("索引状态已刷新", "info");
5745
+ } catch (error) {
5746
+ toast(error.message, "error");
5747
+ } finally {
5748
+ button.disabled = false;
5749
+ }
5750
+ });
5751
+ $("#rebuild-relationship-search-index").addEventListener("click", async () => {
5752
+ const button = $("#rebuild-relationship-search-index");
5753
+ button.disabled = true;
5754
+ try {
5755
+ const status = await api(`/api/works/${workId}/ai-settings/relationship-search-index/rebuild`, { method: "POST" });
5756
+ updateRelationshipIndexStatus(status);
5757
+ toast(`已将全部来源加入索引队列,共 ${status.queuedSourceCount} 项`);
5758
+ } catch (error) {
5759
+ toast(error.message, "error");
5760
+ } finally {
5761
+ button.disabled = false;
5762
+ }
5763
+ });
4579
5764
  $("#save-book-summary-context-percent").addEventListener("click", async () => {
4580
5765
  const button = $("#save-book-summary-context-percent");
4581
5766
  button.disabled = true;
@@ -5124,12 +6309,16 @@ function openWorkSettingsDialog(work) {
5124
6309
  <div><strong id="import-history-settings-title">正文导入历史</strong><small>查看导入前快照并恢复被覆盖的分卷、章节标题和正文;大纲、伏笔等章节关联资料不在快照中。</small></div>
5125
6310
  <button id="import-history-button" class="ghost-button" type="button" aria-controls="import-history-dialog" aria-haspopup="dialog" ${canOpenImportHistory ? "" : "disabled"}>${importHistoryAction}</button>
5126
6311
  </section>`;
6312
+ const recycleBinField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="chapter-recycle-bin-settings-title">
6313
+ <div><strong id="chapter-recycle-bin-settings-title">章节回收站</strong><small>查看并恢复已软删除的章节,正文、版本和关联资料不会在删除时清理。</small></div>
6314
+ <button id="chapter-recycle-bin-button" class="ghost-button" type="button" aria-controls="chapter-recycle-bin-dialog" aria-haspopup="dialog" ${canEditProse() ? "" : "disabled"}>打开回收站</button>
6315
+ </section>` : "";
5127
6316
  const whitespaceField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="whitespace-settings-title">
5128
6317
  <div><strong id="whitespace-settings-title">正文空白符</strong><small>在编辑器正文中显示或隐藏空格、全角空格和 Tab 的可视标记。</small></div>
5129
6318
  <button id="toggle-whitespace-settings" class="ghost-button" data-toggle-whitespace type="button" aria-pressed="${chapterWhitespaceVisible}" title="用点标记半角空格,用方框标记全角空格,用箭头标记 Tab">${chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符"}</button>
5130
6319
  </section>` : "";
5131
6320
  openDialog("作品信息",
5132
- workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + whitespaceField + accessField + importHistoryField,
6321
+ workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + whitespaceField + accessField + importHistoryField + recycleBinField,
5133
6322
  async (form) => {
5134
6323
  await api(`/api/works/${work.id}`, { method: "PATCH", body: { title: form.get("title"), author: form.get("author"), description: form.get("description") } });
5135
6324
  state.works = (await apiPage("/api/works")).items;
@@ -5152,6 +6341,10 @@ function openWorkSettingsDialog(work) {
5152
6341
  $("#form-dialog").close();
5153
6342
  void openImportHistory();
5154
6343
  });
6344
+ $("#chapter-recycle-bin-button")?.addEventListener("click", () => {
6345
+ $("#form-dialog").close();
6346
+ void openChapterRecycleBin();
6347
+ });
5155
6348
  $("#work-access-manage")?.addEventListener("click", () => {
5156
6349
  $("#form-dialog").close();
5157
6350
  openMembersDialog(work);
@@ -6040,9 +7233,10 @@ async function showCharacterHistory() {
6040
7233
 
6041
7234
  async function openCharacterEditor(item = null, { readOnly = false } = {}) {
6042
7235
  entityEditorReadOnly = readOnly;
6043
- [state.races, state.organizations] = await Promise.all([
7236
+ [state.races, state.organizations, state.characters] = await Promise.all([
6044
7237
  canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
6045
- canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
7238
+ canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([]),
7239
+ canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
6046
7240
  ]);
6047
7241
  characterEditorItem = item ?? null;
6048
7242
  characterEditorVersions = [];
@@ -6391,6 +7585,7 @@ async function openRelationshipDialog(item, options = {}) {
6391
7585
  <div><strong>关系操作</strong><small>删除操作会记录到版本历史和审计日志。</small></div>
6392
7586
  <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-relationship-delete>删除关系</button></div>
6393
7587
  </section>` : "";
7588
+ setRelationshipPresence(item?.id ?? null);
6394
7589
  openDialog(item ? "编辑人物关系" : "新建人物关系", field("from", "起点人物", "select", item?.fromCharacterId ?? defaultFrom, characterOptions) + field("to", "终点人物", "select", item?.toCharacterId ?? defaultTo, characterOptions) + field("category", "关系大类", "select", item?.category ?? "social", [["family", "亲属"], ["social", "社交"], ["emotional", "情感"], ["conflict", "冲突"], ["uncertain", "未确定"]]) + field("subtype", "关系子类", "text", item?.subtype) + field("keywords", "关系关键词", "keyword-chips", item?.keywords ?? []) + field("confidence", "置信度(0-1)", "number", item?.confidence ?? "1") + field("directed", "有方向性", "checkbox", item?.directed ?? false) + management, async (form) => {
6395
7590
  const keywords = uniqueRelationshipKeywords(form.getAll("keywords").map(String));
6396
7591
  await api(item ? `/api/relationships/${item.id}` : `/api/works/${state.work.id}/relationships`, { method: item ? "PATCH" : "POST", body: { fromCharacterId: form.get("from"), toCharacterId: form.get("to"), category: form.get("category"), subtype: form.get("subtype"), keywords, confidence: Number(form.get("confidence")), directed: form.get("directed") === "on", confirmationStatus: item?.confirmationStatus ?? "confirmed", ...(item ? { expectedVersionNo: item.versionNo } : {}) } });
@@ -6398,15 +7593,19 @@ async function openRelationshipDialog(item, options = {}) {
6398
7593
  }, item ? "关系档案" : "人工确认关系");
6399
7594
  $("#dialog-fields").querySelector("[data-dialog-relationship-delete]")?.addEventListener("click", async () => {
6400
7595
  const dialog = $("#form-dialog");
7596
+ const reopenDialog = () => {
7597
+ setRelationshipPresence(item.id);
7598
+ dialog.showModal();
7599
+ };
6401
7600
  dialog.close();
6402
- if (!await confirmToast(`确认删除人物关系“${relationshipName}”吗?`, { title: "删除人物关系", confirmLabel: "继续删除" })) return dialog.showModal();
6403
- if (!await confirmToast(`删除人物关系“${relationshipName}”后无法恢复。`, { title: "删除操作需要再次确认", confirmLabel: "确认删除" })) return dialog.showModal();
7601
+ if (!await confirmToast(`确认删除人物关系“${relationshipName}”吗?`, { title: "删除人物关系", confirmLabel: "继续删除" })) return reopenDialog();
7602
+ if (!await confirmToast(`删除人物关系“${relationshipName}”后无法恢复。`, { title: "删除操作需要再次确认", confirmLabel: "确认删除" })) return reopenDialog();
6404
7603
  try {
6405
7604
  await api(`/api/relationships/${item.id}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
6406
7605
  await Promise.all([refreshRelationshipSurfaces(options.characterId ?? null), loadAiReferences()]);
6407
7606
  toast(`已删除人物关系“${relationshipName}”`);
6408
7607
  } catch (error) {
6409
- dialog.showModal();
7608
+ reopenDialog();
6410
7609
  toast(error.message, "error");
6411
7610
  }
6412
7611
  });
@@ -6471,30 +7670,76 @@ async function openTaskDialog() {
6471
7670
  const relationshipFields = `<div class="relationship-analysis-options hidden">
6472
7671
  ${relationshipCharacterPicker}
6473
7672
  <p class="relationship-analysis-helper"><span aria-hidden="true">i</span><span>留空时使用基础关系抽取;选中角色后,将汇总其跨章节证据再进行全局关系归纳。默认仅追加不存在的关系,不修改或删除已有关系。</span></p>
6474
- <div class="relationship-overwrite-card hidden">
7673
+ <div class="relationship-overwrite-card relationship-prefilter-card">
7674
+ <label class="checkbox-field"><input name="preFilterRelationshipSources" type="checkbox" checked disabled><span>分析前按人物名称和拼音过滤来源</span></label>
7675
+ <p>仅对定向人物分析生效。取消勾选后,将跳过前置过滤,无差别发送所选范围内的全部章节和设定来源。</p>
7676
+ </div>
7677
+ <div class="relationship-overwrite-card relationship-change-preview-card">
7678
+ <label class="checkbox-field"><input name="previewRelationshipChanges" type="checkbox" checked><span>分析完成后先预览关系变更</span></label>
7679
+ <p>建议保持开启。分析只生成新增、更新和删除清单,确认应用前不会修改人物关系库;取消勾选则沿用直接写入逻辑。</p>
7680
+ </div>
7681
+ <section class="relationship-source-preview-card" aria-labelledby="relationship-source-preview-title">
7682
+ <div class="relationship-source-preview-header">
7683
+ <div><strong id="relationship-source-preview-title">发送来源预检</strong><p>创建任务前查看将发送的章节与设定,并可取消误命中的来源。疑似拼音写法可能调用当前任务模型确认身份。</p></div>
7684
+ <button type="button" class="ghost-button" data-relationship-source-preview disabled>预览来源</button>
7685
+ </div>
7686
+ <div class="relationship-source-preview-content" data-relationship-source-preview-content role="status" aria-live="polite">选择至少一个被分析角色后即可预览。</div>
7687
+ </section>
7688
+ <div class="relationship-overwrite-card relationship-existing-overwrite-card hidden">
6475
7689
  <label class="checkbox-field"><input name="replaceExistingRelationships" type="checkbox" disabled><span>用本次结果覆盖所选角色的已有关系</span></label>
6476
7690
  <p>勾选后,任务成功时会先删除所有涉及所选角色的旧关系,再写入本次分析结果;不勾选则只追加新关系。</p>
6477
7691
  </div>
6478
7692
  <label>额外分析提示<textarea name="additionalPrompt" maxlength="10000" placeholder="例如:重点识别权力继承、师承变化或隐秘亲缘关系"></textarea><small>将同时追加到证据收集和全局关系归纳提示词,仅影响本次任务。</small></label>
6479
7693
  </div>`;
6480
- openDialog("开始 AI 分析", taskTypeField + modelField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + chapterField + relationshipFields, async (form) => {
7694
+ let relationshipSourcePreview = null;
7695
+ let relationshipSourcePreviewConfigKey = "";
7696
+ const buildRelationshipScope = (form) => {
6481
7697
  const taskType = String(form.get("taskType"));
6482
- const modelId = String(form.get("modelId"));
6483
7698
  const scopeType = String(form.get("scopeType"));
6484
7699
  const includeAllSettings = taskType === "relationship-analysis" && scopeType === "book-with-settings";
6485
7700
  const settingsOnly = taskType === "relationship-analysis" && scopeType === "settings";
6486
7701
  const additionalPrompt = taskType === "relationship-analysis" ? String(form.get("additionalPrompt") ?? "").trim() : "";
6487
7702
  const characterIds = taskType === "relationship-analysis" ? form.getAll("characterIds").map(String).filter(Boolean) : [];
7703
+ const preFilterRelationshipSources = characterIds.length > 0 && form.get("preFilterRelationshipSources") === "on";
7704
+ const previewRelationshipChanges = taskType === "relationship-analysis" && form.get("previewRelationshipChanges") === "on";
6488
7705
  const replaceExistingRelationships = characterIds.length > 0 && form.get("replaceExistingRelationships") === "on";
6489
7706
  const scope = settingsOnly
6490
- ? { type: "settings", ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
7707
+ ? { type: "settings", ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
6491
7708
  : taskType === "character-identity-audit" || scopeType === "book" || includeAllSettings
6492
- ? { type: "book", ...(includeAllSettings ? { includeAllSettings: true } : {}), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
6493
- : { type: "chapter", chapterId: form.get("chapterId"), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) };
7709
+ ? { type: "book", ...(includeAllSettings ? { includeAllSettings: true } : {}), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) }
7710
+ : { type: "chapter", chapterId: form.get("chapterId"), ...(additionalPrompt ? { additionalPrompt } : {}), ...(characterIds.length ? { characterIds, preFilterRelationshipSources } : {}), ...(previewRelationshipChanges ? { previewRelationshipChanges: true } : {}), ...(replaceExistingRelationships ? { replaceExistingRelationships: true } : {}) };
7711
+ return scope;
7712
+ };
7713
+ const relationshipPreviewKey = (scope, modelId) => JSON.stringify({
7714
+ type: scope.type,
7715
+ chapterId: scope.chapterId ?? null,
7716
+ includeAllSettings: scope.includeAllSettings === true,
7717
+ characterIds: scope.characterIds ?? [],
7718
+ preFilterRelationshipSources: scope.preFilterRelationshipSources !== false,
7719
+ modelId
7720
+ });
7721
+ openDialog("开始 AI 分析", taskTypeField + modelField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + chapterField + relationshipFields, async (form) => {
7722
+ const taskType = String(form.get("taskType"));
7723
+ const modelId = String(form.get("modelId"));
7724
+ const scope = buildRelationshipScope(form);
7725
+ if (taskType === "relationship-analysis" && relationshipSourcePreview
7726
+ && relationshipSourcePreviewConfigKey === relationshipPreviewKey(scope, modelId)) {
7727
+ scope.relationshipSourceRefs = [...$("#dialog-fields").querySelectorAll("[data-relationship-source-selected]:checked")]
7728
+ .map((input) => ({
7729
+ sourceType: input.dataset.sourceType,
7730
+ sourceId: input.dataset.sourceId,
7731
+ sourceVersion: input.dataset.sourceVersion
7732
+ }));
7733
+ }
6494
7734
  await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType, scope, modelId } });
7735
+ await refreshBackgroundTaskCenter({ announce: false });
6495
7736
  taskListPage = 1;
6496
- toast("分析任务已创建,已进入任务队列");
6497
- void renderTasks(1).catch((error) => toast(`任务已创建,但列表刷新失败:${error.message}`, "error"));
7737
+ try {
7738
+ await renderTasks(1);
7739
+ toast("分析任务已创建,已进入任务队列");
7740
+ } catch (error) {
7741
+ toast(`任务已创建,但列表刷新失败:${error.message}`, "error");
7742
+ }
6498
7743
  }, "AI 分析", {
6499
7744
  submitLabel: "创建任务",
6500
7745
  pendingLabel: "创建中…",
@@ -6518,8 +7763,11 @@ async function openTaskDialog() {
6518
7763
  const relationshipCharacterCount = relationshipOptions.querySelector("[data-relationship-character-count]");
6519
7764
  const relationshipCharacterClear = relationshipOptions.querySelector("[data-relationship-character-clear]");
6520
7765
  const relationshipCharacterEmpty = relationshipOptions.querySelector("[data-relationship-character-empty]");
7766
+ const preFilterRelationships = relationshipOptions.querySelector('input[name="preFilterRelationshipSources"]');
7767
+ const relationshipSourcePreviewButton = relationshipOptions.querySelector("[data-relationship-source-preview]");
7768
+ const relationshipSourcePreviewContent = relationshipOptions.querySelector("[data-relationship-source-preview-content]");
6521
7769
  const replaceRelationships = relationshipOptions.querySelector('input[name="replaceExistingRelationships"]');
6522
- const relationshipOverwriteCard = relationshipOptions.querySelector(".relationship-overwrite-card");
7770
+ const relationshipOverwriteCard = relationshipOptions.querySelector(".relationship-existing-overwrite-card");
6523
7771
  const allSettingsOption = document.createElement("option");
6524
7772
  allSettingsOption.value = "book-with-settings";
6525
7773
  allSettingsOption.textContent = "全书 + 设定集";
@@ -6547,6 +7795,41 @@ async function openTaskDialog() {
6547
7795
  relationshipCharacterClear.disabled = selected.length === 0;
6548
7796
  relationshipCharacterTrigger.setAttribute("aria-label", `筛选被分析角色,已选择 ${selected.length} 个`);
6549
7797
  };
7798
+ const invalidateRelationshipSourcePreview = () => {
7799
+ relationshipSourcePreview = null;
7800
+ relationshipSourcePreviewConfigKey = "";
7801
+ relationshipSourcePreviewContent.classList.remove("is-loading");
7802
+ relationshipSourcePreviewContent.textContent = relationshipCharacterInputs.some((input) => input.checked)
7803
+ ? "分析配置已变化,请重新预览将发送的来源。"
7804
+ : "选择至少一个被分析角色后即可预览。";
7805
+ };
7806
+ const relationshipSourceTypeLabel = (sourceType) => ({
7807
+ chapter: "章节",
7808
+ work: "作品资料",
7809
+ setting: "设定",
7810
+ character: "人物档案",
7811
+ race: "种族",
7812
+ organization: "组织",
7813
+ "timeline-track": "时间轴",
7814
+ "timeline-event": "时间线事件",
7815
+ relationship: "已有关系",
7816
+ "chapter-outline": "章节大纲",
7817
+ foreshadow: "伏笔",
7818
+ review: "审核项"
7819
+ }[sourceType] ?? sourceType);
7820
+ const renderRelationshipSourcePreview = (preview) => {
7821
+ const summary = `<div class="relationship-source-preview-summary"><strong>${preview.sourceCount} 条来源</strong><span>${preview.chapterCount} 章</span><span>${preview.settingCount} 条设定资料</span><span>约 ${Number(preview.totalCharacters).toLocaleString("zh-CN")} 字符</span><span>预计 ${preview.estimatedBatchCount} 个批次</span></div>`;
7822
+ if (!preview.sources.length) {
7823
+ relationshipSourcePreviewContent.innerHTML = `${summary}<p class="relationship-source-preview-empty">当前配置没有命中任何来源。仍可创建任务,但任务不会向模型发送正文或设定。</p>`;
7824
+ return;
7825
+ }
7826
+ const rows = preview.sources.map((source) => `<label class="relationship-source-preview-row">
7827
+ <input type="checkbox" data-relationship-source-selected data-source-type="${esc(source.sourceType)}" data-source-id="${esc(source.sourceId)}" data-source-version="${esc(source.version)}" checked>
7828
+ <span class="relationship-source-preview-main"><strong>${esc(source.title)}</strong><small>${esc(relationshipSourceTypeLabel(source.sourceType))} · ${Number(source.characterCount).toLocaleString("zh-CN")} 字符</small></span>
7829
+ <span class="relationship-source-preview-match ${source.matchType === "fuzzy" ? "is-fuzzy" : ""}">${source.matchType === "exact" ? "名称命中" : source.matchType === "fuzzy" ? "疑似写法确认" : "范围内来源"}</span>
7830
+ </label>`).join("");
7831
+ relationshipSourcePreviewContent.innerHTML = `${summary}<div class="relationship-source-preview-list" role="group" aria-label="选择要发送的来源">${rows}</div>`;
7832
+ };
6550
7833
  const filterRelationshipCharacters = () => {
6551
7834
  const query = relationshipCharacterSearch.value.trim().toLocaleLowerCase();
6552
7835
  let visibleCount = 0;
@@ -6577,6 +7860,8 @@ async function openTaskDialog() {
6577
7860
  for (const input of relationshipCharacterInputs) input.disabled = !enabled;
6578
7861
  if (!enabled) setRelationshipCharacterBubbleOpen(false);
6579
7862
  const hasSelectedCharacters = enabled && relationshipCharacterInputs.some((input) => input.checked);
7863
+ preFilterRelationships.disabled = !hasSelectedCharacters;
7864
+ relationshipSourcePreviewButton.disabled = !hasSelectedCharacters || !taskModelSelect.value;
6580
7865
  replaceRelationships.disabled = !hasSelectedCharacters;
6581
7866
  relationshipOverwriteCard.classList.toggle("hidden", !hasSelectedCharacters);
6582
7867
  if (!hasSelectedCharacters) replaceRelationships.checked = false;
@@ -6591,17 +7876,56 @@ async function openTaskDialog() {
6591
7876
  taskTypeSelect.addEventListener("change", () => {
6592
7877
  description.textContent = analysisTypeDescription(taskTypeSelect.value);
6593
7878
  syncTaskModelDefault();
7879
+ invalidateRelationshipSourcePreview();
7880
+ syncRelationshipOptions();
7881
+ });
7882
+ taskModelSelect.addEventListener("change", () => {
7883
+ invalidateRelationshipSourcePreview();
6594
7884
  syncRelationshipOptions();
6595
7885
  });
6596
7886
  relationshipCharacterTrigger.addEventListener("click", () => {
6597
7887
  setRelationshipCharacterBubbleOpen(relationshipCharacterTrigger.getAttribute("aria-expanded") !== "true");
6598
7888
  });
6599
7889
  relationshipCharacterSearch.addEventListener("input", filterRelationshipCharacters);
6600
- for (const input of relationshipCharacterInputs) input.addEventListener("change", syncRelationshipOptions);
7890
+ for (const input of relationshipCharacterInputs) input.addEventListener("change", () => {
7891
+ invalidateRelationshipSourcePreview();
7892
+ syncRelationshipOptions();
7893
+ });
6601
7894
  relationshipCharacterClear.addEventListener("click", () => {
6602
7895
  for (const input of relationshipCharacterInputs) input.checked = false;
7896
+ invalidateRelationshipSourcePreview();
6603
7897
  syncRelationshipOptions();
6604
7898
  });
7899
+ preFilterRelationships.addEventListener("change", invalidateRelationshipSourcePreview);
7900
+ relationshipSourcePreviewButton.addEventListener("click", async () => {
7901
+ const form = new FormData($("#dynamic-form"));
7902
+ const scope = buildRelationshipScope(form);
7903
+ const modelId = String(form.get("modelId") ?? "");
7904
+ if (!scope.characterIds?.length) return toast("请先选择至少一个被分析角色", "error");
7905
+ relationshipSourcePreviewButton.disabled = true;
7906
+ relationshipSourcePreviewButton.textContent = "预检中…";
7907
+ relationshipSourcePreviewContent.classList.add("is-loading");
7908
+ relationshipSourcePreviewContent.textContent = "正在构建索引并核对来源,请稍候。";
7909
+ try {
7910
+ const preview = await api(`/api/works/${state.work.id}/tasks/relationship-source-preview`, {
7911
+ method: "POST",
7912
+ body: { scope, modelId }
7913
+ });
7914
+ relationshipSourcePreview = preview;
7915
+ relationshipSourcePreviewConfigKey = relationshipPreviewKey(scope, modelId);
7916
+ relationshipSourcePreviewContent.classList.remove("is-loading");
7917
+ renderRelationshipSourcePreview(preview);
7918
+ } catch (error) {
7919
+ relationshipSourcePreview = null;
7920
+ relationshipSourcePreviewConfigKey = "";
7921
+ relationshipSourcePreviewContent.classList.remove("is-loading");
7922
+ relationshipSourcePreviewContent.textContent = `来源预检失败:${error.message}`;
7923
+ toast(`来源预检失败:${error.message}`, "error");
7924
+ } finally {
7925
+ relationshipSourcePreviewButton.textContent = "重新预览";
7926
+ syncRelationshipOptions();
7927
+ }
7928
+ });
6605
7929
  $("#dynamic-form").onclick = (event) => {
6606
7930
  if (!relationshipCharacterPickerElement.contains(event.target)) setRelationshipCharacterBubbleOpen(false);
6607
7931
  };
@@ -6611,7 +7935,11 @@ async function openTaskDialog() {
6611
7935
  setRelationshipCharacterBubbleOpen(false);
6612
7936
  relationshipCharacterTrigger.focus();
6613
7937
  };
6614
- scopeTypeSelect.addEventListener("change", syncChapterField);
7938
+ scopeTypeSelect.addEventListener("change", () => {
7939
+ invalidateRelationshipSourcePreview();
7940
+ syncChapterField();
7941
+ });
7942
+ chapterSelect.addEventListener("change", invalidateRelationshipSourcePreview);
6615
7943
  syncRelationshipOptions();
6616
7944
  }
6617
7945
 
@@ -7047,19 +8375,137 @@ async function openImportHistory() {
7047
8375
  }
7048
8376
  }
7049
8377
 
8378
+ function renderChapterRecycleBin(chapters) {
8379
+ const host = $("#chapter-recycle-bin-list");
8380
+ if (!chapters.length) {
8381
+ host.innerHTML = '<p class="entity-history-empty">回收站为空,没有可恢复的章节。</p>';
8382
+ return;
8383
+ }
8384
+ host.innerHTML = chapters.map((chapter) => `<article class="entity-version-card" data-deleted-chapter="${esc(chapter.id)}">
8385
+ <header><strong>${esc(chapter.title)}</strong><span class="import-history-kind">${esc(chapter.volumeTitle)}</span></header>
8386
+ <time>${esc(formatDateTime(chapter.deletedAt))} · ${esc(chapter.actor)}</time>
8387
+ <p>${esc(chapter.contentPreview || "空白章节")}</p>
8388
+ <small>${Number(chapter.wordCount).toLocaleString("zh-CN")} 字 · 删除版本 v${Number(chapter.versionNo)}</small>
8389
+ <button type="button" data-restore-deleted-chapter="${esc(chapter.id)}">恢复章节</button>
8390
+ </article>`).join("");
8391
+ host.querySelectorAll("[data-restore-deleted-chapter]").forEach((button) => button.addEventListener("click", async () => {
8392
+ const chapter = chapters.find((item) => item.id === button.dataset.restoreDeletedChapter);
8393
+ if (!chapter || !state.work) return;
8394
+ const dialog = $("#chapter-recycle-bin-dialog");
8395
+ dialog.close();
8396
+ const confirmed = await confirmToast(`将章节“${chapter.title}”恢复到分卷“${chapter.volumeTitle}”吗?`, {
8397
+ title: "恢复已删除章节",
8398
+ confirmLabel: "确认恢复"
8399
+ });
8400
+ if (!confirmed) {
8401
+ dialog.showModal();
8402
+ return;
8403
+ }
8404
+ button.disabled = true;
8405
+ try {
8406
+ await api(`/api/chapters/${encodeURIComponent(chapter.id)}/restore`, {
8407
+ method: "POST",
8408
+ body: { versionNo: chapter.versionNo, expectedVersionNo: chapter.versionNo }
8409
+ });
8410
+ state.work = await api(`/api/works/${encodeURIComponent(state.work.id)}`);
8411
+ renderTree();
8412
+ await loadChapterRecycleBin();
8413
+ dialog.showModal();
8414
+ toast(`已恢复章节“${chapter.title}”`);
8415
+ } catch (error) {
8416
+ button.disabled = false;
8417
+ dialog.showModal();
8418
+ toast(error.message, "error");
8419
+ }
8420
+ }));
8421
+ }
8422
+
8423
+ async function loadChapterRecycleBin() {
8424
+ if (!state.work) return;
8425
+ const chapters = await api(`/api/works/${encodeURIComponent(state.work.id)}/deleted-chapters`);
8426
+ renderChapterRecycleBin(chapters);
8427
+ }
8428
+
8429
+ async function openChapterRecycleBin() {
8430
+ if (!state.work || !canEditProse()) return toast("当前权限不能恢复正文", "error");
8431
+ $("#chapter-recycle-bin-list").innerHTML = '<p class="entity-history-empty">正在读取已删除章节…</p>';
8432
+ $("#chapter-recycle-bin-dialog").showModal();
8433
+ try {
8434
+ await loadChapterRecycleBin();
8435
+ } catch (error) {
8436
+ $("#chapter-recycle-bin-dialog").close();
8437
+ toast(error.message, "error");
8438
+ }
8439
+ }
8440
+
7050
8441
  async function showChapterInsight() {
7051
8442
  if (!state.chapter) return;
7052
- const panel = $("#chapter-insight");
7053
- const insights = await api(`/api/chapters/${state.chapter.id}/insights`);
8443
+ const chapterId = state.chapter.id;
8444
+ dismissChapterInsightToast();
8445
+ const requestId = ++chapterInsightRequestId;
8446
+ const insights = await api(`/api/chapters/${chapterId}/insights`);
8447
+ if (requestId !== chapterInsightRequestId || state.chapter?.id !== chapterId || $("#editor-view").classList.contains("hidden")) return;
7054
8448
  const insight = insights.find((item) => item.chapterVersion === state.chapter.versionNo) ?? insights[0];
7055
- panel.classList.remove("hidden");
8449
+ const region = $("#toast-region");
8450
+ const element = document.createElement("section");
8451
+ element.id = "chapter-insight-toast";
8452
+ element.className = "toast chapter-insight-toast";
8453
+ element.setAttribute("role", "region");
8454
+ element.setAttribute("aria-label", "章节概览");
8455
+ const header = document.createElement("header");
8456
+ header.className = "chapter-insight-toast-header";
8457
+ const heading = document.createElement("div");
8458
+ heading.className = "chapter-insight-toast-heading";
8459
+ const title = document.createElement("strong");
8460
+ title.textContent = "章节概览";
8461
+ heading.append(title);
8462
+ if (insight && insight.chapterVersion !== state.chapter.versionNo) {
8463
+ const stale = document.createElement("span");
8464
+ stale.textContent = `基于旧版本 v${insight.chapterVersion}`;
8465
+ heading.append(stale);
8466
+ }
8467
+ const close = document.createElement("button");
8468
+ close.className = "ghost-button chapter-insight-toast-close";
8469
+ close.type = "button";
8470
+ close.textContent = "关闭";
8471
+ close.setAttribute("aria-label", "关闭章节概览");
8472
+ header.append(heading, close);
8473
+ const body = document.createElement("div");
8474
+ body.className = "chapter-insight-toast-body";
7056
8475
  if (!insight) {
7057
- panel.innerHTML = "<strong>尚无章节概览</strong>请在“AI 分析”中运行章节理解,完成后可在此查看结果。";
7058
- return;
8476
+ const empty = document.createElement("p");
8477
+ empty.className = "chapter-insight-toast-empty";
8478
+ empty.textContent = "尚无章节概览。请在“AI 分析”中运行章节理解,完成后可在此查看结果。";
8479
+ body.append(empty);
8480
+ } else {
8481
+ const summary = document.createElement("p");
8482
+ summary.textContent = insight.summary || "暂无梗概";
8483
+ body.append(summary);
8484
+ const eventNames = insight.events.map((event) => typeof event === "string" ? event : (event.name ?? event.description ?? "未命名事件"));
8485
+ const uncertainties = insight.uncertainties.map((item) => typeof item === "string" ? item : JSON.stringify(item));
8486
+ [["事件", eventNames], ["待确认", uncertainties]].forEach(([label, items]) => {
8487
+ if (!items.length) return;
8488
+ const detail = document.createElement("div");
8489
+ detail.className = "chapter-insight-toast-detail";
8490
+ const detailLabel = document.createElement("strong");
8491
+ detailLabel.textContent = label;
8492
+ const detailContent = document.createElement("p");
8493
+ detailContent.textContent = items.join(";");
8494
+ detail.append(detailLabel, detailContent);
8495
+ body.append(detail);
8496
+ });
7059
8497
  }
7060
- const eventNames = insight.events.map((event) => typeof event === "string" ? event : (event.name ?? event.description ?? "未命名事件"));
7061
- const stale = insight.chapterVersion !== state.chapter.versionNo ? `;基于旧版本 v${insight.chapterVersion}` : "";
7062
- panel.innerHTML = `<strong>章节概览${esc(stale)}</strong>${esc(insight.summary || "暂无梗概")}${eventNames.length ? `<br><strong>事件</strong>${esc(eventNames.join(";"))}` : ""}${insight.uncertainties.length ? `<br><strong>待确认</strong>${esc(insight.uncertainties.map((item) => typeof item === "string" ? item : JSON.stringify(item)).join(";"))}` : ""}`;
8498
+ element.append(header, body);
8499
+ close.addEventListener("click", dismissChapterInsightToast, { once: true });
8500
+ element.addEventListener("keydown", (event) => {
8501
+ if (event.key !== "Escape") return;
8502
+ event.preventDefault();
8503
+ dismissChapterInsightToast();
8504
+ $("#insight-button").focus();
8505
+ });
8506
+ region.append(element);
8507
+ $("#insight-button").setAttribute("aria-expanded", "true");
8508
+ raiseToastRegion();
7063
8509
  }
7064
8510
 
7065
8511
  $("#home-button").addEventListener("click", async () => {
@@ -7606,7 +9052,32 @@ $("#register-form").addEventListener("submit", async (event) => {
7606
9052
  $("#settings-return").addEventListener("click", () => returnFromSettings().catch((error) => toast(error.message, "error")));
7607
9053
  $("#platform-ai-button").addEventListener("click", () => showPlatformAi().catch((error) => toast(error.message, "error")));
7608
9054
  $("#platform-ai-return").addEventListener("click", () => returnToSettingsHub("#platform-ai-button").catch((error) => toast(error.message, "error")));
9055
+ $("#platform-usage-button").addEventListener("click", () => showPlatformUsage().catch((error) => toast(error.message, "error")));
9056
+ $("#platform-usage-return").addEventListener("click", () => returnToSettingsHub("#platform-usage-button").catch((error) => toast(error.message, "error")));
9057
+ $("#platform-usage-refresh").addEventListener("click", async () => {
9058
+ const button = $("#platform-usage-refresh");
9059
+ button.disabled = true;
9060
+ try {
9061
+ await renderPlatformTokenUsage();
9062
+ toast("Token 用量已刷新");
9063
+ } catch (error) {
9064
+ toast(error.message, "error");
9065
+ } finally {
9066
+ button.disabled = false;
9067
+ }
9068
+ });
7609
9069
  $("#user-management-button").addEventListener("click", openUsersDialog);
9070
+ $("#writing-progress-button").addEventListener("click", () => openWritingProgressDialog().catch((error) => toast(error.message, "error")));
9071
+ $("#writing-progress-close").addEventListener("click", () => $("#writing-progress-dialog").close());
9072
+ $("#writing-progress-refresh").addEventListener("click", () => loadWritingProgress().catch((error) => toast(error.message, "error")));
9073
+ $("#writing-goal-form").addEventListener("submit", saveWritingGoal);
9074
+ $("#work-audit-button").addEventListener("click", () => openWorkAuditDialog().catch((error) => toast(error.message, "error")));
9075
+ $("#work-audit-close").addEventListener("click", () => $("#work-audit-dialog").close());
9076
+ $("#work-audit-settings-return").addEventListener("click", () => returnToSettingsHub("#work-audit-button", "#work-audit-dialog").catch((error) => toast(error.message, "error")));
9077
+ $("#work-audit-refresh").addEventListener("click", () => loadWorkAuditPage().catch((error) => toast(error.message, "error")));
9078
+ $("#work-audit-load-more").addEventListener("click", () => {
9079
+ if (workAuditNextPage !== null) loadWorkAuditPage(workAuditNextPage, true).catch((error) => toast(error.message, "error"));
9080
+ });
7610
9081
  $("#platform-ui-settings-button").addEventListener("click", openPlatformUiSettingsDialog);
7611
9082
  $("#collaboration-button").addEventListener("click", () => openMembersDialog());
7612
9083
  $("#presence-button").addEventListener("click", () => {
@@ -7629,7 +9100,14 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
7629
9100
  body: {
7630
9101
  toastPosition: $("#toast-position").value,
7631
9102
  pageSizes: {
9103
+ settings: Number($("#page-size-settings").value),
7632
9104
  characters: Number($("#page-size-characters").value),
9105
+ races: Number($("#page-size-races").value),
9106
+ organizations: Number($("#page-size-organizations").value),
9107
+ timeline: Number($("#page-size-timeline").value),
9108
+ outlines: Number($("#page-size-outlines").value),
9109
+ relationships: Number($("#page-size-relationships").value),
9110
+ reviews: Number($("#page-size-reviews").value),
7633
9111
  analysisTasks: Number($("#page-size-analysis-tasks").value),
7634
9112
  fileVersions: Number($("#page-size-file-versions").value)
7635
9113
  }
@@ -7638,6 +9116,7 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
7638
9116
  applyPlatformUiSettings(settings);
7639
9117
  characterListPage = 1;
7640
9118
  taskListPage = 1;
9119
+ Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
7641
9120
  $("#platform-ui-settings-dialog").close();
7642
9121
  toast("界面与分页设置已保存");
7643
9122
  } catch (error) {
@@ -7653,6 +9132,9 @@ $("#members-dialog").addEventListener("close", () => {
7653
9132
  memberDialogMembers = [];
7654
9133
  memberDialogDirectory = [];
7655
9134
  });
9135
+ $("#form-dialog").addEventListener("close", () => {
9136
+ if (relationshipPresenceId && !$("#form-dialog").open) setRelationshipPresence(null);
9137
+ });
7656
9138
  $("#member-user-select").addEventListener("change", () => selectMemberForConfiguration($("#member-user-select").value));
7657
9139
  $("#member-permission-form").querySelectorAll("[data-permission-preset]").forEach((button) => button.addEventListener("click", () => {
7658
9140
  $("#member-permission-grid").querySelectorAll("[data-member-permission]").forEach((select) => {
@@ -7682,13 +9164,30 @@ $("#platform-new-provider").addEventListener("click", () => openProviderDialog()
7682
9164
  $("#shelf-new-work").addEventListener("click", openWorkDialog);
7683
9165
  $("#welcome-new-work").addEventListener("click", () => state.work ? openChapterDialog() : openWorkDialog());
7684
9166
  $("#save-button").addEventListener("click", saveChapter);
9167
+ $("#chapter-delete-button").addEventListener("click", () => {
9168
+ if (state.chapter) void deleteChapter(state.chapter.id);
9169
+ });
7685
9170
  $("#chapter-edit-button").addEventListener("click", enterChapterEditMode);
7686
9171
  $("#tidy-blank-lines-button").addEventListener("click", tidyChapterBlankLines);
7687
9172
  $("#new-volume-button").addEventListener("click", () => openVolumeDialog());
9173
+ $("#chapter-batch-button").addEventListener("click", openChapterBatchDialog);
9174
+ $("#chapter-batch-close").addEventListener("click", () => $("#chapter-batch-dialog").close());
9175
+ $("#chapter-batch-cancel").addEventListener("click", () => $("#chapter-batch-dialog").close());
9176
+ $("#chapter-batch-action").addEventListener("change", updateChapterBatchControls);
9177
+ $("#chapter-batch-select-all").addEventListener("click", () => {
9178
+ state.work?.volumes.flatMap((volume) => volume.chapters).forEach((chapter) => chapterBatchSelectedIds.add(chapter.id));
9179
+ renderChapterBatchDialog();
9180
+ });
9181
+ $("#chapter-batch-clear").addEventListener("click", () => {
9182
+ chapterBatchSelectedIds.clear();
9183
+ renderChapterBatchDialog();
9184
+ });
9185
+ $("#chapter-batch-form").addEventListener("submit", submitChapterBatch);
7688
9186
  $("#insight-button").addEventListener("click", () => showChapterInsight().catch((error) => toast(error.message, "error")));
7689
9187
  $("#versions-button").addEventListener("click", showVersions);
7690
9188
  $("#versions-close").addEventListener("click", () => $("#versions-dialog").close());
7691
9189
  $("#import-history-close").addEventListener("click", () => $("#import-history-dialog").close());
9190
+ $("#chapter-recycle-bin-close").addEventListener("click", () => $("#chapter-recycle-bin-dialog").close());
7692
9191
  $("#entity-history-close").addEventListener("click", () => $("#entity-history-dialog").close());
7693
9192
  $("#ai-tool-call-close").addEventListener("click", () => $("#ai-tool-call-dialog").close());
7694
9193
  $("#setting-editor-back").addEventListener("click", () => { void closeEntityEditor(); });
@@ -7778,6 +9277,12 @@ $("#chapter-line-numbers-inner").addEventListener("contextmenu", (event) => {
7778
9277
  if (row) showLineCitationMenu(event, Number(row.dataset.lineIndex));
7779
9278
  });
7780
9279
  $("#add-line-citation").addEventListener("click", addSelectedLinesAsCitation);
9280
+ $("#add-line-annotation").addEventListener("click", () => createSelectedLineAnnotation("note"));
9281
+ $("#add-line-todo").addEventListener("click", () => createSelectedLineAnnotation("todo"));
9282
+ $("#chapter-annotations-button").addEventListener("click", () => openChapterAnnotationsDialog().catch((error) => toast(error.message, "error")));
9283
+ $("#chapter-annotations-close").addEventListener("click", () => $("#chapter-annotations-dialog").close());
9284
+ $("#chapter-annotations-done").addEventListener("click", () => $("#chapter-annotations-dialog").close());
9285
+ $("#chapter-annotations-refresh").addEventListener("click", () => loadChapterAnnotations().catch((error) => toast(error.message, "error")));
7781
9286
  $("#left-panel-toggle").addEventListener("click", () => {
7782
9287
  panelLayout.leftCollapsed = !panelLayout.leftCollapsed;
7783
9288
  applyPanelLayout(true);
@@ -7936,6 +9441,13 @@ $("#cover-file").addEventListener("change", async (event) => {
7936
9441
  }
7937
9442
  });
7938
9443
  $("#chapter-type-menu").addEventListener("click", async (event) => {
9444
+ const deleteButton = event.target.closest("[data-delete-chapter]");
9445
+ if (deleteButton) {
9446
+ const chapterId = state.contextChapterId;
9447
+ closeChapterTypeMenu();
9448
+ if (chapterId) await deleteChapter(chapterId);
9449
+ return;
9450
+ }
7939
9451
  const button = event.target.closest("[data-chapter-type]");
7940
9452
  const chapterId = state.contextChapterId;
7941
9453
  if (!button || !chapterId) return;
@@ -8059,6 +9571,26 @@ $(".quick-actions").addEventListener("click", (event) => {
8059
9571
  $("#top-search-button").addEventListener("click", () => {
8060
9572
  openSearchDialog().catch((error) => toast(error.message, "error"));
8061
9573
  });
9574
+ $("#background-task-button").addEventListener("click", () => {
9575
+ renderBackgroundTaskCenter();
9576
+ const dialog = $("#background-task-dialog");
9577
+ if (!dialog.open) dialog.showModal();
9578
+ void refreshBackgroundTaskCenter();
9579
+ });
9580
+ $("#background-task-dialog").addEventListener("close", scheduleBackgroundTaskCenterRefresh);
9581
+ $("#background-task-refresh").addEventListener("click", async () => {
9582
+ const button = $("#background-task-refresh");
9583
+ button.disabled = true;
9584
+ try {
9585
+ await refreshBackgroundTaskCenter();
9586
+ } finally {
9587
+ button.disabled = false;
9588
+ }
9589
+ });
9590
+ $("#background-task-open-analysis").addEventListener("click", () => {
9591
+ $("#background-task-dialog").close();
9592
+ showModule("tasks").catch((error) => toast(error.message, "error"));
9593
+ });
8062
9594
  $("#search-dialog-close").addEventListener("click", () => $("#search-dialog").close());
8063
9595
  $("#search-form").addEventListener("submit", async (event) => {
8064
9596
  event.preventDefault();