@musnows/scriverse 1.1.3 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -259,7 +259,8 @@ const state = {
259
259
  relationshipExpandedMap: null,
260
260
  collapsedVolumeIds: new Set(),
261
261
  collapsedRaceIds: new Set(),
262
- contextChapterId: null
262
+ contextChapterId: null,
263
+ contextVolumeId: null
263
264
  };
264
265
 
265
266
  let platformAiProtocolOptions = [];
@@ -1274,6 +1275,7 @@ let latestAiContextUsage = null;
1274
1275
  const aiConversationHistoryPageLimit = 20;
1275
1276
  let aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
1276
1277
  let workScopedUiGeneration = 0;
1278
+ let workDirectoryRevision = 0;
1277
1279
  let workSelectionRequestGeneration = 0;
1278
1280
  let initialWorkDetail = null;
1279
1281
  let initialReaderChapterRequest = null;
@@ -1975,6 +1977,8 @@ let chapterDraftLineIdState = null;
1975
1977
  let chapterBeforeInputState = null;
1976
1978
  let chapterSearchMatchIndex = -1;
1977
1979
  let chapterSelectionRequestId = 0;
1980
+ let chapterBodyAbort = null;
1981
+ let chapterContentReadyId = null;
1978
1982
  let chapterForeshadowReminderRequestId = 0;
1979
1983
  let chapterForeshadowReminders = [];
1980
1984
  let chapterForeshadowReminderIndex = 0;
@@ -2080,9 +2084,12 @@ function applyChapterEditorMode() {
2080
2084
  $("#chapter-content").readOnly = viewOnly;
2081
2085
  $("#chapter-title").setAttribute("aria-readonly", String(viewOnly));
2082
2086
  $("#chapter-content").setAttribute("aria-readonly", String(viewOnly));
2087
+ const waitingForChapter = chapterEditingLocked();
2083
2088
  editButton.classList.toggle("hidden", permissionBlocked || !state.chapter);
2084
2089
  editButton.classList.toggle("primary-button", chapterEditorReadOnly);
2085
2090
  editButton.classList.toggle("ghost-button", !chapterEditorReadOnly);
2091
+ editButton.disabled = waitingForChapter;
2092
+ editButton.setAttribute("aria-disabled", String(waitingForChapter));
2086
2093
  editButton.textContent = chapterEditorReadOnly ? "编辑" : "预览";
2087
2094
  editButton.setAttribute("aria-pressed", String(!chapterEditorReadOnly));
2088
2095
  editButton.setAttribute("aria-label", chapterEditorReadOnly ? "切换到编辑模式" : "切换到预览模式");
@@ -2093,6 +2100,8 @@ function applyChapterEditorMode() {
2093
2100
  }
2094
2101
 
2095
2102
  function enterChapterEditMode() {
2103
+ // 缓存或占位正文还没被本次请求确认前,编辑按钮不进入编辑。
2104
+ if (chapterEditingLocked()) return;
2096
2105
  if (!state.chapter || !canEditProse()) return;
2097
2106
  chapterEditorReadOnly = false;
2098
2107
  applyChapterEditorMode();
@@ -2103,6 +2112,7 @@ function enterChapterEditMode() {
2103
2112
  }
2104
2113
 
2105
2114
  function toggleChapterEditPreviewMode() {
2115
+ if (chapterEditingLocked()) return;
2106
2116
  if (!state.chapter || !canEditProse()) return;
2107
2117
  if (chapterEditorReadOnly) {
2108
2118
  enterChapterEditMode();
@@ -6533,6 +6543,7 @@ async function api(path, options = {}) {
6533
6543
  body: body && typeof body !== "string" ? JSON.stringify(body) : body
6534
6544
  });
6535
6545
  } catch (error) {
6546
+ if (error?.name === "AbortError") throw error;
6536
6547
  if (mobileOfflineRuntime.enabled) {
6537
6548
  const offlineSession = await mobileOfflineRuntime.activateOffline();
6538
6549
  if (offlineSession) return mobileOfflineRuntime.request(path, { method, body });
@@ -7658,6 +7669,7 @@ async function persistChapter({ automatic = false } = {}) {
7658
7669
  const saved = await request;
7659
7670
  if (state.work?.id !== workId || state.chapter?.id !== draft.chapterId) return saved.chapter;
7660
7671
  state.chapter = saved.chapter;
7672
+ chapterContentReadyId = saved.chapter.id;
7661
7673
  state.work = saved.work;
7662
7674
  resetChapterDraftLineIds(state.chapter);
7663
7675
  lastSavedChapterSnapshot = draft;
@@ -9372,6 +9384,9 @@ function renderShelf() {
9372
9384
 
9373
9385
  function resetWorkScopedUiCaches() {
9374
9386
  chapterSelectionRequestId += 1;
9387
+ chapterContentReadyId = null;
9388
+ chapterBodyAbort?.abort();
9389
+ chapterBodyAbort = null;
9375
9390
  clearChapterForeshadowReminders({ invalidateRequest: true });
9376
9391
  invalidateAiConversationNavigation("已切换作品");
9377
9392
  stopBackgroundTaskCenter();
@@ -9471,6 +9486,7 @@ async function selectWork(workId, preferredChapterId = null) {
9471
9486
  chapterEditorReadOnly = true;
9472
9487
  if (!canReadModule(state.module)) state.module = firstReadableUiModule(state.work) ?? "editor";
9473
9488
  applyWorkAccessMode();
9489
+ void refreshChapterTitleFormat();
9474
9490
  startBackgroundTaskCenter(nextWork.id);
9475
9491
  updateDocumentTitle(state.work);
9476
9492
  $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
@@ -9490,26 +9506,27 @@ async function loadVolumeChapters(volumeId) {
9490
9506
  if (existingRequest) return existingRequest;
9491
9507
  const workId = state.work.id;
9492
9508
  const generation = workScopedUiGeneration;
9509
+ const directoryRevision = workDirectoryRevision;
9493
9510
  volumeChapterLoadingIds.add(volumeId);
9494
9511
  renderTree([volumeId]);
9495
9512
  const request = (async () => {
9496
9513
  try {
9497
9514
  const chapters = await apiAllPages(`/api/volumes/${encodeURIComponent(volumeId)}/chapters`, 100);
9498
- if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
9515
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration || directoryRevision !== workDirectoryRevision) return;
9499
9516
  const volume = state.work.volumes.find((item) => item.id === volumeId);
9500
9517
  if (!volume) return;
9501
9518
  volume.chapters = chapters;
9502
9519
  volume.chapterCount = chapters.length;
9503
9520
  loadedVolumeChapterIds.add(volumeId);
9504
9521
  } catch (error) {
9505
- if (state.work?.id === workId && generation === workScopedUiGeneration) {
9522
+ if (state.work?.id === workId && generation === workScopedUiGeneration && directoryRevision === workDirectoryRevision) {
9506
9523
  toast(`加载分卷章节失败:${error.message}`, "error");
9507
9524
  }
9508
9525
  } finally {
9509
9526
  if (volumeChapterRequests.get(volumeId) === request) {
9510
9527
  volumeChapterLoadingIds.delete(volumeId);
9511
9528
  volumeChapterRequests.delete(volumeId);
9512
- if (state.work?.id === workId && generation === workScopedUiGeneration) renderTree([volumeId]);
9529
+ if (state.work?.id === workId && generation === workScopedUiGeneration && directoryRevision === workDirectoryRevision) renderTree([volumeId]);
9513
9530
  }
9514
9531
  }
9515
9532
  })();
@@ -9616,7 +9633,7 @@ function renderTree(volumeIds = null) {
9616
9633
  return `
9617
9634
  <div class="volume-node ${collapsed ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
9618
9635
  <div class="volume-title">
9619
- <button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${collapsed ? "false" : "true"}" title="左键展开或折叠;右键打开分卷详情;可将章节拖到这里追加"><span>${esc(volume.title)}</span><span class="volume-chapter-count"><span class="volume-chapter-count-number">${Number(volume.chapterCount ?? chapters.length)}</span><span class="volume-chapter-count-unit"> 章</span></span></button>
9636
+ <button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${collapsed ? "false" : "true"}" title="左键展开或折叠;右键打开分卷菜单;可将章节拖到这里追加"><span>${esc(volume.title)}</span><span class="volume-chapter-count"><span class="volume-chapter-count-number">${Number(volume.chapterCount ?? chapters.length)}</span><span class="volume-chapter-count-unit"> 章</span></span></button>
9620
9637
  ${proseEditable ? `<button class="ghost-button volume-detail-button" type="button" data-volume-detail="${esc(volume.id)}" aria-label="打开“${esc(volume.title)}”分卷详情" title="打开“${esc(volume.title)}”分卷详情"><svg class="volume-detail-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5"></circle><path d="M12 10.5v5.5M12 7.5h.01"></path></svg></button>` : ""}
9621
9638
  ${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>` : ""}
9622
9639
  </div>
@@ -9650,12 +9667,13 @@ function renderTree(volumeIds = null) {
9650
9667
  button.addEventListener("contextmenu", (event) => {
9651
9668
  if (!canEditProse()) return;
9652
9669
  event.preventDefault();
9653
- openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeToggle));
9670
+ openVolumeContextMenu(button.dataset.volumeToggle, event.clientX, event.clientY);
9654
9671
  });
9655
9672
  button.addEventListener("keydown", (event) => {
9656
9673
  if (!canEditProse() || (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))) return;
9657
9674
  event.preventDefault();
9658
- openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeToggle));
9675
+ const rect = button.getBoundingClientRect();
9676
+ openVolumeContextMenu(button.dataset.volumeToggle, rect.left, rect.bottom);
9659
9677
  });
9660
9678
  if (proseEditable) {
9661
9679
  button.addEventListener("dragover", (event) => {
@@ -9856,7 +9874,10 @@ async function submitChapterBatch(event) {
9856
9874
  state.work = await api(`/api/works/${encodeURIComponent(workId)}`);
9857
9875
  const currentEditorVisible = state.module === "editor";
9858
9876
  const currentStillExists = state.chapter && state.work.volumes.some((volume) => volume.chapters.some((chapter) => chapter.id === state.chapter.id));
9859
- if (state.chapter && currentStillExists) state.chapter = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}`);
9877
+ if (state.chapter && currentStillExists) {
9878
+ state.chapter = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}`);
9879
+ chapterContentReadyId = state.chapter.id;
9880
+ }
9860
9881
  if (state.chapter && !currentStillExists) {
9861
9882
  state.chapter = null;
9862
9883
  showWelcome(true);
@@ -9940,6 +9961,7 @@ function closeChapterTypeMenu() {
9940
9961
  }
9941
9962
 
9942
9963
  function openChapterTypeMenu(chapterId, clientX, clientY) {
9964
+ closeVolumeContextMenu();
9943
9965
  const chapter = state.work?.volumes.flatMap((volume) => volume.chapters).find((item) => item.id === chapterId);
9944
9966
  if (!chapter) return;
9945
9967
  state.contextChapterId = chapterId;
@@ -9947,9 +9969,10 @@ function openChapterTypeMenu(chapterId, clientX, clientY) {
9947
9969
  const canManageChapter = canEditProse();
9948
9970
  const canAddAiReference = canWritePermissionModule(state.work, "ai-chat");
9949
9971
  menu.querySelector("strong").textContent = `操作“${chapter.title}”`;
9950
- menu.querySelectorAll("[data-chapter-type], [data-delete-chapter]").forEach((button) => button.classList.toggle("hidden", !canManageChapter));
9972
+ menu.querySelectorAll("[data-chapter-type], [data-delete-chapter], [data-insert-chapter-before], [data-insert-chapter-after]").forEach((button) => button.classList.toggle("hidden", !canManageChapter));
9951
9973
  menu.querySelector("[data-add-chapter-ai-reference]")?.classList.toggle("hidden", !canAddAiReference);
9952
9974
  menu.querySelector("#chapter-type-ai-reference-separator")?.classList.toggle("hidden", !(canManageChapter && canAddAiReference));
9975
+ menu.querySelector("#chapter-insert-separator")?.classList.toggle("hidden", !canManageChapter);
9953
9976
  menu.querySelectorAll("[data-chapter-type]").forEach((button) => {
9954
9977
  button.classList.toggle("active", button.dataset.chapterType === (chapter.chapterType || "正文"));
9955
9978
  button.setAttribute("aria-checked", String(button.classList.contains("active")));
@@ -9990,6 +10013,7 @@ async function deleteChapter(chapterId) {
9990
10013
  const deletingSelectedChapter = state.chapter?.id === chapterId;
9991
10014
  const expectedVersionNo = deletingSelectedChapter ? state.chapter.versionNo : chapter.versionNo;
9992
10015
  await api(`/api/chapters/${chapterId}`, { method: "DELETE", body: { expectedVersionNo } });
10016
+ if (String(chapterContentReadyId ?? "") === String(chapterId)) chapterContentReadyId = null;
9993
10017
  const workId = state.work.id;
9994
10018
  state.work = await api(`/api/works/${workId}`);
9995
10019
  if (deletingSelectedChapter) {
@@ -10208,33 +10232,47 @@ async function resolveCurrentChapterForeshadowReminder() {
10208
10232
  }
10209
10233
  }
10210
10234
 
10211
- async function selectChapter(chapterId, { editMode = false } = {}) {
10212
- imWorkspace.close();
10213
- const workId = state.work?.id;
10214
- if (!workId) return false;
10215
- const selectionGeneration = ++chapterSelectionRequestGeneration;
10216
- const selectionRequestId = ++chapterSelectionRequestId;
10217
- clearChapterForeshadowReminders({ invalidateRequest: true });
10218
- if (state.chapter?.id !== chapterId && !(await confirmDiscardChanges("当前章节有未保存修改,仍要切换吗?"))) {
10219
- if (selectionRequestId === chapterSelectionRequestId) void loadChapterForeshadowReminders();
10220
- return false;
10235
+ function setChapterContentSkeleton(visible) {
10236
+ const skeleton = $("#chapter-content-skeleton");
10237
+ skeleton.classList.toggle("hidden", !visible);
10238
+ skeleton.setAttribute("aria-busy", String(visible));
10239
+ $("#editor-view").setAttribute("aria-busy", String(visible));
10240
+ if (!visible) return;
10241
+ $("#chapter-line-numbers-inner").replaceChildren();
10242
+ $("#chapter-whitespace-inner").replaceChildren();
10243
+ }
10244
+
10245
+ function chapterEditingLocked() {
10246
+ return !state.chapter || String(chapterContentReadyId ?? "") !== String(state.chapter.id);
10247
+ }
10248
+
10249
+ function directoryChapterEntry(chapterId) {
10250
+ for (const volume of state.work?.volumes ?? []) {
10251
+ const chapter = volume.chapters?.find((item) => String(item.id) === String(chapterId));
10252
+ if (chapter) return chapter;
10221
10253
  }
10222
- resetChapterSearchPanel();
10223
- dismissDeleteToasts();
10224
- if (selectionGeneration !== chapterSelectionRequestGeneration || selectionRequestId !== chapterSelectionRequestId || state.work?.id !== workId) return false;
10225
- cancelChapterAutoSave();
10226
- let selectedChapter = state.chapter;
10227
- if (state.chapter?.id !== chapterId) {
10228
- selectedChapter = await api(`/api/chapters/${encodeURIComponent(chapterId)}`);
10229
- if (selectionGeneration !== chapterSelectionRequestGeneration || selectionRequestId !== chapterSelectionRequestId || state.work?.id !== workId) return false;
10230
- if (String(selectedChapter?.id ?? "") !== String(chapterId) || String(selectedChapter?.workId ?? "") !== String(workId)) return false;
10231
- }
10232
- if (!selectedChapter || selectionGeneration !== chapterSelectionRequestGeneration || selectionRequestId !== chapterSelectionRequestId || state.work?.id !== workId) return false;
10233
- state.chapter = selectedChapter;
10234
- mergeChapterDirectoryEntry(state.chapter);
10235
- state.collapsedVolumeIds.delete(state.chapter.volumeId);
10236
- lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
10237
- chapterEditorReadOnly = !canEditProse() || !editMode;
10254
+ return null;
10255
+ }
10256
+
10257
+ function chapterRequestStillCurrent(workId, selectionGeneration, selectionRequestId) {
10258
+ return selectionGeneration === chapterSelectionRequestGeneration
10259
+ && selectionRequestId === chapterSelectionRequestId
10260
+ && state.work?.id === workId;
10261
+ }
10262
+
10263
+ function presentChapter(chapter, { editMode = false, contentReady = false, syncRoute = true } = {}) {
10264
+ state.chapter = chapter;
10265
+ if (chapter?.volumeId && typeof chapter.content === "string" && (contentReady || chapter.content.length > 0)) {
10266
+ mergeChapterDirectoryEntry(state.chapter);
10267
+ }
10268
+ if (chapter?.volumeId) state.collapsedVolumeIds.delete(chapter.volumeId);
10269
+ lastSavedChapterSnapshot = {
10270
+ chapterId: state.chapter.id,
10271
+ title: state.chapter.title ?? "",
10272
+ content: state.chapter.content ?? ""
10273
+ };
10274
+ chapterContentReadyId = contentReady ? state.chapter.id : null;
10275
+ chapterEditorReadOnly = !contentReady || !editMode;
10238
10276
  state.module = "editor";
10239
10277
  applyWorkAccessMode();
10240
10278
  markActiveModule("editor");
@@ -10242,22 +10280,77 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
10242
10280
  $("#module-view").classList.add("hidden");
10243
10281
  $("#editor-view").classList.remove("hidden");
10244
10282
  updateChapterPath();
10245
- $("#chapter-title").value = state.chapter.title;
10246
- $("#chapter-content").value = state.chapter.content;
10283
+ const titleInput = $("#chapter-title");
10284
+ const contentInput = $("#chapter-content");
10285
+ const nextTitle = state.chapter.title ?? "";
10286
+ const nextContent = state.chapter.content ?? "";
10287
+ if (titleInput.value !== nextTitle) titleInput.value = nextTitle;
10288
+ if (contentInput.value !== nextContent) contentInput.value = nextContent;
10247
10289
  resetChapterDraftLineIds(state.chapter);
10248
10290
  chapterAnnotationCounts = new Map();
10249
10291
  clearChapterLineSelection();
10250
10292
  scheduleChapterLineNumbers();
10251
- void loadChapterAnnotationCounts(state.chapter.id).catch((error) => {
10252
- if (String(state.chapter?.id ?? "") === String(chapterId)) toast("正文评论数量读取失败,请稍后重试", "error");
10253
- });
10254
10293
  dismissChapterInsightToast();
10255
10294
  updateChapterStats();
10295
+ if (!contentReady) $("#chapter-stats").textContent = "正在载入";
10256
10296
  if (!canEditProse()) setSaveState("正文只读");
10297
+ else if (!contentReady) setSaveState("正在同步正文");
10257
10298
  else if (chapterEditorReadOnly) setSaveState("阅读模式");
10258
10299
  else setSaveState("已保存");
10300
+ setChapterContentSkeleton(!contentReady);
10259
10301
  syncChapterTreeSelection();
10260
- replacePageRoute({ view: "editor", workId: state.work.id, chapterId: state.chapter.id });
10302
+ if (syncRoute) replacePageRoute({ view: "editor", workId: state.work.id, chapterId: state.chapter.id });
10303
+ }
10304
+
10305
+ async function selectChapter(chapterId, { editMode = false } = {}) {
10306
+ imWorkspace.close();
10307
+ const workId = state.work?.id;
10308
+ if (!workId) return false;
10309
+ const sameChapter = String(state.chapter?.id ?? "") === String(chapterId);
10310
+ if (sameChapter && chapterEditingLocked() && chapterBodyAbort && !chapterBodyAbort.signal.aborted) return true;
10311
+ const selectionGeneration = ++chapterSelectionRequestGeneration;
10312
+ const selectionRequestId = ++chapterSelectionRequestId;
10313
+ if (!sameChapter) clearChapterForeshadowReminders({ invalidateRequest: true });
10314
+ if (!sameChapter && !(await confirmDiscardChanges("当前章节有未保存修改,仍要切换吗?"))) {
10315
+ if (selectionRequestId === chapterSelectionRequestId) void loadChapterForeshadowReminders();
10316
+ return false;
10317
+ }
10318
+ resetChapterSearchPanel();
10319
+ dismissDeleteToasts();
10320
+ if (!chapterRequestStillCurrent(workId, selectionGeneration, selectionRequestId)) return false;
10321
+ cancelChapterAutoSave();
10322
+ if (sameChapter && String(chapterContentReadyId ?? "") === String(chapterId) && state.chapter) {
10323
+ presentChapter(state.chapter, { editMode, contentReady: true });
10324
+ syncChapterTreeSelection();
10325
+ return true;
10326
+ }
10327
+ const directoryEntry = directoryChapterEntry(chapterId);
10328
+ const previewChapter = directoryEntry
10329
+ ? { ...directoryEntry, content: "", lineIds: [] }
10330
+ : { id: chapterId, workId, volumeId: "", title: "", content: "", wordCount: 0, versionNo: 0, chapterType: "正文", lineIds: [] };
10331
+ presentChapter(previewChapter, { editMode: false, contentReady: false });
10332
+ syncChapterTreeSelection();
10333
+ chapterBodyAbort?.abort();
10334
+ const chapterBodyController = new AbortController();
10335
+ chapterBodyAbort = chapterBodyController;
10336
+ let selectedChapter = null;
10337
+ try {
10338
+ selectedChapter = await api(`/api/chapters/${encodeURIComponent(chapterId)}`, { signal: chapterBodyController.signal });
10339
+ } catch (error) {
10340
+ if (chapterBodyAbort === chapterBodyController) chapterBodyAbort = null;
10341
+ if (error?.name === "AbortError" || !chapterRequestStillCurrent(workId, selectionGeneration, selectionRequestId)) return false;
10342
+ if (String(state.chapter?.id ?? "") === String(chapterId)) setSaveState("正文载入失败", true);
10343
+ toast(error.message || "章节读取失败", "error");
10344
+ return false;
10345
+ }
10346
+ if (chapterBodyAbort === chapterBodyController) chapterBodyAbort = null;
10347
+ if (!chapterRequestStillCurrent(workId, selectionGeneration, selectionRequestId)) return false;
10348
+ if (String(selectedChapter?.id ?? "") !== String(chapterId) || String(selectedChapter?.workId ?? "") !== String(workId)) return false;
10349
+ presentChapter(selectedChapter, { editMode, contentReady: true, syncRoute: false });
10350
+ syncChapterTreeSelection();
10351
+ void loadChapterAnnotationCounts(state.chapter.id).catch(() => {
10352
+ if (String(state.chapter?.id ?? "") === String(chapterId)) toast("正文评论数量读取失败,请稍后重试", "error");
10353
+ });
10261
10354
  void loadChapterForeshadowReminders();
10262
10355
  return true;
10263
10356
  }
@@ -10829,6 +10922,8 @@ async function showModule(module) {
10829
10922
  state.module = module;
10830
10923
  if (module !== "editor") {
10831
10924
  chapterSelectionRequestId += 1;
10925
+ chapterBodyAbort?.abort();
10926
+ chapterBodyAbort = null;
10832
10927
  clearChapterForeshadowReminders({ invalidateRequest: true });
10833
10928
  }
10834
10929
  if (module !== "tasks") stopTaskProgressRefresh();
@@ -11890,12 +11985,13 @@ async function renderCharacters(page = characterListPage) {
11890
11985
  || characterFilters.genderValues.length > 0
11891
11986
  || characterFilters.deathState !== "all";
11892
11987
  const pageSize = pageSizeFor("characters");
11988
+ const needsCharacterFilterCatalog = hasCharacterFilters || characterFiltersPanelOpen;
11893
11989
  const [characterSource, races, organizations] = await Promise.all([
11894
11990
  hasCharacterFilters
11895
11991
  ? moduleApiAllPages("characters", `/api/works/${state.work.id}/characters`)
11896
11992
  : moduleApiPage("characters", `/api/works/${state.work.id}/characters`, page, pageSize),
11897
- canReadModule("races") ? moduleApi("characters", `/api/works/${state.work.id}/races`) : Promise.resolve([]),
11898
- canReadModule("organizations") ? moduleApiAllPages("characters", `/api/works/${state.work.id}/organizations`) : Promise.resolve([])
11993
+ needsCharacterFilterCatalog && canReadModule("races") ? moduleApi("characters", `/api/works/${state.work.id}/races`) : Promise.resolve([]),
11994
+ needsCharacterFilterCatalog && canReadModule("organizations") ? moduleApiAllPages("characters", `/api/works/${state.work.id}/organizations`) : Promise.resolve([])
11899
11995
  ]);
11900
11996
  const characterPage = hasCharacterFilters
11901
11997
  ? paginateCharacters(filterCharacters(characterSource, characterFilters), page, pageSize)
@@ -11903,7 +11999,7 @@ async function renderCharacters(page = characterListPage) {
11903
11999
  if (!characterPage.items.length && page > 1) return renderCharacters(page - 1);
11904
12000
  characterListPage = characterPage.page;
11905
12001
  const pageCharacters = characterPage.items;
11906
- [state.races, state.organizations] = [races, organizations];
12002
+ if (needsCharacterFilterCatalog) [state.races, state.organizations] = [races, organizations];
11907
12003
  mountModuleCount(characterPage.total);
11908
12004
  const layout = readModuleLayout();
11909
12005
  const characterActions = (item) => `${characterPinButton(item)}${characterFavoriteButton(item)}${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}`;
@@ -14157,10 +14253,7 @@ function updateBackgroundTaskCenterVisibility() {
14157
14253
  $("#background-task-count")?.classList.add("hidden");
14158
14254
  return;
14159
14255
  }
14160
- const activityCount = backgroundTaskActivityCount(
14161
- backgroundTaskCenterSnapshot.taskPage,
14162
- backgroundTaskCenterSnapshot.relationshipIndex
14163
- ) + (productUpdateChecking ? 1 : 0);
14256
+ const activityCount = backgroundTaskActivityCount(backgroundTaskCenterSnapshot.taskPage) + (productUpdateChecking ? 1 : 0);
14164
14257
  const badge = $("#background-task-count");
14165
14258
  badge.textContent = activityCount > 99 ? "99+" : String(activityCount);
14166
14259
  badge.classList.toggle("hidden", activityCount === 0);
@@ -14244,7 +14337,7 @@ function backgroundIndexMarkup(relationshipIndex, error) {
14244
14337
  const editable = canEditModule("ai-settings");
14245
14338
  return `<section class="background-task-section">
14246
14339
  <div class="background-task-section-heading">
14247
- <div><strong>人物关系拼音索引</strong><small>可在任何模块查看增量同步状态</small></div>
14340
+ <div><strong>人物关系拼音索引</strong><small>打开本窗口时显示当前索引状态</small></div>
14248
14341
  <div class="background-index-actions ${editable ? "" : "hidden"}">
14249
14342
  <button class="primary-button" type="button" data-background-index-action="sync">同步增量队列</button>
14250
14343
  <button class="ghost-button" type="button" data-background-index-action="rebuild">完整重建</button>
@@ -14315,10 +14408,7 @@ function scheduleBackgroundTaskCenterRefresh() {
14315
14408
  if (backgroundTaskCenterTimer !== null) window.clearTimeout(backgroundTaskCenterTimer);
14316
14409
  backgroundTaskCenterTimer = null;
14317
14410
  if (!backgroundTaskCenterWorkId) return;
14318
- const activityCount = backgroundTaskActivityCount(
14319
- backgroundTaskCenterSnapshot.taskPage,
14320
- backgroundTaskCenterSnapshot.relationshipIndex
14321
- );
14411
+ const activityCount = backgroundTaskActivityCount(backgroundTaskCenterSnapshot.taskPage);
14322
14412
  const delay = backgroundTaskPollDelay(activityCount, Boolean($("#background-task-dialog")?.open));
14323
14413
  backgroundTaskCenterTimer = window.setTimeout(() => {
14324
14414
  backgroundTaskCenterTimer = null;
@@ -14332,8 +14422,9 @@ async function refreshBackgroundTaskCenter({ announce = true } = {}) {
14332
14422
  if (backgroundTaskCenterTimer !== null) window.clearTimeout(backgroundTaskCenterTimer);
14333
14423
  backgroundTaskCenterTimer = null;
14334
14424
  const requestId = ++backgroundTaskCenterRequest;
14425
+ const dialogOpen = Boolean($("#background-task-dialog")?.open);
14335
14426
  const readTasks = canReadModule("tasks");
14336
- const readIndex = canReadModule("ai-settings");
14427
+ const readIndex = dialogOpen && canReadModule("ai-settings");
14337
14428
  const [taskResult, indexResult] = await Promise.all([
14338
14429
  readTasks
14339
14430
  ? api(`/api/works/${encodeURIComponent(workId)}/tasks?page=1&limit=30`)
@@ -16285,19 +16376,159 @@ async function deleteWork(work) {
16285
16376
  }
16286
16377
  }
16287
16378
 
16379
+ const chapterTitleFormatLabels = {
16380
+ off: "不自动编号",
16381
+ auto: "自动识别",
16382
+ dot: "N. 标题",
16383
+ "chapter-arabic": "第N章 标题",
16384
+ "chapter-chinese": "第N章(中文数字)",
16385
+ enumeration: "N、标题",
16386
+ english: "Chapter N:"
16387
+ };
16388
+ let chapterTitleFormatStatus = null;
16389
+ let chapterTitleOnFocus = "";
16390
+
16391
+ function chapterTitleHasNumber(title) {
16392
+ const source = String(title ?? "").trim();
16393
+ return /^(?:第\s*(?:[0-90-9]+|[〇零一二三四五六七八九十百千万两]+)\s*章|chap(?:ter)?\.?\s*[0-90-9]+|[0-90-9]+\s*[..、])/iu.test(source);
16394
+ }
16395
+
16396
+ function currentChapterTitleFormatStatus() {
16397
+ if (!chapterTitleFormatStatus || chapterTitleFormatStatus.workId !== state.work?.id) return null;
16398
+ return chapterTitleFormatStatus;
16399
+ }
16400
+
16401
+ function currentChapterTitlePreference() {
16402
+ const preference = state.work?.chapterTitleFormat || currentChapterTitleFormatStatus()?.preference || "off";
16403
+ return chapterTitleFormatLabels[preference] ? preference : "off";
16404
+ }
16405
+
16406
+ function describeChapterTitleFormatNote(preference, status) {
16407
+ if (!state.work) return "选择作品后,可以识别已有标题格式,或指定一种常见格式。";
16408
+ if (preference === "off") return "关闭后,新建和插入章节保持手写标题。需要整理某一卷时,再打开该卷的标题编号并重新编号。";
16409
+ if (preference === "auto") {
16410
+ if (status?.detected) return `已识别为「${chapterTitleFormatLabels[status.detected]}」。插入章节时按此格式编号,并只后移本卷后面的序号。`;
16411
+ return "现有标题还没有压倒性的同一种格式。请选择一种常见格式,或重新编号本卷。";
16412
+ }
16413
+ const label = chapterTitleFormatLabels[preference] ?? preference;
16414
+ return `新插入的章节使用「${label}」。切换格式不会改写已有标题;只有重新编号本卷才会改写这一卷。`;
16415
+ }
16416
+
16417
+ function syncVolumeTitleNumberingNote() {
16418
+ const note = $("#volume-title-number-note");
16419
+ const select = $("#dialog-fields select[name='chapterTitleFormat']");
16420
+ if (!note || !select) return;
16421
+ const preference = currentChapterTitlePreference();
16422
+ if (document.activeElement !== select && chapterTitleFormatLabels[preference]) select.value = preference;
16423
+ note.textContent = describeChapterTitleFormatNote(select.value || preference, currentChapterTitleFormatStatus());
16424
+ }
16425
+
16426
+ async function refreshChapterTitleFormat() {
16427
+ const workId = state.work?.id;
16428
+ if (!workId) {
16429
+ chapterTitleFormatStatus = null;
16430
+ syncVolumeTitleNumberingNote();
16431
+ return;
16432
+ }
16433
+ const requestId = ++refreshChapterTitleFormat.requestId;
16434
+ try {
16435
+ const status = await api(`/api/works/${encodeURIComponent(workId)}/chapter-title-format`);
16436
+ if (requestId !== refreshChapterTitleFormat.requestId || state.work?.id !== workId) return;
16437
+ chapterTitleFormatStatus = { ...status, workId };
16438
+ state.work.chapterTitleFormat = status.preference;
16439
+ syncVolumeTitleNumberingNote();
16440
+ } catch (error) {
16441
+ if (requestId === refreshChapterTitleFormat.requestId && state.work?.id === workId) syncVolumeTitleNumberingNote();
16442
+ }
16443
+ }
16444
+ refreshChapterTitleFormat.requestId = 0;
16445
+
16446
+ async function reloadWorkDirectory(workId) {
16447
+ const revision = ++workDirectoryRevision;
16448
+ const directory = await api(`/api/works/${encodeURIComponent(workId)}`);
16449
+ if (state.work?.id !== workId || revision !== workDirectoryRevision) return null;
16450
+ const collapsed = state.collapsedVolumeIds;
16451
+ state.work = {
16452
+ ...directory,
16453
+ volumes: (directory.volumes ?? []).map((volume) => ({
16454
+ ...volume,
16455
+ chapters: Array.isArray(volume.chapters) ? volume.chapters : [],
16456
+ chapterCount: Array.isArray(volume.chapters) ? volume.chapters.length : Number(volume.chapterCount ?? 0)
16457
+ }))
16458
+ };
16459
+ state.collapsedVolumeIds = collapsed;
16460
+ loadedVolumeChapterIds.clear();
16461
+ for (const volume of state.work.volumes) loadedVolumeChapterIds.add(volume.id);
16462
+ renderTree();
16463
+ if (state.chapter) {
16464
+ const fresh = state.work.volumes.flatMap((volume) => volume.chapters).find((item) => item.id === state.chapter.id);
16465
+ // 插入或重排会改写已打开章节的序号。目录已经是新标题,但内存中的当前章节仍可能是旧标题;先对齐再同步,避免把旧序号写回目录节点。
16466
+ if (fresh && fresh.title !== state.chapter.title) {
16467
+ const previousTitle = state.chapter.title;
16468
+ state.chapter = {
16469
+ ...state.chapter,
16470
+ title: fresh.title,
16471
+ sortOrder: fresh.sortOrder,
16472
+ versionNo: fresh.versionNo,
16473
+ wordCount: fresh.wordCount,
16474
+ chapterType: fresh.chapterType ?? state.chapter.chapterType
16475
+ };
16476
+ const titleInput = $("#chapter-title");
16477
+ if (titleInput.value.trim() === previousTitle) {
16478
+ titleInput.value = fresh.title;
16479
+ chapterTitleOnFocus = fresh.title;
16480
+ if (lastSavedChapterSnapshot?.chapterId === state.chapter.id) {
16481
+ lastSavedChapterSnapshot = { ...lastSavedChapterSnapshot, title: fresh.title };
16482
+ }
16483
+ }
16484
+ }
16485
+ syncChapterTreeSelection();
16486
+ }
16487
+ return state.work;
16488
+ }
16489
+
16490
+ function rememberWorkVersion(work) {
16491
+ if (!work?.id) return;
16492
+ const listed = state.works.find((item) => item.id === work.id);
16493
+ if (listed) listed.versionNo = work.versionNo;
16494
+ }
16495
+
16288
16496
  async function openChapterDialog(volumeId = null) {
16497
+ const insertAfterChapterId = openChapterDialog.insertAfterChapterId ?? null;
16498
+ const insertBeforeChapterId = openChapterDialog.insertBeforeChapterId ?? null;
16499
+ openChapterDialog.insertAfterChapterId = null;
16500
+ openChapterDialog.insertBeforeChapterId = null;
16289
16501
  if (!state.work) return openWorkDialog();
16290
16502
  if (!canEditProse()) return toast("当前权限只能编辑设定资料,正文为只读", "error");
16291
16503
  if (!state.work.volumes.length) {
16292
16504
  await api(`/api/works/${state.work.id}/volumes`, { method: "POST", body: { title: "正文", kind: "main" } });
16293
- state.work = await api(`/api/works/${state.work.id}`);
16294
- renderTree();
16295
- }
16296
- const selectedVolumeId = state.work.volumes.some((volume) => volume.id === volumeId) ? volumeId : state.work.volumes[0].id;
16297
- openDialog("新建章节", field("title", "章节标题") + field("volumeId", "所属卷", "select", selectedVolumeId, state.work.volumes.map((volume) => [volume.id, volume.title])) + field("chapterType", "章节类型", "select", "正文", chapterTypes.map((value) => [value, value])), async (form) => {
16298
- const chapter = await api(`/api/works/${state.work.id}/chapters`, { method: "POST", body: { title: form.get("title"), volumeId: form.get("volumeId"), chapterType: form.get("chapterType"), content: "" } });
16299
- state.work = await api(`/api/works/${state.work.id}`);
16505
+ await reloadWorkDirectory(state.work.id);
16506
+ }
16507
+ const anchorId = insertBeforeChapterId ?? insertAfterChapterId;
16508
+ const anchorLocation = anchorId ? findChapterLocation(anchorId) : null;
16509
+ const anchor = anchorLocation?.chapter ?? null;
16510
+ const insertBefore = Boolean(anchor && insertBeforeChapterId);
16511
+ const preferredVolumeId = anchorLocation?.volume.id ?? volumeId;
16512
+ const selectedVolumeId = state.work.volumes.some((volume) => volume.id === preferredVolumeId) ? preferredVolumeId : state.work.volumes[0].id;
16513
+ const volumeField = anchor
16514
+ ? `<input type="hidden" name="volumeId" value="${esc(selectedVolumeId)}"><p class="form-field-note">插入到“${esc(anchor.title)}”${insertBefore ? "之前" : "之后"}。开启标题编号时,只为本卷补序号并后移本卷后续章节,其他分卷保持不变。</p>`
16515
+ : field("volumeId", "所属卷", "select", selectedVolumeId, state.work.volumes.map((volume) => [volume.id, volume.title]));
16516
+ openDialog(anchor ? "插入章节" : "新建章节", field("title", "章节标题") + volumeField + field("chapterType", "章节类型", "select", "正文", chapterTypes.map((value) => [value, value])), async (form) => {
16517
+ const preference = currentChapterTitlePreference();
16518
+ const body = {
16519
+ title: form.get("title"),
16520
+ volumeId: form.get("volumeId"),
16521
+ chapterType: form.get("chapterType"),
16522
+ content: ""
16523
+ };
16524
+ if (anchor && insertBefore) body.insertBeforeChapterId = anchor.id;
16525
+ else if (anchor) body.insertAfterChapterId = anchor.id;
16526
+ if (preference !== "off") body.numberTitle = true;
16527
+ const chapter = await api(`/api/works/${state.work.id}/chapters`, { method: "POST", body });
16528
+ await reloadWorkDirectory(state.work.id);
16300
16529
  await selectChapter(chapter.id, { editMode: true });
16530
+ if (state.chapter?.id === chapter.id) $("#chapter-title").value = state.chapter.title;
16531
+ void refreshChapterTitleFormat();
16301
16532
  });
16302
16533
  }
16303
16534
 
@@ -16340,6 +16571,145 @@ function openVolumeDialog(item) {
16340
16571
  });
16341
16572
  }
16342
16573
 
16574
+ function closeVolumeContextMenu() {
16575
+ state.contextVolumeId = null;
16576
+ $("#volume-context-menu")?.classList.add("hidden");
16577
+ }
16578
+
16579
+ function openVolumeContextMenu(volumeId, clientX, clientY) {
16580
+ const volume = state.work?.volumes.find((item) => item.id === volumeId);
16581
+ if (!volume || !canEditProse()) return;
16582
+ closeChapterTypeMenu();
16583
+ state.contextVolumeId = volumeId;
16584
+ const menu = $("#volume-context-menu");
16585
+ menu.querySelector("strong").textContent = `操作“${volume.title}”`;
16586
+ menu.classList.remove("hidden");
16587
+ const rect = menu.getBoundingClientRect();
16588
+ menu.style.left = `${Math.max(8, Math.min(clientX, window.innerWidth - rect.width - 8))}px`;
16589
+ menu.style.top = `${Math.max(8, Math.min(clientY, window.innerHeight - rect.height - 8))}px`;
16590
+ }
16591
+
16592
+ let chapterTitleFormatSave = null;
16593
+
16594
+ async function saveChapterTitleFormat(next, select = null) {
16595
+ const run = () => saveChapterTitleFormatNow(next, select);
16596
+ const pending = (chapterTitleFormatSave ?? Promise.resolve()).then(run, run);
16597
+ chapterTitleFormatSave = pending.finally(() => {
16598
+ if (chapterTitleFormatSave === pending) chapterTitleFormatSave = null;
16599
+ });
16600
+ return chapterTitleFormatSave;
16601
+ }
16602
+
16603
+ async function saveChapterTitleFormatNow(next, select = null) {
16604
+ const previous = currentChapterTitlePreference();
16605
+ if (!state.work || !chapterTitleFormatLabels[next] || next === previous) {
16606
+ syncVolumeTitleNumberingNote();
16607
+ return next === previous;
16608
+ }
16609
+ if (!canEditProse()) {
16610
+ if (select) select.value = previous;
16611
+ toast("当前权限不能修改正文目录", "error");
16612
+ return false;
16613
+ }
16614
+ if (select) select.disabled = true;
16615
+ try {
16616
+ const status = await api(`/api/works/${encodeURIComponent(state.work.id)}/chapter-title-format`, {
16617
+ method: "PATCH",
16618
+ body: { chapterTitleFormat: next, expectedVersionNo: state.work.versionNo }
16619
+ });
16620
+ if (state.work?.id) {
16621
+ state.work.chapterTitleFormat = status.preference;
16622
+ state.work.versionNo = status.versionNo;
16623
+ rememberWorkVersion(state.work);
16624
+ chapterTitleFormatStatus = { ...status, workId: state.work.id };
16625
+ }
16626
+ syncVolumeTitleNumberingNote();
16627
+ toast(next === "off"
16628
+ ? "已关闭自动编号,已有章节标题保持不变"
16629
+ : `标题编号已改为「${chapterTitleFormatLabels[next]}」。已有章节不会立刻改写,可在该卷的标题编号里重新编号。`);
16630
+ return true;
16631
+ } catch (error) {
16632
+ if (select) select.value = previous;
16633
+ toast(error.message, "error");
16634
+ return false;
16635
+ } finally {
16636
+ if (select?.isConnected) select.disabled = false;
16637
+ syncVolumeTitleNumberingNote();
16638
+ }
16639
+ }
16640
+
16641
+ function openVolumeTitleNumberingDialog(item) {
16642
+ if (!state.work || !item || !canEditProse()) return toast("当前权限不能修改分卷", "error");
16643
+ const preference = currentChapterTitlePreference();
16644
+ openDialog("标题编号",
16645
+ field("chapterTitleFormat", "编号格式", "select", preference, Object.entries(chapterTitleFormatLabels)) +
16646
+ `<p id="volume-title-number-note" class="form-field-note">${esc(describeChapterTitleFormatNote(preference, currentChapterTitleFormatStatus()))}</p>` +
16647
+ `<p class="form-field-note">编号格式供这部作品的新章节使用,切换后不会改写已有标题。重新编号只改写“${esc(item.title)}”,其他分卷保持不变。插入章节时,也只给所在分卷补序号并后移该卷后续章节。</p>` +
16648
+ `<div class="entity-dialog-management-actions"><button class="ghost-button" type="button" data-volume-title-renumber>重新编号本卷</button></div>`,
16649
+ async () => {},
16650
+ "分卷",
16651
+ { submitLabel: "完成", meta: item.title }
16652
+ );
16653
+ const select = $("#dialog-fields select[name='chapterTitleFormat']");
16654
+ select?.addEventListener("change", () => {
16655
+ void saveChapterTitleFormat(select.value, select);
16656
+ });
16657
+ $("#dialog-fields").querySelector("[data-volume-title-renumber]")?.addEventListener("click", async () => {
16658
+ if (select && select.value !== currentChapterTitlePreference()) {
16659
+ const saved = await saveChapterTitleFormat(select.value, select);
16660
+ if (!saved) return;
16661
+ }
16662
+ await renumberVolumeChapterTitles(item);
16663
+ });
16664
+ void refreshChapterTitleFormat();
16665
+ }
16666
+
16667
+ async function renumberVolumeChapterTitles(item) {
16668
+ if (!state.work || !item || !canEditProse()) return toast("当前权限不能修改分卷", "error");
16669
+ const preference = $("#dialog-fields select[name='chapterTitleFormat']")?.value || currentChapterTitlePreference();
16670
+ const status = currentChapterTitleFormatStatus();
16671
+ if (preference === "off" || (preference === "auto" && status && !status.resolved)) {
16672
+ toast("请先选择标题编号格式,或使用自动识别", "error");
16673
+ return;
16674
+ }
16675
+ $("#form-dialog").close();
16676
+ const confirmed = await confirmToast(`按当前标题编号格式,重新编排分卷“${item.title}”里的章节序号。只影响这一卷,序号以外的标题文字会保留,其他分卷不变。`, {
16677
+ title: "重新编号本卷",
16678
+ confirmLabel: "重新编号"
16679
+ });
16680
+ if (!confirmed) return openVolumeTitleNumberingDialog(item);
16681
+ try {
16682
+ const result = await api(`/api/volumes/${encodeURIComponent(item.id)}/renumber-titles`, { method: "POST", body: {} });
16683
+ const workId = state.work.id;
16684
+ const selectedId = state.chapter?.id ?? null;
16685
+ const selectedVolumeId = state.chapter?.volumeId ?? null;
16686
+ const contentBefore = selectedId ? $("#chapter-content").value : "";
16687
+ await reloadWorkDirectory(workId);
16688
+ if (selectedId && selectedVolumeId === item.id && state.work?.id === workId) {
16689
+ const fresh = await api(`/api/chapters/${encodeURIComponent(selectedId)}`);
16690
+ if (state.chapter?.id === selectedId) {
16691
+ const contentDirty = Boolean(lastSavedChapterSnapshot) && contentBefore !== lastSavedChapterSnapshot.content;
16692
+ state.chapter = { ...state.chapter, title: fresh.title, versionNo: fresh.versionNo, updatedAt: fresh.updatedAt };
16693
+ $("#chapter-title").value = fresh.title;
16694
+ lastSavedChapterSnapshot = {
16695
+ chapterId: selectedId,
16696
+ title: fresh.title,
16697
+ content: contentDirty ? lastSavedChapterSnapshot.content : state.chapter.content
16698
+ };
16699
+ updateChapterPath();
16700
+ syncChapterTreeSelection();
16701
+ if (contentDirty) scheduleChapterAutoSave(120);
16702
+ }
16703
+ }
16704
+ const label = chapterTitleFormatLabels[result.format] ?? result.format;
16705
+ toast(`已按「${label}」重新编号分卷“${item.title}”,从 ${result.startAt} 起更新 ${result.updated} 章`);
16706
+ void refreshChapterTitleFormat();
16707
+ } catch (error) {
16708
+ toast(error.message, "error");
16709
+ openVolumeTitleNumberingDialog(item);
16710
+ }
16711
+ }
16712
+
16343
16713
  async function deleteVolume(item) {
16344
16714
  if (!state.work || !item || !canEditProse()) return toast("当前权限不能删除分卷", "error");
16345
16715
  $("#form-dialog").close();
@@ -20087,6 +20457,7 @@ async function showVersions() {
20087
20457
  }
20088
20458
  try {
20089
20459
  state.chapter = await api(`/api/chapters/${state.chapter.id}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.restoreVersion) } });
20460
+ chapterContentReadyId = state.chapter.id;
20090
20461
  resetChapterDraftLineIds(state.chapter);
20091
20462
  lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
20092
20463
  $("#chapter-title").value = state.chapter.title;
@@ -21170,8 +21541,8 @@ $("#login-form").addEventListener("submit", async (event) => {
21170
21541
  window.location.reload();
21171
21542
  } catch (error) {
21172
21543
  $("#auth-error").textContent = error.message;
21173
- // 仅在验证码已显示时自动换一张,未加载过则保持默认隐藏状态
21174
- if (!$("#login-captcha-image").hidden) refreshAuthCaptcha("login").catch(() => {});
21544
+ // 答案错误时保留同一张图;过期、已核销或其他失败才换一张
21545
+ if (error?.code !== "CAPTCHA_INCORRECT" && !$("#login-captcha-image").hidden) refreshAuthCaptcha("login").catch(() => {});
21175
21546
  }
21176
21547
  });
21177
21548
  $("#register-form").addEventListener("submit", async (event) => {
@@ -21199,8 +21570,8 @@ $("#register-form").addEventListener("submit", async (event) => {
21199
21570
  window.location.reload();
21200
21571
  } catch (error) {
21201
21572
  $("#auth-error").textContent = error.message;
21202
- // 仅在验证码已显示时自动换一张,未加载过则保持默认隐藏状态
21203
- if (!$("#register-captcha-image").hidden) refreshAuthCaptcha("register").catch(() => {});
21573
+ // 答案错误时保留同一张图;过期、已核销或其他失败才换一张
21574
+ if (error?.code !== "CAPTCHA_INCORRECT" && !$("#register-captcha-image").hidden) refreshAuthCaptcha("register").catch(() => {});
21204
21575
  }
21205
21576
  });
21206
21577
  $("#settings-return").addEventListener("click", () => returnFromSettings().catch((error) => toast(error.message, "error")));
@@ -21567,6 +21938,12 @@ $("#appearance-form").addEventListener("submit", (event) => {
21567
21938
  $("#appearance-dialog").close();
21568
21939
  toast(persisted ? "显示设置已保存" : "显示设置已应用,但当前浏览器无法保存偏好", persisted ? "info" : "error");
21569
21940
  });
21941
+ $("#chapter-title").addEventListener("focus", () => {
21942
+ chapterTitleOnFocus = $("#chapter-title").value.trim();
21943
+ });
21944
+ $("#chapter-title").addEventListener("blur", () => {
21945
+ void applyEditedChapterTitleNumber();
21946
+ });
21570
21947
  $("#chapter-title").addEventListener("input", () => scheduleChapterAutoSave());
21571
21948
  $("#chapter-content").addEventListener("beforeinput", (event) => {
21572
21949
  if (!state.chapter) return;
@@ -22063,6 +22440,59 @@ $("#cover-file").addEventListener("change", async (event) => {
22063
22440
  event.target.value = "";
22064
22441
  }
22065
22442
  });
22443
+ async function applyEditedChapterTitleNumber() {
22444
+ if (!state.chapter || !canEditProse() || chapterEditorReadOnly) return;
22445
+ const preference = currentChapterTitlePreference();
22446
+ if (preference === "off") return;
22447
+ if (chapterSaveInFlight) await chapterSaveInFlight;
22448
+ if (!state.chapter || chapterEditorReadOnly) return;
22449
+ const title = $("#chapter-title").value.trim();
22450
+ const titleAtFocus = chapterTitleOnFocus;
22451
+ if (!title || chapterTitleHasNumber(title)) return;
22452
+ if (title === titleAtFocus && title === lastSavedChapterSnapshot?.title) return;
22453
+ cancelChapterAutoSave();
22454
+ const chapterId = state.chapter.id;
22455
+ const workId = state.work.id;
22456
+ const contentBefore = $("#chapter-content").value;
22457
+ try {
22458
+ const chapter = await api(`/api/chapters/${encodeURIComponent(chapterId)}/apply-title-number`, {
22459
+ method: "POST",
22460
+ body: { title, expectedVersionNo: state.chapter.versionNo }
22461
+ });
22462
+ if (state.work?.id !== workId) return;
22463
+ await reloadWorkDirectory(workId);
22464
+ if (state.chapter?.id !== chapterId || state.work?.id !== workId) return;
22465
+ const contentDirty = Boolean(lastSavedChapterSnapshot) && contentBefore !== lastSavedChapterSnapshot.content;
22466
+ state.chapter = { ...state.chapter, ...chapter, content: state.chapter.content };
22467
+ $("#chapter-title").value = chapter.title;
22468
+ chapterTitleOnFocus = chapter.title;
22469
+ lastSavedChapterSnapshot = {
22470
+ chapterId,
22471
+ title: chapter.title,
22472
+ content: contentDirty ? lastSavedChapterSnapshot.content : state.chapter.content
22473
+ };
22474
+ syncChapterTreeSelection();
22475
+ if (contentDirty) scheduleChapterAutoSave(120);
22476
+ else setSaveState("已保存");
22477
+ void refreshChapterTitleFormat();
22478
+ } catch (error) {
22479
+ toast(error.message, "error");
22480
+ if (state.chapter?.id === chapterId) scheduleChapterAutoSave(120);
22481
+ }
22482
+ }
22483
+
22484
+ $("#volume-context-menu").addEventListener("click", (event) => {
22485
+ const volume = state.work?.volumes.find((item) => item.id === state.contextVolumeId);
22486
+ if (event.target.closest("[data-volume-menu-detail]")) {
22487
+ closeVolumeContextMenu();
22488
+ if (volume) openVolumeDialog(volume);
22489
+ return;
22490
+ }
22491
+ if (event.target.closest("[data-volume-menu-numbering]")) {
22492
+ closeVolumeContextMenu();
22493
+ if (volume) openVolumeTitleNumberingDialog(volume);
22494
+ }
22495
+ });
22066
22496
  $("#chapter-type-menu").addEventListener("click", async (event) => {
22067
22497
  const aiReferenceButton = event.target.closest("[data-add-chapter-ai-reference]");
22068
22498
  if (aiReferenceButton) {
@@ -22071,6 +22501,19 @@ $("#chapter-type-menu").addEventListener("click", async (event) => {
22071
22501
  if (chapterId) addChapterAsAiReference(chapterId);
22072
22502
  return;
22073
22503
  }
22504
+ const insertBeforeButton = event.target.closest("[data-insert-chapter-before]");
22505
+ const insertAfterButton = event.target.closest("[data-insert-chapter-after]");
22506
+ if (insertBeforeButton || insertAfterButton) {
22507
+ const chapterId = state.contextChapterId;
22508
+ const location = chapterId ? findChapterLocation(chapterId) : null;
22509
+ closeChapterTypeMenu();
22510
+ if (location) {
22511
+ if (insertBeforeButton) openChapterDialog.insertBeforeChapterId = chapterId;
22512
+ else openChapterDialog.insertAfterChapterId = chapterId;
22513
+ await openChapterDialog(location.volume.id);
22514
+ }
22515
+ return;
22516
+ }
22074
22517
  const deleteButton = event.target.closest("[data-delete-chapter]");
22075
22518
  if (deleteButton) {
22076
22519
  const chapterId = state.contextChapterId;
@@ -22112,6 +22555,7 @@ document.addEventListener("contextmenu", (event) => {
22112
22555
  });
22113
22556
  document.addEventListener("pointerdown", (event) => {
22114
22557
  if (!event.target.closest("#chapter-type-menu")) closeChapterTypeMenu();
22558
+ if (!event.target.closest("#volume-context-menu")) closeVolumeContextMenu();
22115
22559
  if (!event.target.closest("#line-citation-menu")) closeLineCitationMenu();
22116
22560
  if (!event.target.closest("#markdown-table-menu")) closeMarkdownTableMenu();
22117
22561
  if (!event.target.closest("#manuscript-export-menu") && !event.target.closest("#export-button") && !event.target.closest("#work-export-button")) {