@musnows/scriverse 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.en.md +3 -0
  2. package/README.md +3 -0
  3. package/dist/ai-connectivity-test.js +109 -0
  4. package/dist/ai-connectivity-test.js.map +1 -0
  5. package/dist/ai-conversation-export.js +70 -0
  6. package/dist/ai-conversation-export.js.map +1 -0
  7. package/dist/ai-stream-timeout.js +18 -0
  8. package/dist/ai-stream-timeout.js.map +1 -0
  9. package/dist/ai.js +681 -107
  10. package/dist/ai.js.map +1 -1
  11. package/dist/app.js +326 -53
  12. package/dist/app.js.map +1 -1
  13. package/dist/character-extraction.js +133 -0
  14. package/dist/character-extraction.js.map +1 -0
  15. package/dist/cli-core.js +7 -6
  16. package/dist/cli-core.js.map +1 -1
  17. package/dist/database.js +175 -2
  18. package/dist/database.js.map +1 -1
  19. package/dist/epub-export.js +319 -0
  20. package/dist/epub-export.js.map +1 -0
  21. package/dist/hybrid-search.js +8 -0
  22. package/dist/hybrid-search.js.map +1 -1
  23. package/dist/public/ai-connectivity-test.d.ts +7 -0
  24. package/dist/public/ai-connectivity-test.js +82 -0
  25. package/dist/public/ai-request-manager.js +99 -0
  26. package/dist/public/ai-stream-protocol.js +51 -0
  27. package/dist/public/app.js +2567 -265
  28. package/dist/public/chapter-version-diff.d.ts +20 -0
  29. package/dist/public/chapter-version-diff.js +116 -0
  30. package/dist/public/foreshadow-reminder.d.ts +32 -0
  31. package/dist/public/foreshadow-reminder.js +73 -0
  32. package/dist/public/global-replace-refresh.js +60 -0
  33. package/dist/public/index.html +120 -12
  34. package/dist/public/outline-board.d.ts +61 -0
  35. package/dist/public/outline-board.js +137 -0
  36. package/dist/public/page-route.d.ts +1 -0
  37. package/dist/public/page-route.js +8 -0
  38. package/dist/public/reading-preview.d.ts +32 -0
  39. package/dist/public/reading-preview.js +136 -0
  40. package/dist/public/styles.css +485 -4
  41. package/dist/public/upload-progress.d.ts +2 -0
  42. package/dist/public/upload-progress.js +10 -0
  43. package/dist/security.js +5 -2
  44. package/dist/security.js.map +1 -1
  45. package/dist/server-runtime.js +2 -0
  46. package/dist/server-runtime.js.map +1 -1
  47. package/dist/store.js +825 -88
  48. package/dist/store.js.map +1 -1
  49. package/dist/user-auth.js +45 -9
  50. package/dist/user-auth.js.map +1 -1
  51. package/dist/utils.js +3 -0
  52. package/dist/utils.js.map +1 -1
  53. package/dist/version.js +1 -1
  54. package/package.json +2 -1
@@ -3,13 +3,24 @@ import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } f
3
3
  import { renderMarkdown } from "/markdown.js?v=20260731-no-external-images-v1";
4
4
  import { findAiMention, listAiMentionOptions, mergeAiReferenceScope, userMessageMentionNames } from "/ai-mentions.js?v=20260811-user-message-mentions-v1";
5
5
  import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
6
+ import { aiRequestTargetsState, createAiRequestAbortError, createAiRequestManager, isAiRequestCancellation } from "/ai-request-manager.js?v=20260812-ai-request-snapshot-v1";
6
7
  import { calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
7
8
  import { buildChapterLineMirror, findChapterLineWindow } from "/chapter-editor-virtualization.js?v=20260810-visible-lines-v1";
9
+ import {
10
+ FORESHADOW_REMINDER_SNOOZE_STORAGE_KEY,
11
+ foreshadowReminderRequestTargetsState,
12
+ foreshadowReminderSnoozeKey,
13
+ parseForeshadowReminderSnoozes,
14
+ serializeForeshadowReminderSnoozes,
15
+ visibleForeshadowReminders
16
+ } from "/foreshadow-reminder.js?v=20260812-editor-reminder-v1";
8
17
  import { buildVditorLineNumberRows } from "/vditor-line-number-layout.js?v=20260729-vditor-line-numbers-v3";
9
18
  import { MIN_MODEL_CONTEXT_WINDOW, MODEL_PURPOSE_OPTIONS, isKimiModelId, modelContextWindowGuidance, modelFormValues, modelOptionLabel, modelPayload, supportsMultimodalModelProtocol } from "/model-config.js?v=20260803-multimodal-model-config-v2";
19
+ import { connectivityConfigurationSavedToast, connectivityTestErrorToast, connectivityTestResultToast } from "/ai-connectivity-test.js?v=20260812-connectivity-cooldown-v1";
10
20
  import { shouldSendAiPrompt } from "/ai-prompt-keyboard.js?v=20260713-enter-to-send";
11
21
  import { estimateAiMessageTokens, formatAiMessageMeta } from "/ai-message-meta.js?v=20260726-cache-hit-percent";
12
22
  import { createStreamTypewriter } from "/stream-typewriter.js?v=20260730-ai-stream-typewriter-v3";
23
+ import { assertAiStreamCompleted, readAiEventStream } from "/ai-stream-protocol.js?v=20260812-ai-stream-complete-v1";
13
24
  import { buildUsageCalendar, formatCacheHitRate, formatTokenCount } from "/ai-usage.js?v=20260727-ai-usage-v1";
14
25
  import { formatAiMessageTime } from "/ai-message-time.js?v=20260801-month-day-time";
15
26
  import { formatAiContextUsagePercent, formatAiContextUsageTooltip, mergeAiContextUsage, normalizeAiContextTokenDistribution, resolveAiContextUsage } from "/ai-context-meter.js?v=20260812-context-usage-remaining-v2";
@@ -18,6 +29,7 @@ import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-ma
18
29
  import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
19
30
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
20
31
  import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260801-entity-lifecycle-v1";
32
+ import { chapterDiffSummary, diffChapterLines } from "/chapter-version-diff.js?v=20260812-chapter-version-diff-v1";
21
33
  import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260809-global-replace-v1";
22
34
  import {
23
35
  chapterVersionSourceLabel,
@@ -38,7 +50,18 @@ import {
38
50
  timelineStatusLabel,
39
51
  characterStateFieldLabel
40
52
  } from "/display-labels.js?v=20260809-global-replace-v1";
41
- import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260731-work-comments-v2";
53
+ import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260812-reader-preview-v1";
54
+ import {
55
+ READING_PREFERENCES_STORAGE_KEY,
56
+ adjacentReadingChapter,
57
+ buildReadingChapterSequence,
58
+ createReadingRequestGate,
59
+ normalizeReadingPosition,
60
+ normalizeReadingPreferences,
61
+ readingPositionStorageKey,
62
+ resolvePagedReadingStep,
63
+ resolveReadingStart
64
+ } from "/reading-preview.js?v=20260812-reader-preview-v1";
42
65
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
43
66
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
44
67
  import { buildRaceForest, eligibleRaceParents, orderRaceFilterOptions, racePathLabel } from "/race-hierarchy.js?v=20260729-race-tree-all-v1";
@@ -56,11 +79,18 @@ import {
56
79
  normalizeTimelineSortDirection,
57
80
  timelineTrackColorIndex
58
81
  } from "/timeline-view.js?v=20260801-timeline-sort-actions-v1";
82
+ import {
83
+ normalizeOutlineBoardState,
84
+ outlineBoardUnresolvedCount,
85
+ prepareOutlineBoard
86
+ } from "/outline-board.js?v=20260812-outline-board-v1";
59
87
  import { backgroundTaskActivityCount, backgroundTaskPollDelay, collectBackgroundTaskTransitions } from "/background-task-center.js?v=20260810-analysis-task-failed-v1";
60
88
  import { createModuleRequestCache } from "/module-request-cache.js?v=20260730-module-request-cache-v1";
61
89
  import { systemStatusPresentation } from "/system-status.js?v=20260801-system-health-v1";
62
90
  import { collectS3BackupRunTransitions, s3BackupEncryptionKeyFile, s3BackupEncryptionPresentation, s3BackupFailureToast, s3BackupRootPrefix, s3BackupStatusLabel } from "/s3-backup-ui.js?v=20260810-backup-encryption-v1";
63
91
  import { createPresenceClientId, stagePresenceClientIdForRelogin } from "/presence-client-id.js?v=20260810-presence-relogin-v1";
92
+ import { normalizeUploadProgress, uploadProgressText } from "/upload-progress.js?v=20260812-upload-progress-v1";
93
+ import { buildGlobalReplaceRefreshPlan, resolveGlobalReplaceChapterCount } from "/global-replace-refresh.js?v=20260812-global-replace-tree-v2";
64
94
  import {
65
95
  clampCropRect,
66
96
  containImageRect,
@@ -130,6 +160,7 @@ const state = {
130
160
  dirty: false,
131
161
  pendingImportMeta: null,
132
162
  pendingCoverWorkId: null,
163
+ coverUpload: null,
133
164
  uiSettings: { toastPosition: "bottom-right", pageSizes: { ...defaultPageSizes }, galaxyFrameRate: 30 },
134
165
  relationshipGraph: null,
135
166
  galaxy: null,
@@ -140,6 +171,7 @@ const state = {
140
171
  contextChapterId: null
141
172
  };
142
173
 
174
+ const aiRequestManager = createAiRequestManager();
143
175
  const moduleRequestCache = createModuleRequestCache();
144
176
  const cachedWorkModules = new Set([
145
177
  "drafts",
@@ -178,6 +210,18 @@ let workAuditRecords = [];
178
210
  let workAuditNextPage = null;
179
211
  const chapterBatchSelectedIds = new Set();
180
212
  let chapterMovePending = false;
213
+ const readingRequestGate = createReadingRequestGate();
214
+ let readingSequence = [];
215
+ let readingTargetChapter = null;
216
+ let readingLoadedChapter = null;
217
+ let readingPreferences = normalizeReadingPreferences();
218
+ let readingPageIndex = 0;
219
+ let readingPageCount = 1;
220
+ let readingLoading = false;
221
+ let readingReturnRoute = null;
222
+ let readingPreviousFocus = null;
223
+ let readingPositionSaveTimer = null;
224
+ let readingResizeFrame = null;
181
225
 
182
226
  let timelineMultiSelectEnabled = false;
183
227
  let timelineActiveTrackId = null;
@@ -359,6 +403,7 @@ function applyWorkAccessMode() {
359
403
  }
360
404
  $("#module-nav [data-module=\"comments\"]").classList.toggle("permission-hidden", Boolean(state.work) && !canReadModule("comments"));
361
405
  $("#module-nav [data-work-settings]").classList.toggle("permission-hidden", Boolean(state.work) && !canManageWork());
406
+ $("#reader-open-button").classList.toggle("permission-hidden", proseHidden);
362
407
  $("#new-volume-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
363
408
  $("#chapter-batch-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
364
409
  $("#welcome-new-work").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
@@ -381,6 +426,23 @@ function applyWorkAccessMode() {
381
426
 
382
427
  const $ = (selector) => document.querySelector(selector);
383
428
  const esc = (value) => String(value ?? "").replace(/[&<>'"]/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" })[character]);
429
+ const scrollBarHideTimers = new WeakMap();
430
+ const scrollBarHideDelay = 650;
431
+
432
+ function showScrollbarWhileScrolling(target) {
433
+ const element = target instanceof Element ? target : document.scrollingElement;
434
+ if (!element) return;
435
+ element.classList.add("is-scrollbar-visible");
436
+ const previousTimer = scrollBarHideTimers.get(element);
437
+ if (previousTimer) window.clearTimeout(previousTimer);
438
+ const timer = window.setTimeout(() => {
439
+ element.classList.remove("is-scrollbar-visible");
440
+ scrollBarHideTimers.delete(element);
441
+ }, scrollBarHideDelay);
442
+ scrollBarHideTimers.set(element, timer);
443
+ }
444
+
445
+ document.addEventListener("scroll", (event) => showScrollbarWhileScrolling(event.target), { capture: true, passive: true });
384
446
  const defaultImageUploadLimits = {
385
447
  avatarBytes: 2 * 1024 * 1024,
386
448
  coverBytes: 5 * 1024 * 1024,
@@ -419,6 +481,30 @@ function assertAttachmentFileSize(file) {
419
481
  if (file.size > imageUploadLimits.attachmentBytes) throw new Error(`图片附件不能超过 ${formatUploadLimit(imageUploadLimits.attachmentBytes)}`);
420
482
  }
421
483
 
484
+ function uploadProgressHtml(label, fileName, progress = 0) {
485
+ const normalized = normalizeUploadProgress(progress);
486
+ const value = normalized ?? 0;
487
+ return `<div class="image-upload-progress" data-upload-progress data-upload-progress-label="${esc(label)}" style="--upload-progress: ${value}%">
488
+ <div class="image-upload-progress-heading"><span data-upload-progress-text>${esc(uploadProgressText(progress))}</span><output data-upload-progress-value>${normalized === null ? "—" : `${normalized}%`}</output></div>
489
+ <div class="image-upload-progress-track" role="progressbar" aria-label="${esc(label)}:${esc(fileName)}" aria-valuemin="0" aria-valuemax="100"${normalized === null ? "" : ` aria-valuenow="${normalized}"`}><span data-upload-progress-fill></span></div>
490
+ </div>`;
491
+ }
492
+
493
+ function updateUploadProgress(element, progress) {
494
+ if (!element) return;
495
+ const normalized = normalizeUploadProgress(progress);
496
+ element.style.setProperty("--upload-progress", `${normalized ?? 0}%`);
497
+ element.classList.toggle("is-indeterminate", normalized === null);
498
+ const text = element.querySelector("[data-upload-progress-text]");
499
+ if (text) text.textContent = uploadProgressText(progress);
500
+ const value = element.querySelector("[data-upload-progress-value]");
501
+ if (value) value.textContent = normalized === null ? "—" : `${normalized}%`;
502
+ const track = element.querySelector("[role=progressbar]");
503
+ if (!track) return;
504
+ if (normalized === null) track.removeAttribute("aria-valuenow");
505
+ else track.setAttribute("aria-valuenow", String(normalized));
506
+ }
507
+
422
508
  function setAiAssistantStatus(status) {
423
509
  const failed = status === "error";
424
510
  const label = failed ? "创作助手状态:执行失败" : "创作助手状态:正常";
@@ -428,6 +514,76 @@ function setAiAssistantStatus(status) {
428
514
  dot.title = label;
429
515
  }
430
516
 
517
+ function aiRequestTargetsCurrentState(request) {
518
+ return aiRequestManager.isCurrent(request) && aiRequestTargetsState(request, {
519
+ workId: state.work?.id,
520
+ conversationId: state.aiConversationId
521
+ });
522
+ }
523
+
524
+ function assertAiRequestCurrent(request) {
525
+ if (!aiRequestTargetsCurrentState(request)) throw createAiRequestAbortError("AI 请求目标已切换");
526
+ return request;
527
+ }
528
+
529
+ function aiInteractionBusy() {
530
+ return aiRequestManager.hasActive() || aiConversationNavigationPending !== null;
531
+ }
532
+
533
+ function syncAiRequestControls() {
534
+ const sending = aiRequestManager.hasActive();
535
+ const switching = aiConversationNavigationPending !== null;
536
+ const button = $("#ai-send");
537
+ button.disabled = sending || switching;
538
+ button.textContent = sending ? "发送中" : switching ? "切换中" : "发送";
539
+ syncAiTaskOptions();
540
+ renderAiRoleplayCharacterSelect();
541
+ }
542
+
543
+ function cancelActiveAiRequest(reason) {
544
+ const cancelled = aiRequestManager.cancel(reason);
545
+ syncAiRequestControls();
546
+ return cancelled;
547
+ }
548
+
549
+ function beginAiConversationNavigation(reason) {
550
+ aiConversationNavigationGeneration += 1;
551
+ aiConversationNavigationPending = aiConversationNavigationGeneration;
552
+ const navigation = Object.freeze({
553
+ generation: aiConversationNavigationGeneration,
554
+ workId: String(state.work?.id ?? "")
555
+ });
556
+ cancelActiveAiRequest(reason);
557
+ return navigation;
558
+ }
559
+
560
+ function isAiConversationNavigationCurrent(navigation) {
561
+ return navigation.generation === aiConversationNavigationGeneration
562
+ && navigation.workId === String(state.work?.id ?? "");
563
+ }
564
+
565
+ function finishAiConversationNavigation(navigation) {
566
+ if (aiConversationNavigationPending !== navigation.generation) return false;
567
+ aiConversationNavigationPending = null;
568
+ syncAiRequestControls();
569
+ return true;
570
+ }
571
+
572
+ function invalidateAiConversationNavigation(reason) {
573
+ aiConversationNavigationGeneration += 1;
574
+ aiConversationNavigationPending = null;
575
+ cancelActiveAiRequest(reason);
576
+ }
577
+
578
+ function aiAssistantRequestId(request) {
579
+ return request.userMessageId ? `assistant:${request.userMessageId}` : null;
580
+ }
581
+
582
+ function createAiIdempotencyKey() {
583
+ if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
584
+ return `chat-${Date.now().toString(36)}-${Math.random().toString(36).slice(2).padEnd(16, "0")}`;
585
+ }
586
+
431
587
  function userAvatarInitial(user) {
432
588
  return Array.from(String(user?.displayName || user?.username || "作"))[0] ?? "作";
433
589
  }
@@ -489,6 +645,10 @@ let latestAiContextUsage = null;
489
645
  const aiConversationHistoryPageLimit = 20;
490
646
  let aiConversationHistoryPage = { page: 1, limit: aiConversationHistoryPageLimit, hasMore: false, nextPage: null };
491
647
  let workScopedUiGeneration = 0;
648
+ let workSelectionRequestGeneration = 0;
649
+ let chapterSelectionRequestGeneration = 0;
650
+ let aiConversationNavigationGeneration = 0;
651
+ let aiConversationNavigationPending = null;
492
652
  const loadedVolumeChapterIds = new Set();
493
653
  const volumeChapterLoadingIds = new Set();
494
654
  const volumeChapterRequests = new Map();
@@ -701,6 +861,7 @@ function replacePageRoute(route) {
701
861
 
702
862
  function presencePageForRoute(route = currentPageRoute()) {
703
863
  if (!state.work || route.view === "shelf" || route.view === "platform-ai" || route.view === "platform-usage") return null;
864
+ if (route.view === "reader") return { kind: "welcome" };
704
865
  if (relationshipPresenceId) return { kind: "entity-editor", module: "relationship", resourceId: relationshipPresenceId };
705
866
  if (route.view === "editor") return { kind: "editor", resourceId: String(route.chapterId ?? "") || undefined };
706
867
  if (route.view === "entity-editor") return { kind: "entity-editor", module: route.entity, resourceId: String(route.entityId ?? "") || undefined };
@@ -889,6 +1050,9 @@ async function confirmConcurrentSave() {
889
1050
 
890
1051
  function currentPageRoute() {
891
1052
  const workId = state.work?.id ?? null;
1053
+ if (!$("#reader-view").classList.contains("hidden") && workId) {
1054
+ return { view: "reader", workId, chapterId: readingTargetChapter?.id ?? null };
1055
+ }
892
1056
  if (!$("#entity-editor-view").classList.contains("hidden") && workId && entityEditorType) {
893
1057
  const entityId = entityEditorType === "setting" ? settingEditorItem?.id : entityEditorType === "character" ? characterEditorItem?.id : knowledgeEditorItem?.id;
894
1058
  return { view: "entity-editor", workId, entity: entityEditorType, entityId: entityId ?? null, entityMode: entityEditorReadOnly ? "read" : "edit" };
@@ -954,6 +1118,7 @@ function applyPanelLayout(persist = false) {
954
1118
  $("#ai-panel-toggle").textContent = panelLayout.aiCollapsed ? "‹" : "›";
955
1119
  $("#ai-panel-toggle").setAttribute("aria-expanded", String(!panelLayout.aiCollapsed));
956
1120
  $("#ai-panel-toggle").setAttribute("aria-label", panelLayout.aiCollapsed ? "展开创作助手" : "收起创作助手");
1121
+ syncMobileAiPanelSafeTop();
957
1122
  scheduleChapterLineNumbers();
958
1123
  if (persist) {
959
1124
  try { localStorage.setItem(panelLayoutStorageKey, JSON.stringify(panelLayout)); } catch { /* 浏览器禁用存储时仅保留当前布局 */ }
@@ -1012,6 +1177,20 @@ let chapterAutoSaveTimer = null;
1012
1177
  let chapterSaveInFlight = null;
1013
1178
  let chapterSaveGuardInFlight = null;
1014
1179
  let lastSavedChapterSnapshot = null;
1180
+ let chapterSelectionRequestId = 0;
1181
+ let chapterForeshadowReminderRequestId = 0;
1182
+ let chapterForeshadowReminders = [];
1183
+ let chapterForeshadowReminderIndex = 0;
1184
+ let chapterForeshadowReminderDetailsExpanded = false;
1185
+ let chapterForeshadowReminderResolveInFlight = false;
1186
+ let foreshadowReminderSnoozes = new Set();
1187
+ try {
1188
+ foreshadowReminderSnoozes = parseForeshadowReminderSnoozes(
1189
+ presenceSessionStorage?.getItem(FORESHADOW_REMINDER_SNOOZE_STORAGE_KEY)
1190
+ );
1191
+ } catch {
1192
+ // 浏览器禁用会话存储时使用页面级静默状态。
1193
+ }
1015
1194
  let moduleNavExpanded = false;
1016
1195
  const chapterAutoSaveDelay = 800;
1017
1196
  const chapterLineInputRenderDelay = 32;
@@ -1043,6 +1222,9 @@ const characterFilters = { raceIds: [], organizationIds: [] };
1043
1222
  let characterFiltersPanelOpen = false;
1044
1223
  const settingFilters = { keyword: "", category: "", lockState: "all" };
1045
1224
  let settingFiltersPanelOpen = false;
1225
+ const outlineBoardFilters = normalizeOutlineBoardState();
1226
+ let outlineBoardFiltersPanelOpen = false;
1227
+ let outlineBoardRenderRequestId = 0;
1046
1228
  const relationshipFilters = { fromCharacterIds: [], toCharacterIds: [] };
1047
1229
  let relationshipFiltersPanelOpen = false;
1048
1230
 
@@ -1093,6 +1275,7 @@ function applyChapterEditorMode() {
1093
1275
  $("#chapter-edit-button").classList.toggle("hidden", permissionBlocked || !chapterEditorReadOnly || !state.chapter);
1094
1276
  $("#chapter-delete-button").classList.toggle("hidden", permissionBlocked || chapterEditorReadOnly || !state.chapter);
1095
1277
  $("#chapter-annotations-button").classList.toggle("hidden", !state.chapter);
1278
+ $("#chapter-reader-button").classList.toggle("hidden", !state.chapter || !canReadModule("editor"));
1096
1279
  if (viewOnly) cancelChapterAutoSave();
1097
1280
  }
1098
1281
 
@@ -1682,16 +1865,20 @@ function renderMessageCardActions(message) {
1682
1865
  });
1683
1866
  actions.append(copy);
1684
1867
  }
1685
- if (message.dataset.messageId && message.classList.contains("assistant-message")) {
1868
+ if (message.dataset.messageId) {
1686
1869
  const fork = document.createElement("button");
1687
1870
  fork.type = "button";
1688
1871
  fork.className = "message-fork-button";
1689
- fork.setAttribute("aria-label", "从此消息 Fork 新对话");
1690
- fork.innerHTML = '<svg class="message-action-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="5" r="2"/><circle cx="18" cy="5" r="2"/><circle cx="12" cy="19" r="2"/><path d="M6 7v2a4 4 0 0 0 4 4h4a4 4 0 0 0 4-4V7M12 13v4"/></svg><span>分支</span>';
1872
+ fork.setAttribute("aria-label", "从此消息续写为新对话");
1873
+ fork.innerHTML = '<svg class="message-action-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="5" r="2"/><circle cx="18" cy="5" r="2"/><circle cx="12" cy="19" r="2"/><path d="M6 7v2a4 4 0 0 0 4 4h4a4 4 0 0 0 4-4V7M12 13v4"/></svg><span>从此续写</span>';
1874
+ const forkRequestId = crypto.randomUUID();
1691
1875
  fork.addEventListener("click", async () => {
1692
1876
  fork.disabled = true;
1693
1877
  try {
1694
- const conversation = await api(`/api/ai-conversations/${state.aiConversationId}/fork`, { method: "POST", body: { messageId: message.dataset.messageId } });
1878
+ const conversation = await api(`/api/ai-conversations/${state.aiConversationId}/fork`, {
1879
+ method: "POST",
1880
+ body: { messageId: message.dataset.messageId, requestId: forkRequestId }
1881
+ });
1695
1882
  upsertAiConversationSummary(conversation);
1696
1883
  await openAiConversation(conversation.id);
1697
1884
  toast("已从所选消息创建分支对话");
@@ -1938,7 +2125,59 @@ function setAiHistoryVisible(visible) {
1938
2125
  $("#ai-history-toggle").setAttribute("aria-expanded", String(dialog.open));
1939
2126
  }
1940
2127
 
2128
+ let aiHistoryActionConversation = null;
2129
+ let aiHistoryActionAnchor = null;
2130
+
2131
+ function closeAiHistoryActionMenu(restoreFocus = false) {
2132
+ const menu = $("#ai-history-action-menu");
2133
+ menu.classList.add("hidden");
2134
+ document.querySelectorAll(".ai-history-more[aria-expanded=\"true\"]").forEach((button) => {
2135
+ button.setAttribute("aria-expanded", "false");
2136
+ });
2137
+ if (restoreFocus && aiHistoryActionAnchor?.isConnected) aiHistoryActionAnchor.focus();
2138
+ aiHistoryActionConversation = null;
2139
+ aiHistoryActionAnchor = null;
2140
+ }
2141
+
2142
+ function showAiHistoryActionMenu(anchor, conversation) {
2143
+ const menu = $("#ai-history-action-menu");
2144
+ closeAiHistoryActionMenu();
2145
+ aiHistoryActionConversation = conversation;
2146
+ aiHistoryActionAnchor = anchor;
2147
+ menu.classList.remove("hidden");
2148
+ const anchorRect = anchor.getBoundingClientRect();
2149
+ const menuRect = menu.getBoundingClientRect();
2150
+ const left = Math.max(8, Math.min(anchorRect.right - menuRect.width, window.innerWidth - menuRect.width - 8));
2151
+ const top = Math.max(8, Math.min(anchorRect.bottom + 6, window.innerHeight - menuRect.height - 8));
2152
+ menu.style.left = `${left}px`;
2153
+ menu.style.top = `${top}px`;
2154
+ anchor.setAttribute("aria-expanded", "true");
2155
+ menu.querySelector("button")?.focus();
2156
+ }
2157
+
2158
+ async function downloadAiConversation(conversation) {
2159
+ if (!conversation?.id) return;
2160
+ const response = await fetch(`/api/ai-conversations/${encodeURIComponent(conversation.id)}/export`, {
2161
+ headers: { Accept: "text/markdown" }
2162
+ });
2163
+ if (!response.ok) {
2164
+ const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
2165
+ throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
2166
+ }
2167
+ const blob = await response.blob();
2168
+ const objectUrl = URL.createObjectURL(blob);
2169
+ const anchor = document.createElement("a");
2170
+ const safeId = String(conversation.id).replace(/[^A-Za-z0-9_-]/gu, "-") || "export";
2171
+ anchor.href = objectUrl;
2172
+ anchor.download = `ai-conversation-${safeId}.md`;
2173
+ document.body.append(anchor);
2174
+ anchor.click();
2175
+ anchor.remove();
2176
+ window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
2177
+ }
2178
+
1941
2179
  function renderAiConversationHistory() {
2180
+ closeAiHistoryActionMenu();
1942
2181
  const host = $("#ai-history-list");
1943
2182
  host.replaceChildren();
1944
2183
  const pagination = $("#ai-history-pagination");
@@ -1952,6 +2191,8 @@ function renderAiConversationHistory() {
1952
2191
  return;
1953
2192
  }
1954
2193
  for (const conversation of state.aiConversations) {
2194
+ const row = document.createElement("div");
2195
+ row.className = "ai-history-row";
1955
2196
  const button = document.createElement("button");
1956
2197
  button.type = "button";
1957
2198
  button.className = `ai-history-item${conversation.id === state.aiConversationId ? " is-active" : ""}`;
@@ -1962,7 +2203,22 @@ function renderAiConversationHistory() {
1962
2203
  meta.textContent = `${conversation.messageCount} 条${conversation.roleplayCharacter?.name ? ` · 扮演 ${conversation.roleplayCharacter.name}` : ""} · ${formatDateTime(conversation.updatedAt)}`;
1963
2204
  button.append(title, meta);
1964
2205
  button.addEventListener("click", () => openAiConversation(conversation.id));
1965
- host.append(button);
2206
+ const more = document.createElement("button");
2207
+ more.type = "button";
2208
+ more.className = "ai-history-more";
2209
+ more.setAttribute("aria-label", `打开“${conversation.title}”对话操作`);
2210
+ more.setAttribute("aria-haspopup", "menu");
2211
+ more.setAttribute("aria-controls", "ai-history-action-menu");
2212
+ more.setAttribute("aria-expanded", "false");
2213
+ more.textContent = "···";
2214
+ more.addEventListener("click", (event) => {
2215
+ event.stopPropagation();
2216
+ const expanded = more.getAttribute("aria-expanded") === "true";
2217
+ if (expanded) closeAiHistoryActionMenu(true);
2218
+ else showAiHistoryActionMenu(more, conversation);
2219
+ });
2220
+ row.append(button, more);
2221
+ host.append(row);
1966
2222
  }
1967
2223
  }
1968
2224
 
@@ -2003,11 +2259,11 @@ function updateAiConversationSummaryFromMessage(message) {
2003
2259
  });
2004
2260
  }
2005
2261
 
2006
- function applyAiConversationTitle(title) {
2007
- if (!title || !state.aiConversationId) return;
2008
- const current = state.aiConversations.find((conversation) => conversation.id === state.aiConversationId);
2262
+ function applyAiConversationTitle(title, conversationId = state.aiConversationId) {
2263
+ if (!title || !conversationId) return;
2264
+ const current = state.aiConversations.find((conversation) => conversation.id === conversationId);
2009
2265
  if (current) current.title = title;
2010
- $("#ai-conversation-title").textContent = title;
2266
+ if (state.aiConversationId === conversationId) $("#ai-conversation-title").textContent = title;
2011
2267
  renderAiConversationHistory();
2012
2268
  }
2013
2269
 
@@ -2051,35 +2307,47 @@ async function ensureAiConversationsLoaded() {
2051
2307
  }
2052
2308
 
2053
2309
  async function openAiConversation(conversationId, hideHistory = true, focusMessageId = null) {
2054
- const parameters = new URLSearchParams({ page: "1", limit: "100" });
2055
- if (focusMessageId) parameters.set("messageId", String(focusMessageId));
2056
- const [conversation] = await Promise.all([
2057
- api(`/api/ai-conversations/${conversationId}?${parameters}`),
2058
- ensureAiReferencesLoaded()
2059
- ]);
2060
- upsertAiConversationSummary(conversation);
2061
- state.aiConversationId = conversation.id;
2062
- state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
2063
- applyAiConversationTaskType(conversation.taskType);
2064
- applyAiConversationContextScope(conversation.contextScope);
2065
- applyAiRoleplayCharacter(conversation.roleplayCharacter);
2066
- resetAiContextMeter();
2067
- $("#ai-conversation-title").textContent = conversation.title;
2068
- resetAiFeed();
2069
- for (const message of conversation.messages) appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id);
2070
- if (conversation.hasCompactedSummary) {
2071
- renderConversationCompactionDivider(conversation.compactedMessageCount, conversation.messageCount);
2310
+ if (!state.work) return null;
2311
+ const navigation = beginAiConversationNavigation("已切换 AI 对话");
2312
+ try {
2313
+ const parameters = new URLSearchParams({ page: "1", limit: "100" });
2314
+ if (focusMessageId) parameters.set("messageId", String(focusMessageId));
2315
+ const [conversation] = await Promise.all([
2316
+ api(`/api/ai-conversations/${conversationId}?${parameters}`),
2317
+ ensureAiReferencesLoaded()
2318
+ ]);
2319
+ if (!isAiConversationNavigationCurrent(navigation)) return null;
2320
+ if (String(conversation.workId ?? "") !== navigation.workId) throw new Error("AI 对话不属于当前作品");
2321
+ upsertAiConversationSummary(conversation);
2322
+ state.aiConversationId = conversation.id;
2323
+ state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
2324
+ applyAiConversationTaskType(conversation.taskType);
2325
+ applyAiConversationContextScope(conversation.contextScope);
2326
+ applyAiRoleplayCharacter(conversation.roleplayCharacter);
2327
+ resetAiContextMeter();
2328
+ $("#ai-conversation-title").textContent = conversation.title;
2329
+ resetAiFeed();
2330
+ for (const message of conversation.messages) appendMessage(message.role, message.content, message.citations, message.createdAt, message.metadata, message.id);
2331
+ if (conversation.hasCompactedSummary) {
2332
+ renderConversationCompactionDivider(conversation.compactedMessageCount, conversation.messageCount);
2333
+ }
2334
+ state.aiCitations = [];
2335
+ state.aiReferences = [];
2336
+ setAiPromptText("");
2337
+ renderAiCitations();
2338
+ renderAiReferences();
2339
+ renderAiQuickActions();
2340
+ renderAiConversationHistory();
2341
+ if (conversation.contextWarningPending) showAiContextWarning();
2342
+ else hideAiContextWarning();
2343
+ if (hideHistory) setAiHistoryVisible(false);
2344
+ return conversation;
2345
+ } catch (error) {
2346
+ if (!isAiConversationNavigationCurrent(navigation)) return null;
2347
+ throw error;
2348
+ } finally {
2349
+ finishAiConversationNavigation(navigation);
2072
2350
  }
2073
- state.aiCitations = [];
2074
- state.aiReferences = [];
2075
- setAiPromptText("");
2076
- renderAiCitations();
2077
- renderAiReferences();
2078
- renderAiQuickActions();
2079
- renderAiConversationHistory();
2080
- if (conversation.contextWarningPending) showAiContextWarning();
2081
- else hideAiContextWarning();
2082
- if (hideHistory) setAiHistoryVisible(false);
2083
2351
  }
2084
2352
 
2085
2353
  function focusAiConversationMessage(messageId) {
@@ -2091,9 +2359,7 @@ function focusAiConversationMessage(messageId) {
2091
2359
  window.setTimeout(() => message.classList.remove("is-search-target"), 1800);
2092
2360
  }
2093
2361
 
2094
- async function createNewAiConversation(taskType = "chat") {
2095
- if (!state.work) return;
2096
- const conversation = await api(`/api/works/${state.work.id}/ai-conversations`, { method: "POST", body: { taskType } });
2362
+ function applyNewAiConversation(conversation) {
2097
2363
  upsertAiConversationSummary(conversation);
2098
2364
  state.aiConversationId = conversation.id;
2099
2365
  state.aiPromptSent = false;
@@ -2108,6 +2374,23 @@ async function createNewAiConversation(taskType = "chat") {
2108
2374
  setAiHistoryVisible(false);
2109
2375
  }
2110
2376
 
2377
+ async function createNewAiConversation(taskType = "chat") {
2378
+ if (!state.work) return null;
2379
+ const navigation = beginAiConversationNavigation("已新建 AI 对话");
2380
+ try {
2381
+ const conversation = await api(`/api/works/${navigation.workId}/ai-conversations`, { method: "POST", body: { taskType } });
2382
+ if (!isAiConversationNavigationCurrent(navigation)) return null;
2383
+ if (String(conversation.workId ?? "") !== navigation.workId) throw new Error("新对话不属于当前作品");
2384
+ applyNewAiConversation(conversation);
2385
+ return conversation;
2386
+ } catch (error) {
2387
+ if (!isAiConversationNavigationCurrent(navigation)) return null;
2388
+ throw error;
2389
+ } finally {
2390
+ finishAiConversationNavigation(navigation);
2391
+ }
2392
+ }
2393
+
2111
2394
  function renderAiRoleplayCharacterSelect() {
2112
2395
  const select = $("#ai-roleplay-character");
2113
2396
  const selectedId = String(state.aiRoleplayCharacter?.id ?? "");
@@ -2129,7 +2412,7 @@ function renderAiRoleplayCharacterSelect() {
2129
2412
  const canSelectCharacter = Boolean(state.work)
2130
2413
  && canReadModule("characters")
2131
2414
  && canWritePermissionModule(state.work, "ai-chat");
2132
- select.disabled = !canSelectCharacter || state.aiPromptSent;
2415
+ select.disabled = aiInteractionBusy() || !canSelectCharacter || state.aiPromptSent;
2133
2416
  select.title = canSelectCharacter
2134
2417
  ? "为当前对话选择角色卡;角色扮演时 Agent 只能查询与该角色自身有关的记忆"
2135
2418
  : "当前账户没有角色模块读取权限";
@@ -2137,13 +2420,14 @@ function renderAiRoleplayCharacterSelect() {
2137
2420
 
2138
2421
  function syncAiTaskOptions() {
2139
2422
  const roleplaySelected = $("#ai-task").value === "roleplay";
2423
+ const interactionBusy = aiInteractionBusy();
2140
2424
  $("#ai-scope").classList.toggle("hidden", roleplaySelected);
2141
2425
  $("#ai-scope").setAttribute("aria-hidden", String(roleplaySelected));
2142
2426
  $("#ai-roleplay-character").classList.toggle("hidden", !roleplaySelected);
2143
2427
  $("#ai-roleplay-character").setAttribute("aria-hidden", String(!roleplaySelected));
2144
- $("#ai-task").disabled = state.aiPromptSent;
2428
+ $("#ai-task").disabled = interactionBusy || state.aiPromptSent;
2145
2429
  $("#ai-task").title = state.aiPromptSent ? "对话开始后不能切换任务类型" : "";
2146
- $("#ai-scope").disabled = roleplaySelected || state.aiPromptSent;
2430
+ $("#ai-scope").disabled = interactionBusy || roleplaySelected || state.aiPromptSent;
2147
2431
  $("#ai-scope").title = state.aiPromptSent
2148
2432
  ? "对话开始后不能切换上下文引用"
2149
2433
  : roleplaySelected ? "角色扮演模式只使用角色自身的记忆" : "";
@@ -2233,12 +2517,75 @@ async function persistAiConversationContextScope(scope) {
2233
2517
  return conversation;
2234
2518
  }
2235
2519
 
2236
- async function persistAiConversationMessage(role, content, citations = [], metadata = {}) {
2237
- const conversationId = await ensureAiConversation();
2520
+ async function prepareAiRequestConversation(requestHolder, taskType, scope) {
2521
+ let request = assertAiRequestCurrent(requestHolder.snapshot);
2522
+ if (!request.conversationId) {
2523
+ const conversation = await api(`/api/works/${encodeURIComponent(request.workId)}/ai-conversations`, {
2524
+ method: "POST",
2525
+ body: { taskType },
2526
+ signal: request.signal
2527
+ });
2528
+ assertAiRequestCurrent(request);
2529
+ if (String(conversation.workId ?? "") !== request.workId) throw new Error("新对话不属于请求作品");
2530
+ applyNewAiConversation(conversation);
2531
+ requestHolder.snapshot = aiRequestManager.bind(request, { conversationId: conversation.id });
2532
+ request = requestHolder.snapshot;
2533
+ }
2534
+ const taskConversation = await api(`/api/ai-conversations/${encodeURIComponent(request.conversationId)}/task-type`, {
2535
+ method: "PATCH",
2536
+ body: { taskType },
2537
+ signal: request.signal
2538
+ });
2539
+ assertAiRequestCurrent(request);
2540
+ upsertAiConversationSummary(taskConversation);
2541
+ applyAiConversationTaskType(taskConversation.taskType);
2542
+ applyAiRoleplayCharacter(taskConversation.roleplayCharacter);
2543
+
2544
+ const scopedConversation = await api(`/api/ai-conversations/${encodeURIComponent(request.conversationId)}/context-scope`, {
2545
+ method: "PATCH",
2546
+ body: { scope },
2547
+ signal: request.signal
2548
+ });
2549
+ assertAiRequestCurrent(request);
2550
+ upsertAiConversationSummary(scopedConversation);
2551
+ applyAiConversationContextScope(scopedConversation.contextScope);
2552
+ return request;
2553
+ }
2554
+
2555
+ async function persistAiConversationMessage(conversationId, role, content, citations = [], metadata = {}, options = {}) {
2238
2556
  if (!conversationId) throw new Error("无法创建 AI 对话");
2239
- const message = await api(`/api/ai-conversations/${conversationId}/messages`, { method: "POST", body: { role, content, citations, metadata } });
2240
- updateAiConversationSummaryFromMessage(message);
2241
- return message;
2557
+ return api(`/api/ai-conversations/${encodeURIComponent(conversationId)}/messages`, {
2558
+ method: "POST",
2559
+ body: {
2560
+ role,
2561
+ content,
2562
+ citations,
2563
+ metadata,
2564
+ ...(options.requestId ? { requestId: options.requestId } : {})
2565
+ },
2566
+ ...(options.signal ? { signal: options.signal } : {})
2567
+ });
2568
+ }
2569
+
2570
+ async function persistAiRequestInterruption(request, interruption = null) {
2571
+ if (!request.conversationId || !request.userMessageId) return null;
2572
+ const partialContent = typeof interruption?.content === "string" && interruption.content.trim()
2573
+ ? interruption.content
2574
+ : null;
2575
+ try {
2576
+ const message = await persistAiConversationMessage(
2577
+ request.conversationId,
2578
+ "assistant",
2579
+ partialContent ?? "调用失败:生成已取消,尚未收到可保留的回复内容。",
2580
+ [],
2581
+ partialContent ? interruption.metadata : {},
2582
+ { requestId: aiAssistantRequestId(request) }
2583
+ );
2584
+ updateAiConversationSummaryFromMessage(message);
2585
+ return message;
2586
+ } catch {
2587
+ return null;
2588
+ }
2242
2589
  }
2243
2590
 
2244
2591
  function promptTextFromNode(node, root = node) {
@@ -2269,6 +2616,22 @@ function clearAiPromptComposer() {
2269
2616
  hideAiMentionMenu();
2270
2617
  }
2271
2618
 
2619
+ function captureAiPromptComposer() {
2620
+ return {
2621
+ text: aiPromptText(),
2622
+ citations: state.aiCitations.map((citation) => ({ ...citation })),
2623
+ references: state.aiReferences.map((reference) => ({ ...reference }))
2624
+ };
2625
+ }
2626
+
2627
+ function restoreAiPromptComposer(snapshot) {
2628
+ state.aiCitations = snapshot.citations.map((citation) => ({ ...citation }));
2629
+ state.aiReferences = snapshot.references.map((reference) => ({ ...reference }));
2630
+ setAiPromptText(snapshot.text);
2631
+ renderAiCitations();
2632
+ hideAiMentionMenu();
2633
+ }
2634
+
2272
2635
  function aiPromptTextBeforeCursor() {
2273
2636
  const prompt = $("#ai-prompt");
2274
2637
  const selection = window.getSelection();
@@ -2802,7 +3165,7 @@ async function api(path, options = {}) {
2802
3165
  body: body && typeof body !== "string" ? JSON.stringify(body) : body
2803
3166
  });
2804
3167
  } catch (error) {
2805
- updateSystemHealth({ status: "offline" });
3168
+ if (!isAiRequestCancellation(error)) updateSystemHealth({ status: "offline" });
2806
3169
  throw error;
2807
3170
  }
2808
3171
  updateSystemHealth({ status: response.status >= 500 ? "degraded" : "ready" });
@@ -2833,6 +3196,61 @@ async function api(path, options = {}) {
2833
3196
  return payload.data;
2834
3197
  }
2835
3198
 
3199
+ function uploadWithProgress(path, options = {}, onProgress = () => {}) {
3200
+ const method = String(options.method ?? "GET").toUpperCase();
3201
+ const body = options.skipOptimisticVersion ? options.body : attachOptimisticVersion(path, method, options.body);
3202
+ if (!(body instanceof FormData)) return api(path, options);
3203
+ const headers = { ...(options.headers ?? {}) };
3204
+ if (state.csrfToken && !["GET", "HEAD", "OPTIONS"].includes(method)) headers["X-CSRF-Token"] = state.csrfToken;
3205
+ onProgress(0);
3206
+ return new Promise((resolve, reject) => {
3207
+ const request = new XMLHttpRequest();
3208
+ request.open(method, path, true);
3209
+ request.withCredentials = true;
3210
+ Object.entries(headers).forEach(([name, value]) => request.setRequestHeader(name, String(value)));
3211
+ request.upload.addEventListener("progress", (event) => {
3212
+ onProgress(event.lengthComputable && event.total > 0 ? (event.loaded / event.total) * 100 : null);
3213
+ });
3214
+ request.addEventListener("error", () => {
3215
+ updateSystemHealth({ status: "offline" });
3216
+ reject(new Error("网络连接失败"));
3217
+ });
3218
+ request.addEventListener("abort", () => reject(new Error("上传已取消")));
3219
+ request.addEventListener("load", async () => {
3220
+ const responseStatus = request.status;
3221
+ let payload = null;
3222
+ try {
3223
+ payload = request.responseText ? JSON.parse(request.responseText) : null;
3224
+ } catch {
3225
+ reject(new Error("服务器返回了无效响应"));
3226
+ return;
3227
+ }
3228
+ updateSystemHealth({ status: responseStatus >= 500 ? "degraded" : "ready" });
3229
+ if (responseStatus < 200 || responseStatus >= 300) {
3230
+ if (responseStatus === 401 && !path.startsWith("/api/auth/") && !path.includes("/presence")) {
3231
+ const restarted = await checkSystemBoot(true);
3232
+ if (!restarted) invalidateAuthentication();
3233
+ }
3234
+ const errorPayload = payload?.error;
3235
+ reject(createClientError(errorPayload, `请求失败:${responseStatus}`, responseStatus));
3236
+ return;
3237
+ }
3238
+ if (responseStatus === 204) {
3239
+ invalidateModuleRequestsAfterMutation(path, method);
3240
+ resolve(null);
3241
+ return;
3242
+ }
3243
+ if (!payload || typeof payload !== "object") {
3244
+ reject(new Error("服务器返回了无效响应"));
3245
+ return;
3246
+ }
3247
+ invalidateModuleRequestsAfterMutation(path, method);
3248
+ resolve(payload.data);
3249
+ });
3250
+ request.send(body);
3251
+ });
3252
+ }
3253
+
2836
3254
  function createClientError(payload, fallbackMessage, fallbackStatus = null) {
2837
3255
  const source = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
2838
3256
  const error = new Error(typeof source.message === "string" ? source.message : fallbackMessage);
@@ -2845,9 +3263,24 @@ function createClientError(payload, fallbackMessage, fallbackStatus = null) {
2845
3263
  error.providerId = typeof source.providerId === "string" ? source.providerId : undefined;
2846
3264
  error.modelId = typeof source.modelId === "string" ? source.modelId : undefined;
2847
3265
  error.modelRecordId = typeof source.modelRecordId === "string" ? source.modelRecordId : undefined;
3266
+ error.phase = typeof source.phase === "string" ? source.phase : undefined;
3267
+ error.idleTimeoutSeconds = typeof source.idleTimeoutSeconds === "number" ? source.idleTimeoutSeconds : undefined;
2848
3268
  return error;
2849
3269
  }
2850
3270
 
3271
+ function aiStreamInterruptionLabel(code) {
3272
+ if (code === "AI_STREAM_IDLE_TIMEOUT") return "网络超时";
3273
+ if (code === "AI_STREAM_NETWORK_ERROR") return "网络中断";
3274
+ if (code === "AI_STREAM_UPSTREAM_CLOSED") return "流被关闭";
3275
+ if (code === "AI_STREAM_REQUEST_CANCELLED") return "请求已取消";
3276
+ return "生成中断";
3277
+ }
3278
+
3279
+ function formatAiStreamInterruptionMeta(code, preservedCharacters) {
3280
+ const label = aiStreamInterruptionLabel(code);
3281
+ return preservedCharacters > 0 ? `${label} · 已保留 ${preservedCharacters} 字` : label;
3282
+ }
3283
+
2851
3284
  function formatAiFailureMessage(error) {
2852
3285
  const message = error instanceof Error ? error.message : String(error ?? "未知错误");
2853
3286
  const lines = [`调用失败:${message}`];
@@ -2946,7 +3379,7 @@ function invalidateModuleRequestsAfterMutation(path, method) {
2946
3379
  if (path.includes("/races")) affected.add("races");
2947
3380
  if (path.includes("/organizations")) affected.add("organizations");
2948
3381
  if (path.includes("/timeline")) affected.add("timeline");
2949
- if (path.includes("/outlines") || path.includes("/foreshadows")) affected.add("outlines");
3382
+ if (path.includes("/outlines") || path.includes("/foreshadows") || path.includes("/foreshadow-reminders")) affected.add("outlines");
2950
3383
  if (path.includes("/relationships")) affected.add("relationships");
2951
3384
  if (path.includes("/chapter-annotations/") || /\/chapters\/[^/]+\/annotations(?:$|\?)/u.test(path)) affected.add("comments");
2952
3385
  if (path.includes("/reviews")) affected.add("reviews");
@@ -3318,6 +3751,8 @@ function toast(message, type = "info") {
3318
3751
  const region = $("#toast-region");
3319
3752
  const element = document.createElement("div");
3320
3753
  element.className = `toast ${type}`;
3754
+ element.setAttribute("role", type === "error" ? "alert" : "status");
3755
+ element.setAttribute("aria-atomic", "true");
3321
3756
  element.textContent = message;
3322
3757
  region.append(element);
3323
3758
  raiseToastRegion();
@@ -3631,6 +4066,7 @@ async function persistChapter({ automatic = false } = {}) {
3631
4066
  }
3632
4067
  if (sameChapterSnapshot(draft, lastSavedChapterSnapshot)) {
3633
4068
  setSaveState(automatic ? "已自动保存" : collaborationAutoSaveDisabled ? "已保存 · 自动保存已关闭" : "已保存");
4069
+ if (!automatic) await loadChapterForeshadowReminders();
3634
4070
  return state.chapter;
3635
4071
  }
3636
4072
  const saveGuard = confirmConcurrentSave();
@@ -3667,6 +4103,7 @@ async function persistChapter({ automatic = false } = {}) {
3667
4103
  } else {
3668
4104
  scheduleChapterAutoSave(250);
3669
4105
  }
4106
+ await loadChapterForeshadowReminders();
3670
4107
  return state.chapter;
3671
4108
  } catch (error) {
3672
4109
  if (state.chapter?.id === draft.chapterId) setSaveState("自动保存失败", true);
@@ -3762,6 +4199,10 @@ async function initializePage() {
3762
4199
  await selectWork(requestedWork.id, route.view === "editor" ? route.chapterId : null);
3763
4200
  }
3764
4201
 
4202
+ if (route.view === "reader") {
4203
+ await openReadingPreview({ chapterId: route.chapterId, restorePosition: true });
4204
+ return;
4205
+ }
3765
4206
  if (route.view === "editor") {
3766
4207
  if (route.chapterId && state.chapter?.id !== route.chapterId) await selectChapter(route.chapterId);
3767
4208
  if (state.chapter?.id === route.chapterId && $("#editor-view").classList.contains("hidden")) await selectChapter(state.chapter.id);
@@ -4754,29 +5195,69 @@ function openGlobalReplaceDialog() {
4754
5195
  async function refreshWorkAfterGlobalReplace(route, result) {
4755
5196
  const workId = state.work?.id;
4756
5197
  if (!workId) return;
5198
+ const previousVolumes = Array.isArray(state.work.volumes) ? state.work.volumes : [];
5199
+ const refreshPlan = buildGlobalReplaceRefreshPlan({
5200
+ volumes: previousVolumes,
5201
+ collapsedVolumeIds: state.collapsedVolumeIds,
5202
+ selectedChapterId: state.chapter?.id,
5203
+ selectedChapterVolumeId: state.chapter?.volumeId,
5204
+ routeChapterId: route.view === "editor" ? route.chapterId : null,
5205
+ scope: String(result?.scope ?? ""),
5206
+ chapterCount: result?.chapterCount,
5207
+ settingCount: result?.settingCount
5208
+ });
4757
5209
  const nextWork = result?.work ?? await api(`/api/works/${encodeURIComponent(workId)}?directory=volumes`);
4758
5210
  if (!nextWork || nextWork.id !== workId) return;
5211
+ const refreshGeneration = refreshPlan.proseChanged ? ++workScopedUiGeneration : workScopedUiGeneration;
5212
+ const previousVolumeById = new Map(previousVolumes.map((volume) => [volume.id, volume]));
4759
5213
  state.work = nextWork;
4760
- state.work.volumes = state.work.volumes.map((volume) => ({ ...volume, chapters: Array.isArray(volume.chapters) ? volume.chapters : [] }));
5214
+ state.work.volumes = (Array.isArray(state.work.volumes) ? state.work.volumes : []).map((volume) => {
5215
+ const previousVolume = previousVolumeById.get(volume.id);
5216
+ return {
5217
+ ...volume,
5218
+ chapterCount: resolveGlobalReplaceChapterCount(volume, previousVolume),
5219
+ chapters: refreshPlan.proseChanged
5220
+ ? []
5221
+ : Array.isArray(previousVolume?.chapters)
5222
+ ? previousVolume.chapters
5223
+ : Array.isArray(volume.chapters) ? volume.chapters : []
5224
+ };
5225
+ });
4761
5226
  state.works = state.works.map((work) => work.id === workId ? { ...work, ...nextWork } : work);
4762
- state.settings = [];
4763
- loadedVolumeChapterIds.clear();
4764
- volumeChapterLoadingIds.clear();
4765
- volumeChapterRequests.clear();
4766
- for (const volume of state.work.volumes) loadedVolumeChapterIds.add(volume.id);
4767
- state.collapsedVolumeIds = new Set(state.work.volumes.map((volume) => volume.id));
4768
- if (String(result?.scope) === "prose" || String(result?.scope) === "prose-and-settings") {
4769
- state.chapter = null;
4770
- lastSavedChapterSnapshot = null;
5227
+ if (refreshPlan.settingsChanged) state.settings = [];
5228
+ if (refreshPlan.proseChanged) {
5229
+ loadedVolumeChapterIds.clear();
5230
+ volumeChapterLoadingIds.clear();
5231
+ volumeChapterRequests.clear();
4771
5232
  }
5233
+ const expandedVolumeIds = new Set(refreshPlan.expandedVolumeIds);
5234
+ state.collapsedVolumeIds = new Set(state.work.volumes
5235
+ .filter((volume) => !expandedVolumeIds.has(volume.id))
5236
+ .map((volume) => volume.id));
4772
5237
  applyWorkAccessMode();
4773
5238
  showSystemStatus();
4774
5239
  updateDocumentTitle(state.work);
4775
5240
  $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
4776
5241
  $("#top-search-button").disabled = !canReadAggregateContent();
4777
5242
  renderTree();
5243
+ if (refreshPlan.proseChanged) {
5244
+ await Promise.all(refreshPlan.reloadVolumeIds.map((volumeId) => loadVolumeChapters(volumeId)));
5245
+ if (state.work?.id !== workId || refreshGeneration !== workScopedUiGeneration) return;
5246
+ }
4778
5247
  if (route.view === "editor" && route.chapterId && canReadModule("editor")) {
5248
+ if (refreshPlan.proseChanged) {
5249
+ state.chapter = null;
5250
+ lastSavedChapterSnapshot = null;
5251
+ }
4779
5252
  await selectChapter(route.chapterId);
5253
+ if (refreshPlan.proseChanged && state.chapter?.volumeId) await loadVolumeChapters(state.chapter.volumeId);
5254
+ } else if (refreshPlan.proseChanged && refreshPlan.selectedChapterId) {
5255
+ const chapter = await api(`/api/chapters/${encodeURIComponent(refreshPlan.selectedChapterId)}`);
5256
+ if (state.work?.id !== workId || refreshGeneration !== workScopedUiGeneration) return;
5257
+ state.chapter = chapter;
5258
+ mergeChapterDirectoryEntry(chapter);
5259
+ lastSavedChapterSnapshot = { chapterId: chapter.id, title: chapter.title, content: chapter.content };
5260
+ await loadVolumeChapters(chapter.volumeId);
4780
5261
  } else if (route.view === "module") {
4781
5262
  await showModule(route.module);
4782
5263
  } else if (route.view === "settings") {
@@ -4827,10 +5308,17 @@ async function submitGlobalReplace(event) {
4827
5308
  const button = $("#replace-submit");
4828
5309
  const route = currentPageRoute();
4829
5310
  const workId = state.work.id;
5311
+ const hadDirtyDraft = state.dirty;
4830
5312
  button.disabled = true;
4831
5313
  button.textContent = "替换中…";
4832
5314
  cancelChapterAutoSave();
4833
5315
  state.dirty = false;
5316
+ let replacementApplied = false;
5317
+ const restoreDraftAfterNoop = () => {
5318
+ if (!hadDirtyDraft) return;
5319
+ if (state.chapter && canEditProse() && !chapterEditorReadOnly) scheduleChapterAutoSave();
5320
+ else state.dirty = true;
5321
+ };
4834
5322
  try {
4835
5323
  const result = await api(`/api/works/${encodeURIComponent(workId)}/replace`, {
4836
5324
  method: "POST",
@@ -4838,16 +5326,20 @@ async function submitGlobalReplace(event) {
4838
5326
  skipOptimisticVersion: true
4839
5327
  });
4840
5328
  $("#replace-dialog").close();
4841
- if (Number(result.totalMatches) > 0) await refreshWorkAfterGlobalReplace(route, result);
4842
5329
  if (Number(result.totalMatches) > 0) {
5330
+ replacementApplied = true;
5331
+ await refreshWorkAfterGlobalReplace(route, result);
5332
+ if (Number(result.chapterCount) === 0) restoreDraftAfterNoop();
4843
5333
  const changedTargets = [];
4844
5334
  if (Number(result.chapterCount) > 0) changedTargets.push(`${result.chapterCount} 章`);
4845
5335
  if (Number(result.settingCount) > 0) changedTargets.push(`${result.settingCount} 条设定`);
4846
5336
  toast(`全局替换完成:${result.totalMatches} 处,已更新 ${changedTargets.join("、")}`);
4847
5337
  } else {
5338
+ restoreDraftAfterNoop();
4848
5339
  toast("没有找到需要替换的内容");
4849
5340
  }
4850
5341
  } catch (error) {
5342
+ if (!replacementApplied) restoreDraftAfterNoop();
4851
5343
  toast(error.message, "error");
4852
5344
  } finally {
4853
5345
  button.disabled = false;
@@ -5098,6 +5590,9 @@ function renderShelf() {
5098
5590
  }
5099
5591
 
5100
5592
  function resetWorkScopedUiCaches() {
5593
+ chapterSelectionRequestId += 1;
5594
+ clearChapterForeshadowReminders({ invalidateRequest: true });
5595
+ invalidateAiConversationNavigation("已切换作品");
5101
5596
  stopBackgroundTaskCenter();
5102
5597
  workScopedUiGeneration += 1;
5103
5598
  loadedAiModelsWorkId = null;
@@ -5126,6 +5621,10 @@ function resetWorkScopedUiCaches() {
5126
5621
  settingFilters.category = "";
5127
5622
  settingFilters.lockState = "all";
5128
5623
  settingFiltersPanelOpen = false;
5624
+ Object.assign(outlineBoardFilters, normalizeOutlineBoardState());
5625
+ outlineBoardFiltersPanelOpen = false;
5626
+ outlineBoardRenderRequestId += 1;
5627
+ chapterSelectionRequestGeneration += 1;
5129
5628
  Object.keys(moduleListPages).forEach((key) => { moduleListPages[key] = 1; });
5130
5629
  relationshipFilters.fromCharacterIds = [];
5131
5630
  relationshipFilters.toCharacterIds = [];
@@ -5159,8 +5658,11 @@ function resetWorkScopedUiCaches() {
5159
5658
  }
5160
5659
 
5161
5660
  async function selectWork(workId, preferredChapterId = null) {
5661
+ chapterSelectionRequestGeneration += 1;
5162
5662
  const discarding = state.work?.id !== workId && state.dirty;
5163
5663
  if (discarding && !(await confirmDiscardChanges())) return false;
5664
+ const selectionGeneration = ++workSelectionRequestGeneration;
5665
+ invalidateAiConversationNavigation(state.work?.id === workId ? "已重新载入作品" : "已切换作品");
5164
5666
  if (state.work?.id === workId) {
5165
5667
  workScopedUiGeneration += 1;
5166
5668
  loadedVolumeChapterIds.clear();
@@ -5168,6 +5670,7 @@ async function selectWork(workId, preferredChapterId = null) {
5168
5670
  volumeChapterRequests.clear();
5169
5671
  }
5170
5672
  const nextWork = await api(`/api/works/${workId}?directory=volumes`);
5673
+ if (selectionGeneration !== workSelectionRequestGeneration) return false;
5171
5674
  if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
5172
5675
  showSystemStatus();
5173
5676
  $("#app").classList.remove("shelf-mode");
@@ -5222,9 +5725,11 @@ async function loadVolumeChapters(volumeId) {
5222
5725
  renderTree();
5223
5726
  }
5224
5727
  } finally {
5225
- volumeChapterLoadingIds.delete(volumeId);
5226
- volumeChapterRequests.delete(volumeId);
5227
- if (state.work?.id === workId && generation === workScopedUiGeneration) renderTree();
5728
+ if (volumeChapterRequests.get(volumeId) === request) {
5729
+ volumeChapterLoadingIds.delete(volumeId);
5730
+ volumeChapterRequests.delete(volumeId);
5731
+ if (state.work?.id === workId && generation === workScopedUiGeneration) renderTree();
5732
+ }
5228
5733
  }
5229
5734
  })();
5230
5735
  volumeChapterRequests.set(volumeId, request);
@@ -5232,9 +5737,10 @@ async function loadVolumeChapters(volumeId) {
5232
5737
  }
5233
5738
 
5234
5739
  async function loadAllVolumeChapters(workId) {
5740
+ const generation = workScopedUiGeneration;
5235
5741
  const volumeIds = state.work?.id === workId ? state.work.volumes.map((volume) => volume.id) : [];
5236
5742
  for (const volumeId of volumeIds) {
5237
- if (state.work?.id !== workId) return;
5743
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
5238
5744
  await loadVolumeChapters(volumeId);
5239
5745
  }
5240
5746
  }
@@ -5271,6 +5777,7 @@ function renderTree() {
5271
5777
  const count = state.work.volumes.reduce((total, volume) => total + Number(volume.chapterCount ?? volume.chapters?.length ?? 0), 0);
5272
5778
  const proseEditable = canEditProse();
5273
5779
  $("#chapter-count").textContent = `${count} 章`;
5780
+ $("#reader-open-button").disabled = !canReadModule("editor") || count === 0;
5274
5781
  $("#novel-tree").classList.remove("empty-copy");
5275
5782
  $("#novel-tree").innerHTML = state.work.volumes.map((volume) => {
5276
5783
  const collapsed = state.collapsedVolumeIds.has(volume.id);
@@ -5291,6 +5798,7 @@ function renderTree() {
5291
5798
  <div class="volume-node ${collapsed ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
5292
5799
  <div class="volume-title">
5293
5800
  <button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${collapsed ? "false" : "true"}" title="左键展开或折叠;右键设置分卷;可将章节拖到这里追加"><span>${esc(volume.title)}</span><span>${Number(volume.chapterCount ?? chapters.length)} 章</span></button>
5801
+ <button class="chapter-batch-button volume-export-button" type="button" data-export-volume="${esc(volume.id)}" aria-label="导出“${esc(volume.title)}”为 EPUB" title="导出当前分卷为 EPUB"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg></button>
5294
5802
  ${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>` : ""}
5295
5803
  </div>
5296
5804
  <div class="volume-chapters">
@@ -5331,6 +5839,12 @@ function renderTree() {
5331
5839
  $("#novel-tree").querySelectorAll("[data-new-chapter-volume]").forEach((button) => {
5332
5840
  button.addEventListener("click", () => openChapterDialog(button.dataset.newChapterVolume));
5333
5841
  });
5842
+ $("#novel-tree").querySelectorAll("[data-export-volume]").forEach((button) => {
5843
+ button.addEventListener("click", () => {
5844
+ const volume = state.work?.volumes.find((item) => item.id === button.dataset.exportVolume);
5845
+ if (volume) void downloadVolumeEpub(volume, button);
5846
+ });
5847
+ });
5334
5848
  $("#novel-tree").querySelectorAll("[data-chapter-id]").forEach((button) => {
5335
5849
  button.addEventListener("click", async () => {
5336
5850
  const chapterId = button.dataset.chapterId;
@@ -5590,13 +6104,219 @@ async function deleteChapter(chapterId) {
5590
6104
  }
5591
6105
  }
5592
6106
 
6107
+ function currentChapterForeshadowReminder() {
6108
+ return chapterForeshadowReminders[chapterForeshadowReminderIndex] ?? null;
6109
+ }
6110
+
6111
+ function syncMobileAiPanelSafeTop() {
6112
+ const container = $("#chapter-foreshadow-reminder");
6113
+ const safeTop = container.classList.contains("hidden")
6114
+ ? 0
6115
+ : Math.ceil(container.getBoundingClientRect().bottom);
6116
+ $("#app").style.setProperty("--mobile-ai-panel-safe-top", `${safeTop}px`);
6117
+ }
6118
+
6119
+ function renderChapterForeshadowReminder() {
6120
+ const container = $("#chapter-foreshadow-reminder");
6121
+ const reminder = currentChapterForeshadowReminder();
6122
+ if (!reminder || !state.chapter || !state.work) {
6123
+ container.classList.add("hidden");
6124
+ container.removeAttribute("data-role");
6125
+ container.removeAttribute("data-multiple");
6126
+ $("#chapter-foreshadow-reminder-details").classList.add("hidden");
6127
+ $("#chapter-foreshadow-reminder-details-button").setAttribute("aria-expanded", "false");
6128
+ syncMobileAiPanelSafeTop();
6129
+ return;
6130
+ }
6131
+ const roleLabel = reminder.role === "payoff" ? "回收章" : "提醒章";
6132
+ container.dataset.role = reminder.role;
6133
+ $("#chapter-foreshadow-reminder-role").textContent = roleLabel;
6134
+ $("#chapter-foreshadow-reminder-title").textContent = `本章是伏笔《${reminder.title}》的${roleLabel}`;
6135
+ $("#chapter-foreshadow-reminder-context").textContent = reminder.note.trim()
6136
+ || reminder.description.trim()
6137
+ || `重要程度:${levelLabel(reminder.importance)}`;
6138
+ $("#chapter-foreshadow-reminder-counter").textContent = `${chapterForeshadowReminderIndex + 1} / ${chapterForeshadowReminders.length}`;
6139
+ const hasMultiple = chapterForeshadowReminders.length > 1;
6140
+ container.dataset.multiple = String(hasMultiple);
6141
+ const previous = $("#chapter-foreshadow-reminder-previous");
6142
+ const next = $("#chapter-foreshadow-reminder-next");
6143
+ previous.classList.toggle("hidden", !hasMultiple);
6144
+ next.classList.toggle("hidden", !hasMultiple);
6145
+ previous.disabled = chapterForeshadowReminderIndex === 0;
6146
+ next.disabled = chapterForeshadowReminderIndex >= chapterForeshadowReminders.length - 1;
6147
+ $("#chapter-foreshadow-reminder-description").textContent = reminder.description;
6148
+ $("#chapter-foreshadow-reminder-description-row").classList.toggle("hidden", !reminder.description.trim());
6149
+ $("#chapter-foreshadow-reminder-note").textContent = reminder.note;
6150
+ $("#chapter-foreshadow-reminder-note-row").classList.toggle("hidden", !reminder.note.trim());
6151
+ $("#chapter-foreshadow-reminder-importance").textContent = levelLabel(reminder.importance);
6152
+ const details = $("#chapter-foreshadow-reminder-details");
6153
+ const detailsButton = $("#chapter-foreshadow-reminder-details-button");
6154
+ details.classList.toggle("hidden", !chapterForeshadowReminderDetailsExpanded);
6155
+ detailsButton.setAttribute("aria-expanded", String(chapterForeshadowReminderDetailsExpanded));
6156
+ detailsButton.textContent = chapterForeshadowReminderDetailsExpanded ? "收起详情" : "查看详情";
6157
+ const snooze = $("#chapter-foreshadow-reminder-snooze");
6158
+ snooze.setAttribute("aria-label", `本次会话暂不处理伏笔“${reminder.title}”`);
6159
+ const resolve = $("#chapter-foreshadow-reminder-resolve");
6160
+ resolve.classList.toggle("hidden", !canEditModule("outlines"));
6161
+ resolve.disabled = chapterForeshadowReminderResolveInFlight;
6162
+ resolve.textContent = chapterForeshadowReminderResolveInFlight ? "正在标记" : "标记已回收";
6163
+ resolve.setAttribute("aria-label", `将伏笔“${reminder.title}”标记为已回收`);
6164
+ container.classList.remove("hidden");
6165
+ syncMobileAiPanelSafeTop();
6166
+ }
6167
+
6168
+ function clearChapterForeshadowReminders({ invalidateRequest = false } = {}) {
6169
+ if (invalidateRequest) chapterForeshadowReminderRequestId += 1;
6170
+ chapterForeshadowReminders = [];
6171
+ chapterForeshadowReminderIndex = 0;
6172
+ chapterForeshadowReminderDetailsExpanded = false;
6173
+ chapterForeshadowReminderResolveInFlight = false;
6174
+ renderChapterForeshadowReminder();
6175
+ }
6176
+
6177
+ function persistForeshadowReminderSnoozes() {
6178
+ try {
6179
+ presenceSessionStorage?.setItem(
6180
+ FORESHADOW_REMINDER_SNOOZE_STORAGE_KEY,
6181
+ serializeForeshadowReminderSnoozes(foreshadowReminderSnoozes)
6182
+ );
6183
+ } catch {
6184
+ // 浏览器禁用会话存储时,当前页面内的静默状态仍然有效。
6185
+ }
6186
+ }
6187
+
6188
+ function focusAfterChapterForeshadowReminderAction() {
6189
+ queueMicrotask(() => {
6190
+ if (currentChapterForeshadowReminder()) $("#chapter-foreshadow-reminder-details-button").focus();
6191
+ else $("#chapter-content").focus({ preventScroll: true });
6192
+ });
6193
+ }
6194
+
6195
+ async function loadChapterForeshadowReminders({ preserveOccurrenceId = null, focusAfterUpdate = false } = {}) {
6196
+ const workId = state.work?.id;
6197
+ const chapterId = state.chapter?.id;
6198
+ const requestId = ++chapterForeshadowReminderRequestId;
6199
+ const target = { workId, chapterId };
6200
+ if (!workId || !chapterId || state.module !== "editor" || !canReadModule("outlines")) {
6201
+ clearChapterForeshadowReminders();
6202
+ if (focusAfterUpdate) focusAfterChapterForeshadowReminderAction();
6203
+ return false;
6204
+ }
6205
+ const previousOccurrenceId = preserveOccurrenceId ?? currentChapterForeshadowReminder()?.occurrenceId ?? null;
6206
+ try {
6207
+ const reminders = await api(`/api/works/${encodeURIComponent(workId)}/chapters/${encodeURIComponent(chapterId)}/foreshadow-reminders`);
6208
+ if (
6209
+ requestId !== chapterForeshadowReminderRequestId
6210
+ || state.module !== "editor"
6211
+ || $("#editor-view").classList.contains("hidden")
6212
+ || !foreshadowReminderRequestTargetsState(target, { workId: state.work?.id, chapterId: state.chapter?.id })
6213
+ ) return false;
6214
+ chapterForeshadowReminders = visibleForeshadowReminders(
6215
+ reminders,
6216
+ workId,
6217
+ chapterId,
6218
+ foreshadowReminderSnoozes
6219
+ );
6220
+ const preservedIndex = previousOccurrenceId
6221
+ ? chapterForeshadowReminders.findIndex((item) => item.occurrenceId === previousOccurrenceId)
6222
+ : -1;
6223
+ chapterForeshadowReminderIndex = preservedIndex >= 0 ? preservedIndex : 0;
6224
+ chapterForeshadowReminderDetailsExpanded = false;
6225
+ chapterForeshadowReminderResolveInFlight = false;
6226
+ renderChapterForeshadowReminder();
6227
+ if (focusAfterUpdate) focusAfterChapterForeshadowReminderAction();
6228
+ return true;
6229
+ } catch (error) {
6230
+ if (
6231
+ requestId !== chapterForeshadowReminderRequestId
6232
+ || !foreshadowReminderRequestTargetsState(target, { workId: state.work?.id, chapterId: state.chapter?.id })
6233
+ ) return false;
6234
+ clearChapterForeshadowReminders();
6235
+ if (focusAfterUpdate) focusAfterChapterForeshadowReminderAction();
6236
+ toast("伏笔提醒读取失败,请稍后重试", "error");
6237
+ return false;
6238
+ }
6239
+ }
6240
+
6241
+ function showChapterForeshadowReminderAt(index) {
6242
+ if (!Number.isInteger(index) || index < 0 || index >= chapterForeshadowReminders.length) return;
6243
+ chapterForeshadowReminderIndex = index;
6244
+ chapterForeshadowReminderDetailsExpanded = false;
6245
+ renderChapterForeshadowReminder();
6246
+ }
6247
+
6248
+ function snoozeCurrentChapterForeshadowReminder() {
6249
+ const reminder = currentChapterForeshadowReminder();
6250
+ const workId = state.work?.id;
6251
+ const chapterId = state.chapter?.id;
6252
+ const key = foreshadowReminderSnoozeKey(workId, chapterId, reminder);
6253
+ if (!reminder || !key) return;
6254
+ foreshadowReminderSnoozes.delete(key);
6255
+ foreshadowReminderSnoozes.add(key);
6256
+ persistForeshadowReminderSnoozes();
6257
+ chapterForeshadowReminders = chapterForeshadowReminders.filter((item) => item.occurrenceId !== reminder.occurrenceId);
6258
+ chapterForeshadowReminderIndex = Math.min(chapterForeshadowReminderIndex, Math.max(0, chapterForeshadowReminders.length - 1));
6259
+ chapterForeshadowReminderDetailsExpanded = false;
6260
+ renderChapterForeshadowReminder();
6261
+ focusAfterChapterForeshadowReminderAction();
6262
+ toast("本次会话将不再提示这条伏笔");
6263
+ }
6264
+
6265
+ async function resolveCurrentChapterForeshadowReminder() {
6266
+ const reminder = currentChapterForeshadowReminder();
6267
+ const workId = state.work?.id;
6268
+ const chapterId = state.chapter?.id;
6269
+ if (!reminder || !workId || !chapterId || !canEditModule("outlines") || chapterForeshadowReminderResolveInFlight) return;
6270
+ const target = { workId, chapterId };
6271
+ chapterForeshadowReminderResolveInFlight = true;
6272
+ renderChapterForeshadowReminder();
6273
+ try {
6274
+ await api(`/api/works/${encodeURIComponent(workId)}/chapters/${encodeURIComponent(chapterId)}/foreshadow-reminders/${encodeURIComponent(reminder.foreshadowId)}/resolve`, {
6275
+ method: "POST",
6276
+ body: { expectedVersionNo: reminder.versionNo }
6277
+ });
6278
+ if (!foreshadowReminderRequestTargetsState(target, { workId: state.work?.id, chapterId: state.chapter?.id })) return;
6279
+ chapterForeshadowReminders = chapterForeshadowReminders.filter((item) => item.foreshadowId !== reminder.foreshadowId);
6280
+ chapterForeshadowReminderIndex = Math.min(chapterForeshadowReminderIndex, Math.max(0, chapterForeshadowReminders.length - 1));
6281
+ chapterForeshadowReminderResolveInFlight = false;
6282
+ chapterForeshadowReminderDetailsExpanded = false;
6283
+ renderChapterForeshadowReminder();
6284
+ toast(`伏笔“${reminder.title}”已标记为已回收`);
6285
+ await loadChapterForeshadowReminders({ focusAfterUpdate: true });
6286
+ } catch (error) {
6287
+ if (!foreshadowReminderRequestTargetsState(target, { workId: state.work?.id, chapterId: state.chapter?.id })) return;
6288
+ chapterForeshadowReminderResolveInFlight = false;
6289
+ renderChapterForeshadowReminder();
6290
+ if (error.code === "VERSION_CONFLICT" || error.status === 404) {
6291
+ await loadChapterForeshadowReminders({ focusAfterUpdate: true });
6292
+ toast("伏笔状态已变化,提醒已刷新");
6293
+ return;
6294
+ }
6295
+ toast(error.message, "error");
6296
+ }
6297
+ }
6298
+
5593
6299
  async function selectChapter(chapterId, { editMode = false } = {}) {
5594
- if (state.chapter?.id !== chapterId && !(await confirmDiscardChanges("当前章节有未保存修改,仍要切换吗?"))) return;
6300
+ const workId = state.work?.id;
6301
+ if (!workId) return false;
6302
+ const selectionGeneration = ++chapterSelectionRequestGeneration;
6303
+ const selectionRequestId = ++chapterSelectionRequestId;
6304
+ clearChapterForeshadowReminders({ invalidateRequest: true });
6305
+ if (state.chapter?.id !== chapterId && !(await confirmDiscardChanges("当前章节有未保存修改,仍要切换吗?"))) {
6306
+ if (selectionRequestId === chapterSelectionRequestId) void loadChapterForeshadowReminders();
6307
+ return false;
6308
+ }
6309
+ if (selectionGeneration !== chapterSelectionRequestGeneration || selectionRequestId !== chapterSelectionRequestId || state.work?.id !== workId) return false;
5595
6310
  cancelChapterAutoSave();
6311
+ let selectedChapter = state.chapter;
5596
6312
  if (state.chapter?.id !== chapterId) {
5597
- state.chapter = await api(`/api/chapters/${chapterId}`);
5598
- mergeChapterDirectoryEntry(state.chapter);
6313
+ selectedChapter = await api(`/api/chapters/${encodeURIComponent(chapterId)}`);
6314
+ if (selectionGeneration !== chapterSelectionRequestGeneration || selectionRequestId !== chapterSelectionRequestId || state.work?.id !== workId) return false;
6315
+ if (String(selectedChapter?.id ?? "") !== String(chapterId) || String(selectedChapter?.workId ?? "") !== String(workId)) return false;
5599
6316
  }
6317
+ if (!selectedChapter || selectionGeneration !== chapterSelectionRequestGeneration || selectionRequestId !== chapterSelectionRequestId || state.work?.id !== workId) return false;
6318
+ state.chapter = selectedChapter;
6319
+ mergeChapterDirectoryEntry(state.chapter);
5600
6320
  state.collapsedVolumeIds.delete(state.chapter.volumeId);
5601
6321
  lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
5602
6322
  chapterEditorReadOnly = !canEditProse() || !editMode;
@@ -5624,6 +6344,8 @@ async function selectChapter(chapterId, { editMode = false } = {}) {
5624
6344
  else setSaveState("已保存");
5625
6345
  renderTree();
5626
6346
  replacePageRoute({ view: "editor", workId: state.work.id, chapterId: state.chapter.id });
6347
+ await loadChapterForeshadowReminders();
6348
+ return true;
5627
6349
  }
5628
6350
 
5629
6351
  function updateChapterStats() {
@@ -5633,27 +6355,508 @@ function updateChapterStats() {
5633
6355
  $("#chapter-stats").textContent = `${count} 字 · v${state.chapter.versionNo}`;
5634
6356
  }
5635
6357
 
5636
- async function saveChapter() {
5637
- return persistChapter({ automatic: false });
6358
+ function readReadingStorage(key) {
6359
+ try {
6360
+ const value = localStorage.getItem(key);
6361
+ return value ? JSON.parse(value) : null;
6362
+ } catch {
6363
+ return null;
6364
+ }
5638
6365
  }
5639
6366
 
5640
- function tidyChapterBlankLines() {
5641
- if (!state.chapter) return toast("请先选择章节", "error");
5642
- if (chapterEditorReadOnly || !canEditProse()) return toast("请先进入编辑模式", "error");
5643
- const input = $("#chapter-content");
5644
- const normalized = normalizeParagraphSpacing(input.value);
5645
- if (normalized === input.value) return toast("正文空行已经符合要求");
5646
- input.value = normalized;
5647
- scheduleChapterLineNumbers();
5648
- updateChapterStats();
5649
- scheduleChapterAutoSave(120);
5650
- toast("已整理空行:段与段之间保留 1 个空行");
6367
+ function storedReadingPosition() {
6368
+ if (!state.work) return null;
6369
+ return normalizeReadingPosition(readReadingStorage(readingPositionStorageKey(state.work.id)), readingSequence);
5651
6370
  }
5652
6371
 
5653
- function showWelcome(hasWork = false) {
5654
- dismissChapterInsightToast();
5655
- $("#editor-view").classList.add("hidden");
5656
- $("#module-view").classList.add("hidden");
6372
+ function persistReadingPreferences() {
6373
+ try { localStorage.setItem(READING_PREFERENCES_STORAGE_KEY, JSON.stringify(readingPreferences)); } catch { /* 禁用本地存储时保留本次会话设置 */ }
6374
+ }
6375
+
6376
+ function readingProgressRatio() {
6377
+ if (readingPreferences.mode === "paged") {
6378
+ return readingPageCount > 1 ? readingPageIndex / (readingPageCount - 1) : 0;
6379
+ }
6380
+ const viewport = $("#reader-viewport");
6381
+ const maximum = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
6382
+ return maximum > 0 ? Math.min(1, Math.max(0, viewport.scrollTop / maximum)) : 0;
6383
+ }
6384
+
6385
+ function persistReadingPosition() {
6386
+ if (
6387
+ !state.work
6388
+ || !readingTargetChapter
6389
+ || readingLoading
6390
+ || readingLoadedChapter?.id !== readingTargetChapter.id
6391
+ || $("#reader-view").classList.contains("hidden")
6392
+ ) return;
6393
+ const value = {
6394
+ chapterId: readingTargetChapter.id,
6395
+ scrollRatio: readingPreferences.mode === "scroll" ? readingProgressRatio() : 0,
6396
+ pageIndex: readingPreferences.mode === "paged" ? readingPageIndex : 0
6397
+ };
6398
+ try { localStorage.setItem(readingPositionStorageKey(state.work.id), JSON.stringify(value)); } catch { /* 阅读位置只做本地尽力保存 */ }
6399
+ }
6400
+
6401
+ function scheduleReadingPositionSave() {
6402
+ if (readingLoading || readingLoadedChapter?.id !== readingTargetChapter?.id) return;
6403
+ if (readingPositionSaveTimer !== null) clearTimeout(readingPositionSaveTimer);
6404
+ readingPositionSaveTimer = setTimeout(() => {
6405
+ readingPositionSaveTimer = null;
6406
+ persistReadingPosition();
6407
+ }, 120);
6408
+ }
6409
+
6410
+ function setSelectOptions(select, items, selectedValue) {
6411
+ const options = items.map(({ value, label }) => {
6412
+ const option = document.createElement("option");
6413
+ option.value = value;
6414
+ option.textContent = label;
6415
+ return option;
6416
+ });
6417
+ select.replaceChildren(...options);
6418
+ select.value = selectedValue ?? "";
6419
+ }
6420
+
6421
+ function renderReadingSelectors() {
6422
+ const selectedId = readingTargetChapter?.id ?? "";
6423
+ const volumeItems = (state.work?.volumes ?? [])
6424
+ .filter((volume) => readingSequence.some((chapter) => chapter.volumeId === volume.id))
6425
+ .map((volume) => ({ value: String(volume.id), label: String(volume.title || "正文") }));
6426
+ setSelectOptions($("#reader-volume"), volumeItems, readingTargetChapter?.volumeId ?? volumeItems[0]?.value);
6427
+
6428
+ const chapterSelect = $("#reader-chapter");
6429
+ const groups = [];
6430
+ for (const volume of state.work?.volumes ?? []) {
6431
+ const chapters = readingSequence.filter((chapter) => chapter.volumeId === volume.id);
6432
+ if (!chapters.length) continue;
6433
+ const group = document.createElement("optgroup");
6434
+ group.label = String(volume.title || "正文");
6435
+ for (const chapter of chapters) {
6436
+ const option = document.createElement("option");
6437
+ option.value = chapter.id;
6438
+ option.textContent = chapter.title;
6439
+ group.append(option);
6440
+ }
6441
+ groups.push(group);
6442
+ }
6443
+ chapterSelect.replaceChildren(...groups);
6444
+ chapterSelect.value = selectedId;
6445
+ }
6446
+
6447
+ function renderReadingNavigation() {
6448
+ const current = readingTargetChapter;
6449
+ const index = current ? readingSequence.findIndex((chapter) => chapter.id === current.id) : -1;
6450
+ const previous = current ? adjacentReadingChapter(readingSequence, current.id, -1) : null;
6451
+ const next = current ? adjacentReadingChapter(readingSequence, current.id, 1) : null;
6452
+ $("#reader-previous").disabled = !previous || readingLoading;
6453
+ $("#reader-next").disabled = !next || readingLoading;
6454
+ $("#reader-continue").disabled = !next || readingLoading;
6455
+ $("#reader-continue").textContent = next ? `继续下一章 · ${next.title}` : "已读到全书末尾";
6456
+ const paged = readingPreferences.mode === "paged";
6457
+ const previousPage = current && paged ? resolvePagedReadingStep({
6458
+ sequence: readingSequence,
6459
+ chapterId: current.id,
6460
+ pageIndex: readingPageIndex,
6461
+ pageCount: readingPageCount
6462
+ }, -1) : null;
6463
+ const nextPage = current && paged ? resolvePagedReadingStep({
6464
+ sequence: readingSequence,
6465
+ chapterId: current.id,
6466
+ pageIndex: readingPageIndex,
6467
+ pageCount: readingPageCount
6468
+ }, 1) : null;
6469
+ $("#reader-page-previous").disabled = !previousPage || readingLoading;
6470
+ $("#reader-page-next").disabled = !nextPage || readingLoading;
6471
+ const compact = window.matchMedia("(max-width: 700px)").matches;
6472
+ const chapterProgress = index >= 0
6473
+ ? compact ? `${index + 1}/${readingSequence.length} 章` : `第 ${index + 1} / ${readingSequence.length} 章`
6474
+ : compact ? `0/${readingSequence.length} 章` : `第 0 / ${readingSequence.length} 章`;
6475
+ $("#reader-progress").textContent = paged
6476
+ ? compact
6477
+ ? `${chapterProgress} · ${readingPageIndex + 1}/${readingPageCount} 页`
6478
+ : `${chapterProgress} · 第 ${readingPageIndex + 1} / ${readingPageCount} 页`
6479
+ : chapterProgress;
6480
+ }
6481
+
6482
+ function applyReadingPreferences() {
6483
+ const view = $("#reader-view");
6484
+ const viewport = $("#reader-viewport");
6485
+ view.dataset.readerTheme = readingPreferences.theme;
6486
+ view.style.setProperty("--reader-font-size", `${readingPreferences.fontSize}px`);
6487
+ view.style.setProperty("--reader-line-height", String(readingPreferences.lineHeight));
6488
+ viewport.classList.toggle("is-paged", readingPreferences.mode === "paged");
6489
+ $("#reader-mode").value = readingPreferences.mode;
6490
+ $("#reader-font-size").value = String(readingPreferences.fontSize);
6491
+ $("#reader-line-height").value = String(readingPreferences.lineHeight);
6492
+ $("#reader-theme").value = readingPreferences.theme;
6493
+ $("#reader-continuation").classList.toggle("hidden", readingPreferences.mode === "paged");
6494
+ $("#reader-page-previous").classList.toggle("hidden", readingPreferences.mode !== "paged");
6495
+ $("#reader-page-next").classList.toggle("hidden", readingPreferences.mode !== "paged");
6496
+ }
6497
+
6498
+ function resetReadingPageLayout() {
6499
+ const view = $("#reader-view");
6500
+ const content = $("#reader-content");
6501
+ view.style.removeProperty("--reader-page-width");
6502
+ view.style.removeProperty("--reader-page-height");
6503
+ content.style.removeProperty("width");
6504
+ content.style.removeProperty("height");
6505
+ content.style.removeProperty("transform");
6506
+ readingPageCount = 1;
6507
+ readingPageIndex = 0;
6508
+ }
6509
+
6510
+ function setReadingPageIndex(pageIndex) {
6511
+ const view = $("#reader-view");
6512
+ const content = $("#reader-content");
6513
+ const shell = $("#reader-page-shell");
6514
+ const styles = getComputedStyle(content);
6515
+ const gap = Number.parseFloat(styles.columnGap) || 0;
6516
+ const width = Math.max(1, shell.clientWidth);
6517
+ readingPageIndex = Math.min(readingPageCount - 1, Math.max(0, Math.floor(Number(pageIndex) || 0)));
6518
+ content.style.transform = `translate3d(${-readingPageIndex * (width + gap)}px, 0, 0)`;
6519
+ renderReadingNavigation();
6520
+ scheduleReadingPositionSave();
6521
+ view.dataset.readerPage = String(readingPageIndex + 1);
6522
+ }
6523
+
6524
+ function layoutReadingPages({ pageIndex = readingPageIndex, progressRatio = null } = {}) {
6525
+ if (readingPreferences.mode !== "paged" || !readingLoadedChapter) return;
6526
+ const view = $("#reader-view");
6527
+ const shell = $("#reader-page-shell");
6528
+ const content = $("#reader-content");
6529
+ const width = Math.max(1, shell.clientWidth);
6530
+ const height = Math.max(1, shell.clientHeight);
6531
+ view.style.setProperty("--reader-page-width", `${width}px`);
6532
+ view.style.setProperty("--reader-page-height", `${height}px`);
6533
+ content.style.width = `${width}px`;
6534
+ content.style.height = `${height}px`;
6535
+ content.style.transform = "none";
6536
+ const gap = Number.parseFloat(getComputedStyle(content).columnGap) || 0;
6537
+ readingPageCount = Math.max(1, Math.ceil((content.scrollWidth + gap) / (width + gap)));
6538
+ const target = progressRatio === null
6539
+ ? pageIndex < 0 ? readingPageCount - 1 : pageIndex
6540
+ : Math.round(Math.min(1, Math.max(0, progressRatio)) * (readingPageCount - 1));
6541
+ setReadingPageIndex(target);
6542
+ }
6543
+
6544
+ function appendReadingParagraph(parent, text) {
6545
+ const paragraph = document.createElement("p");
6546
+ const lines = text.split("\n");
6547
+ lines.forEach((line, index) => {
6548
+ if (index > 0) paragraph.append(document.createElement("br"));
6549
+ paragraph.append(document.createTextNode(line));
6550
+ });
6551
+ parent.append(paragraph);
6552
+ }
6553
+
6554
+ function renderReadingContent(content) {
6555
+ const host = $("#reader-content");
6556
+ host.replaceChildren();
6557
+ const normalized = String(content ?? "").replace(/\r\n?/gu, "\n").trim();
6558
+ if (!normalized) {
6559
+ const empty = document.createElement("p");
6560
+ empty.className = "reader-empty";
6561
+ empty.textContent = "本章暂无正文。";
6562
+ host.append(empty);
6563
+ return;
6564
+ }
6565
+ normalized.split(/\n{2,}/gu).forEach((paragraph) => appendReadingParagraph(host, paragraph));
6566
+ }
6567
+
6568
+ function renderReadingStatus(message, className = "reader-loading") {
6569
+ const status = document.createElement("p");
6570
+ status.className = className;
6571
+ status.textContent = message;
6572
+ $("#reader-content").replaceChildren(status);
6573
+ }
6574
+
6575
+ function renderReadingError(error, chapterId) {
6576
+ const status = document.createElement("div");
6577
+ status.className = "reader-error";
6578
+ const message = document.createElement("span");
6579
+ message.textContent = `章节载入失败:${error instanceof Error ? error.message : "网络请求失败"}`;
6580
+ const retry = document.createElement("button");
6581
+ retry.type = "button";
6582
+ retry.textContent = "重试本章";
6583
+ retry.addEventListener("click", () => void loadReadingChapter(chapterId, { restorePosition: true }));
6584
+ status.append(message, retry);
6585
+ $("#reader-content").replaceChildren(status);
6586
+ }
6587
+
6588
+ function nextReaderFrame() {
6589
+ return new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
6590
+ }
6591
+
6592
+ async function restoreReadingViewport({ scrollRatio = 0, pageIndex = 0 } = {}) {
6593
+ await nextReaderFrame();
6594
+ if (readingPreferences.mode === "paged") {
6595
+ layoutReadingPages({ pageIndex });
6596
+ return;
6597
+ }
6598
+ resetReadingPageLayout();
6599
+ await nextReaderFrame();
6600
+ const viewport = $("#reader-viewport");
6601
+ const maximum = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
6602
+ viewport.scrollTop = maximum * Math.min(1, Math.max(0, Number(scrollRatio) || 0));
6603
+ renderReadingNavigation();
6604
+ }
6605
+
6606
+ async function loadReadingChapter(chapterId, { scrollRatio = null, pageIndex = null, restorePosition = false } = {}) {
6607
+ const target = readingSequence.find((chapter) => chapter.id === chapterId);
6608
+ if (!target || !state.work) return false;
6609
+ const saved = restorePosition ? storedReadingPosition() : null;
6610
+ const targetScrollRatio = scrollRatio ?? (saved?.chapterId === target.id ? saved.scrollRatio : 0);
6611
+ const targetPageIndex = pageIndex ?? (saved?.chapterId === target.id ? saved.pageIndex : 0);
6612
+ const request = readingRequestGate.begin(target.id);
6613
+ readingTargetChapter = target;
6614
+ readingLoadedChapter = null;
6615
+ readingLoading = true;
6616
+ readingPageIndex = 0;
6617
+ readingPageCount = 1;
6618
+ renderReadingSelectors();
6619
+ renderReadingNavigation();
6620
+ $("#reader-volume-title").textContent = target.volumeTitle;
6621
+ $("#reader-chapter-title").textContent = target.title;
6622
+ $("#reader-chapter-meta").textContent = "正在载入正文";
6623
+ $("#reader-title").textContent = `${state.work.title} · ${target.title}`;
6624
+ $("#reader-document").setAttribute("aria-busy", "true");
6625
+ renderReadingStatus("正在载入章节……");
6626
+ replacePageRoute({ view: "reader", workId: state.work.id, chapterId: target.id });
6627
+ try {
6628
+ const chapter = await api(`/api/chapters/${encodeURIComponent(target.id)}`, { signal: request.signal });
6629
+ if (!readingRequestGate.isCurrent(request)) return false;
6630
+ if (String(chapter?.id ?? "") !== target.id || String(chapter?.workId ?? "") !== String(state.work.id)) {
6631
+ throw new Error("章节响应与当前作品不匹配");
6632
+ }
6633
+ readingLoadedChapter = chapter;
6634
+ renderReadingContent(chapter.content);
6635
+ $("#reader-chapter-title").textContent = String(chapter.title || target.title);
6636
+ $("#reader-chapter-meta").textContent = `${Number(chapter.wordCount ?? Array.from(String(chapter.content ?? "").replace(/\s/gu, "")).length).toLocaleString("zh-CN")} 字 · ${target.chapterType}`;
6637
+ updateDocumentTitle({ title: `${state.work.title} · ${target.title} · 阅读预览` });
6638
+ await restoreReadingViewport({ scrollRatio: targetScrollRatio, pageIndex: targetPageIndex });
6639
+ if (!readingRequestGate.isCurrent(request)) return false;
6640
+ readingLoading = false;
6641
+ $("#reader-document").removeAttribute("aria-busy");
6642
+ renderReadingNavigation();
6643
+ persistReadingPosition();
6644
+ return true;
6645
+ } catch (error) {
6646
+ if (!readingRequestGate.isCurrent(request) || error?.name === "AbortError") return false;
6647
+ readingLoading = false;
6648
+ readingLoadedChapter = null;
6649
+ $("#reader-document").removeAttribute("aria-busy");
6650
+ $("#reader-chapter-meta").textContent = "正文载入失败";
6651
+ renderReadingError(error, target.id);
6652
+ renderReadingNavigation();
6653
+ return false;
6654
+ } finally {
6655
+ if (readingRequestGate.isCurrent(request)) readingRequestGate.finish(request);
6656
+ }
6657
+ }
6658
+
6659
+ async function navigateReadingChapter(direction, { continueFromBoundary = false } = {}) {
6660
+ if (!readingTargetChapter || readingLoading) return false;
6661
+ const target = adjacentReadingChapter(readingSequence, readingTargetChapter.id, direction);
6662
+ if (!target) {
6663
+ toast(direction < 0 ? "已经是全书第一章" : "已经是全书最后一章");
6664
+ return false;
6665
+ }
6666
+ persistReadingPosition();
6667
+ const previousBoundary = direction < 0 && continueFromBoundary;
6668
+ return loadReadingChapter(target.id, {
6669
+ scrollRatio: previousBoundary && readingPreferences.mode === "scroll" ? 1 : 0,
6670
+ pageIndex: previousBoundary && readingPreferences.mode === "paged" ? -1 : 0
6671
+ });
6672
+ }
6673
+
6674
+ async function stepReadingPage(direction) {
6675
+ if (!readingTargetChapter || readingLoading) return;
6676
+ if (readingPreferences.mode !== "paged") {
6677
+ const viewport = $("#reader-viewport");
6678
+ const atBoundary = direction > 0
6679
+ ? viewport.scrollTop + viewport.clientHeight >= viewport.scrollHeight - 2
6680
+ : viewport.scrollTop <= 2;
6681
+ if (atBoundary) {
6682
+ await navigateReadingChapter(direction, { continueFromBoundary: true });
6683
+ return;
6684
+ }
6685
+ viewport.scrollBy({ top: direction * viewport.clientHeight * .86, behavior: "smooth" });
6686
+ return;
6687
+ }
6688
+ const step = resolvePagedReadingStep({
6689
+ sequence: readingSequence,
6690
+ chapterId: readingTargetChapter.id,
6691
+ pageIndex: readingPageIndex,
6692
+ pageCount: readingPageCount
6693
+ }, direction);
6694
+ if (!step) {
6695
+ toast(direction < 0 ? "已经是全书第一页" : "已经是全书最后一页");
6696
+ return;
6697
+ }
6698
+ if (step.chapterChanged) {
6699
+ persistReadingPosition();
6700
+ await loadReadingChapter(step.chapterId, { pageIndex: step.pageIndex });
6701
+ } else {
6702
+ setReadingPageIndex(step.pageIndex);
6703
+ }
6704
+ }
6705
+
6706
+ function updateReadingPreference(patch) {
6707
+ const progressRatio = readingProgressRatio();
6708
+ const previousMode = readingPreferences.mode;
6709
+ readingPreferences = normalizeReadingPreferences({ ...readingPreferences, ...patch });
6710
+ persistReadingPreferences();
6711
+ applyReadingPreferences();
6712
+ if (!readingLoadedChapter) return;
6713
+ if (readingPreferences.mode === "paged") {
6714
+ $("#reader-viewport").scrollTop = 0;
6715
+ window.requestAnimationFrame(() => layoutReadingPages({
6716
+ progressRatio: previousMode === "scroll" ? progressRatio : null,
6717
+ pageIndex: readingPageIndex
6718
+ }));
6719
+ } else {
6720
+ resetReadingPageLayout();
6721
+ window.requestAnimationFrame(() => {
6722
+ const viewport = $("#reader-viewport");
6723
+ viewport.scrollTop = progressRatio * Math.max(0, viewport.scrollHeight - viewport.clientHeight);
6724
+ renderReadingNavigation();
6725
+ scheduleReadingPositionSave();
6726
+ });
6727
+ }
6728
+ }
6729
+
6730
+ async function openReadingPreview({ chapterId = null, volumeId = null, restorePosition = true } = {}) {
6731
+ if (!state.work) return toast("请先打开一部作品", "error");
6732
+ if (!canReadModule("editor")) return toast("当前账户没有正文读取权限", "error");
6733
+ const workId = state.work.id;
6734
+ await loadAllVolumeChapters(workId);
6735
+ if (state.work?.id !== workId) return false;
6736
+ readingSequence = buildReadingChapterSequence(state.work);
6737
+ if (!readingSequence.length) {
6738
+ toast("当前作品还没有可阅读的章节", "error");
6739
+ return false;
6740
+ }
6741
+ readingPreferences = normalizeReadingPreferences(readReadingStorage(READING_PREFERENCES_STORAGE_KEY));
6742
+ const saved = restorePosition ? storedReadingPosition() : null;
6743
+ const target = resolveReadingStart(readingSequence, { chapterId, volumeId, storedPosition: saved });
6744
+ if (!target) return false;
6745
+ const view = $("#reader-view");
6746
+ if (view.classList.contains("hidden")) {
6747
+ readingReturnRoute = currentPageRoute();
6748
+ readingPreviousFocus = document.activeElement;
6749
+ }
6750
+ view.classList.remove("hidden");
6751
+ view.setAttribute("aria-modal", "true");
6752
+ $("#app").inert = true;
6753
+ document.body.classList.add("reader-open");
6754
+ applyReadingPreferences();
6755
+ renderReadingSelectors();
6756
+ await loadReadingChapter(target.id, { restorePosition: restorePosition && saved?.chapterId === target.id });
6757
+ $("#reader-viewport").focus({ preventScroll: true });
6758
+ return true;
6759
+ }
6760
+
6761
+ function closeReadingPreview() {
6762
+ const view = $("#reader-view");
6763
+ if (view.classList.contains("hidden")) return;
6764
+ persistReadingPosition();
6765
+ readingRequestGate.cancel();
6766
+ readingLoading = false;
6767
+ if (readingPositionSaveTimer !== null) clearTimeout(readingPositionSaveTimer);
6768
+ readingPositionSaveTimer = null;
6769
+ if (readingResizeFrame !== null) window.cancelAnimationFrame(readingResizeFrame);
6770
+ readingResizeFrame = null;
6771
+ view.classList.add("hidden");
6772
+ view.removeAttribute("aria-modal");
6773
+ $("#reader-settings").open = false;
6774
+ $("#app").inert = false;
6775
+ document.body.classList.remove("reader-open");
6776
+ updateDocumentTitle(state.work);
6777
+ const returnRoute = readingReturnRoute ?? (state.work ? { view: "welcome", workId: state.work.id } : { view: "shelf" });
6778
+ readingReturnRoute = null;
6779
+ replacePageRoute(returnRoute);
6780
+ const focus = readingPreviousFocus;
6781
+ readingPreviousFocus = null;
6782
+ const focusCandidates = [focus, $("#chapter-reader-button"), $("#reader-open-button"), $("#home-button")];
6783
+ for (const candidate of focusCandidates) {
6784
+ if (!(candidate instanceof HTMLElement) || !candidate.isConnected || candidate.matches(":disabled")) continue;
6785
+ const rect = candidate.getBoundingClientRect();
6786
+ if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= window.innerHeight || rect.left >= window.innerWidth) continue;
6787
+ candidate.focus({ preventScroll: true });
6788
+ if (document.activeElement === candidate) return;
6789
+ }
6790
+ restoreToastFocus(null);
6791
+ }
6792
+
6793
+ function handleReadingKeyboard(event) {
6794
+ if (event.key === "Escape") {
6795
+ event.preventDefault();
6796
+ closeReadingPreview();
6797
+ return;
6798
+ }
6799
+ if (event.target.closest("button, select, summary")) return;
6800
+ const paged = readingPreferences.mode === "paged";
6801
+ if (["PageDown", "ArrowRight"].includes(event.key) || (event.key === " " && !event.shiftKey)) {
6802
+ event.preventDefault();
6803
+ void stepReadingPage(1);
6804
+ return;
6805
+ }
6806
+ if (["PageUp", "ArrowLeft"].includes(event.key) || (event.key === " " && event.shiftKey)) {
6807
+ event.preventDefault();
6808
+ void stepReadingPage(-1);
6809
+ return;
6810
+ }
6811
+ if (!paged && ["ArrowDown", "ArrowUp"].includes(event.key)) {
6812
+ event.preventDefault();
6813
+ $("#reader-viewport").scrollBy({ top: event.key === "ArrowDown" ? 72 : -72, behavior: "smooth" });
6814
+ return;
6815
+ }
6816
+ if (!paged && ["Home", "End"].includes(event.key)) {
6817
+ event.preventDefault();
6818
+ const viewport = $("#reader-viewport");
6819
+ viewport.scrollTo({ top: event.key === "Home" ? 0 : viewport.scrollHeight, behavior: "smooth" });
6820
+ }
6821
+ }
6822
+
6823
+ function handleReadingWheel(event) {
6824
+ if (readingPreferences.mode !== "scroll" || readingLoading || Math.abs(event.deltaY) < 2) return;
6825
+ const viewport = $("#reader-viewport");
6826
+ const atEnd = viewport.scrollTop + viewport.clientHeight >= viewport.scrollHeight - 2;
6827
+ const atStart = viewport.scrollTop <= 2;
6828
+ if (event.deltaY > 0 && atEnd) {
6829
+ event.preventDefault();
6830
+ void navigateReadingChapter(1, { continueFromBoundary: true });
6831
+ } else if (event.deltaY < 0 && atStart) {
6832
+ event.preventDefault();
6833
+ void navigateReadingChapter(-1, { continueFromBoundary: true });
6834
+ }
6835
+ }
6836
+
6837
+ async function saveChapter() {
6838
+ return persistChapter({ automatic: false });
6839
+ }
6840
+
6841
+ function tidyChapterBlankLines() {
6842
+ if (!state.chapter) return toast("请先选择章节", "error");
6843
+ if (chapterEditorReadOnly || !canEditProse()) return toast("请先进入编辑模式", "error");
6844
+ const input = $("#chapter-content");
6845
+ const normalized = normalizeParagraphSpacing(input.value);
6846
+ if (normalized === input.value) return toast("正文空行已经符合要求");
6847
+ input.value = normalized;
6848
+ scheduleChapterLineNumbers();
6849
+ updateChapterStats();
6850
+ scheduleChapterAutoSave(120);
6851
+ toast("已整理空行:段与段之间保留 1 个空行");
6852
+ }
6853
+
6854
+ function showWelcome(hasWork = false) {
6855
+ chapterSelectionRequestId += 1;
6856
+ clearChapterForeshadowReminders({ invalidateRequest: true });
6857
+ dismissChapterInsightToast();
6858
+ $("#editor-view").classList.add("hidden");
6859
+ $("#module-view").classList.add("hidden");
5657
6860
  $("#welcome-view").classList.remove("hidden");
5658
6861
  if (hasWork) showSystemStatus();
5659
6862
  $("#welcome-view h1").innerHTML = hasWork ? "故事已经就位,<br>从新章节继续。" : "把长篇故事的每条线索,<br>留在作者掌控之中。";
@@ -5697,6 +6900,10 @@ async function showModule(module) {
5697
6900
  if (module !== "editor" && state.module === "editor" && !(await confirmDiscardChanges())) return;
5698
6901
  if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
5699
6902
  state.module = module;
6903
+ if (module !== "editor") {
6904
+ chapterSelectionRequestId += 1;
6905
+ clearChapterForeshadowReminders({ invalidateRequest: true });
6906
+ }
5700
6907
  if (module !== "tasks") stopTaskProgressRefresh();
5701
6908
  applyWorkAccessMode();
5702
6909
  markActiveModule(module);
@@ -6058,6 +7265,17 @@ function mountDraftFilterToggle() {
6058
7265
  });
6059
7266
  }
6060
7267
 
7268
+ function mountOutlineBoardFilterToggle() {
7269
+ $("#module-header-actions").querySelector('[data-module-header-action="outline-board-filter-toggle"]')?.remove();
7270
+ $("#module-header-actions").insertAdjacentHTML("afterbegin", `<button type="button" class="module-filter-toggle" data-module-header-action="outline-board-filter-toggle" aria-label="筛选章节大纲看板" aria-controls="outline-board-filter-panel" aria-expanded="${outlineBoardFiltersPanelOpen}" title="筛选章节大纲看板"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16l-6.5 7.2v5.3l-3 1.5v-6.8L4 5Z"></path></svg></button>`);
7271
+ const toggle = $("#module-header-actions").querySelector('[data-module-header-action="outline-board-filter-toggle"]');
7272
+ toggle?.addEventListener("click", () => {
7273
+ outlineBoardFiltersPanelOpen = !outlineBoardFiltersPanelOpen;
7274
+ $("#outline-board-filter-panel")?.classList.toggle("hidden", !outlineBoardFiltersPanelOpen);
7275
+ toggle.setAttribute("aria-expanded", String(outlineBoardFiltersPanelOpen));
7276
+ });
7277
+ }
7278
+
6061
7279
  function bindRecordPreview(selector, open) {
6062
7280
  $("#module-content").querySelectorAll(selector).forEach((card) => {
6063
7281
  const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openOrganization ?? card.dataset.openReview;
@@ -6738,6 +7956,25 @@ function setTimelineMultiSelectMode(enabled) {
6738
7956
  updateTimelineMultiSelectControls();
6739
7957
  }
6740
7958
 
7959
+ async function deleteTimelineEvent(item, page = moduleListPages.timeline) {
7960
+ if (!item || !canEditModule("timeline")) return toast("当前权限不能删除时间事件", "error");
7961
+ if (!await confirmToast(`确认删除时间事件“${item.name}”吗?删除后将从当前时间轴移除。`, {
7962
+ title: "删除时间事件",
7963
+ confirmLabel: "继续删除"
7964
+ })) return;
7965
+ if (!await confirmToast(`删除时间事件“${item.name}”后将从当前时间轴移除,版本历史和审计记录仍会保留。仍要删除吗?`, {
7966
+ title: "删除操作需要再次确认",
7967
+ confirmLabel: "确认删除"
7968
+ })) return;
7969
+ try {
7970
+ await api(`/api/timeline/${encodeURIComponent(item.id)}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
7971
+ await renderTimeline(page);
7972
+ toast(`已删除时间事件“${item.name}”`);
7973
+ } catch (error) {
7974
+ toast(error.message, "error");
7975
+ }
7976
+ }
7977
+
6741
7978
  async function renderTimeline(page = moduleListPages.timeline) {
6742
7979
  const [events, tracks] = await Promise.all([
6743
7980
  moduleApiAllPages("timeline", `/api/works/${state.work.id}/timeline`),
@@ -6774,10 +8011,11 @@ async function renderTimeline(page = moduleListPages.timeline) {
6774
8011
  ? `<button class="timeline-track-menu" data-edit-timeline-track="${esc(activeTrack.id)}" type="button">编辑</button><button class="timeline-track-menu" data-entity-history="timeline-track" data-entity-id="${esc(activeTrack.id)}" data-entity-title="${esc(activeTrack.name)}" type="button">历史</button>`
6775
8012
  : "";
6776
8013
  const panelMarkup = `<section id="timeline-track-panel" class="timeline-track-panel" data-track-color-index="${timelineTrackColorIndex(activeTrack.id, tracks)}" role="tabpanel" aria-labelledby="timeline-track-tab-${esc(String(activeTrack.id || "ungrouped"))}"><div class="timeline-track-panel-copy"><strong>${esc(activeTrack.name)}</strong><p>${esc(activeTrack.description || "暂无说明")}</p></div><div class="timeline-track-panel-actions"><div class="timeline-track-actions" role="group" aria-label="当前轨道操作">${trackManageActions}<button class="timeline-track-menu" data-add-event-track="${esc(String(activeTrack.id ?? ""))}" type="button">添加事件</button><button class="timeline-track-menu" data-timeline-sort="asc" type="button" aria-pressed="${timelineSortDirection === "asc"}">正序</button><button class="timeline-track-menu" data-timeline-sort="desc" type="button" aria-pressed="${timelineSortDirection === "desc"}">倒序</button></div></div></section>`;
8014
+ const canEditTimeline = canEditModule("timeline");
6777
8015
  const eventItem = (item) => {
6778
8016
  const trackKey = item.trackId ?? "";
6779
8017
  const colorIndex = timelineTrackColorIndex(trackKey, tracks);
6780
- return `<article class="timeline-item" data-track-color-index="${colorIndex}" data-event-id="${esc(item.id)}"><div class="timeline-card-meta"><input class="timeline-select" type="checkbox" data-event-select="${esc(item.id)}" aria-label="选择 ${esc(item.name)}" hidden><small>${esc(item.timeLabel)} · ${esc(timelineStatusLabel(item.status))}</small></div><h3>${esc(item.name)}</h3><p>${esc(item.description || "暂无说明")}</p>${item.location ? `<span class="timeline-item-location">地点:${esc(item.location)}</span>` : ""}<div class="card-actions"><button data-edit-event="${esc(item.id)}" type="button">编辑与排序</button><button data-split-event="${esc(item.id)}" type="button">拆分</button><button data-entity-history="timeline-event" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}" type="button">版本历史</button></div></article>`;
8018
+ return `<article class="timeline-item" data-track-color-index="${colorIndex}" data-event-id="${esc(item.id)}"><div class="timeline-card-meta"><input class="timeline-select" type="checkbox" data-event-select="${esc(item.id)}" aria-label="选择 ${esc(item.name)}" hidden><small>${esc(item.timeLabel)} · ${esc(timelineStatusLabel(item.status))}</small></div><h3>${esc(item.name)}</h3><p>${esc(item.description || "暂无说明")}</p>${item.location ? `<span class="timeline-item-location">地点:${esc(item.location)}</span>` : ""}<div class="card-actions"><button data-edit-event="${esc(item.id)}" type="button">编辑与排序</button><button data-split-event="${esc(item.id)}" type="button">拆分</button><button data-entity-history="timeline-event" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}" type="button">版本历史</button>${canEditTimeline ? `<button class="danger-button" data-delete-timeline-event="${esc(item.id)}" type="button" aria-label="删除时间事件 ${esc(item.name)}">删除</button>` : ""}</div></article>`;
6781
8019
  };
6782
8020
  const axisMarkup = pageResult.items.length
6783
8021
  ? `<div class="timeline-axis" data-testid="timeline-axis"><div class="timeline-list">${pageResult.items.map(eventItem).join("")}</div></div>`
@@ -6804,6 +8042,10 @@ async function renderTimeline(page = moduleListPages.timeline) {
6804
8042
  $("#module-content").querySelectorAll("[data-add-event-track]").forEach((button) => button.addEventListener("click", () => openTimelineDialog(null, button.dataset.addEventTrack || null)));
6805
8043
  $("#module-content").querySelectorAll("[data-edit-event]").forEach((button) => button.addEventListener("click", () => openTimelineDialog(events.find((item) => item.id === button.dataset.editEvent))));
6806
8044
  $("#module-content").querySelectorAll("[data-split-event]").forEach((button) => button.addEventListener("click", () => openTimelineSplitDialog(events.find((item) => item.id === button.dataset.splitEvent))));
8045
+ $("#module-content").querySelectorAll("[data-delete-timeline-event]").forEach((button) => button.addEventListener("click", () => {
8046
+ const item = events.find((event) => event.id === button.dataset.deleteTimelineEvent);
8047
+ if (item) void deleteTimelineEvent(item, pageResult.page);
8048
+ }));
6807
8049
  bindEntityHistoryButtons(() => renderTimeline(pageResult.page));
6808
8050
  $("#merge-events")?.addEventListener("click", () => {
6809
8051
  const eventIds = [...$("#module-content").querySelectorAll("[data-event-select]:checked")].map((input) => input.dataset.eventSelect);
@@ -6820,21 +8062,163 @@ async function renderTimeline(page = moduleListPages.timeline) {
6820
8062
  });
6821
8063
  }
6822
8064
 
6823
- async function renderOutlines(outlinePage = moduleListPages.outlinePlans, foreshadowPage = moduleListPages.foreshadows) {
8065
+ function outlineBoardForeshadowRelation(item) {
8066
+ const roles = (item.roles ?? []).map(occurrenceRoleLabel);
8067
+ if (item.plannedPayoff) roles.push("计划回收");
8068
+ return [...new Set(roles)].join("、") || "关联";
8069
+ }
8070
+
8071
+ function outlineBoardDialogItem(chapter, volume, outline) {
8072
+ return {
8073
+ chapterId: chapter.id,
8074
+ chapterTitle: chapter.title,
8075
+ volumeId: volume.id,
8076
+ volumeTitle: volume.title,
8077
+ goal: outline?.goal ?? "",
8078
+ conflict: outline?.conflict ?? "",
8079
+ turningPoint: outline?.turningPoint ?? "",
8080
+ notes: outline?.notes ?? "",
8081
+ status: outline?.status ?? "draft",
8082
+ versionNo: outline?.versionNo
8083
+ };
8084
+ }
8085
+
8086
+ async function loadOutlineBoardDialogItem(chapter, volume) {
8087
+ const workId = state.work?.id;
8088
+ const generation = workScopedUiGeneration;
8089
+ if (!workId) return null;
8090
+ const outline = await api(`/api/chapters/${encodeURIComponent(chapter.id)}/outline`);
8091
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration) return null;
8092
+ return outlineBoardDialogItem(chapter, volume, outline);
8093
+ }
8094
+
8095
+ async function openOutlineBoardChapter(chapterId, workId) {
8096
+ if (!canReadModule("editor") || state.work?.id !== workId) return;
8097
+ try {
8098
+ await selectChapter(chapterId);
8099
+ } catch (error) {
8100
+ if (state.work?.id === workId) toast(`打开章节失败:${error.message}`, "error");
8101
+ }
8102
+ }
8103
+
8104
+ function outlineBoardDetailField(label, value) {
8105
+ return `<section class="outline-board-detail-field"><h3>${esc(label)}</h3><p>${esc(value || "未填写")}</p></section>`;
8106
+ }
8107
+
8108
+ async function openOutlineBoardDetail(chapter, volume) {
8109
+ const workId = state.work?.id;
8110
+ if (!workId) return;
8111
+ try {
8112
+ const item = await loadOutlineBoardDialogItem(chapter, volume);
8113
+ if (!item) return;
8114
+ const canOpenChapter = canReadModule("editor");
8115
+ const canEditOutline = canEditModule("outlines");
8116
+ const foreshadows = chapter.foreshadows ?? [];
8117
+ const foreshadowList = foreshadows.length
8118
+ ? `<ul class="outline-board-detail-foreshadows">${foreshadows.map((foreshadow) => `<li><strong>${esc(foreshadow.title)}</strong><span>${esc(levelLabel(foreshadow.importance))} · ${esc(foreshadowStatusLabel(foreshadow.status))} · ${esc(outlineBoardForeshadowRelation(foreshadow))}</span></li>`).join("")}</ul>`
8119
+ : '<p class="outline-board-detail-empty">本章没有关联伏笔。</p>';
8120
+ const actions = canOpenChapter || canEditOutline
8121
+ ? `<div class="outline-board-detail-actions">${canOpenChapter ? '<button class="primary-button" type="button" data-outline-detail-open>打开章节</button>' : ""}${canEditOutline ? '<button class="ghost-button" type="button" data-outline-detail-edit>编辑大纲</button>' : ""}</div>`
8122
+ : "";
8123
+ openDialog(`${volume.title} / ${chapter.title}`,
8124
+ `<div class="outline-board-detail-grid">${outlineBoardDetailField("本章目标", item.goal)}${outlineBoardDetailField("核心冲突", item.conflict)}${outlineBoardDetailField("关键转折", item.turningPoint)}${outlineBoardDetailField("补充说明", item.notes)}</div>
8125
+ <section class="outline-board-detail-section"><header><h3>关联伏笔</h3><span>${foreshadows.length} 条</span></header>${foreshadowList}</section>
8126
+ ${chapter.outline?.truncated ? '<p class="outline-board-detail-note">看板仅展示摘要,本详情已读取完整大纲。</p>' : ""}${actions}`,
8127
+ async () => {},
8128
+ `${outlineStatusLabel(item.status)} · 章节大纲详情`,
8129
+ { submitLabel: "关闭", hideCancel: true, wide: true }
8130
+ );
8131
+ $("#dialog-fields").querySelector("[data-outline-detail-open]")?.addEventListener("click", () => {
8132
+ $("#form-dialog").close();
8133
+ void openOutlineBoardChapter(chapter.id, workId);
8134
+ });
8135
+ $("#dialog-fields").querySelector("[data-outline-detail-edit]")?.addEventListener("click", () => {
8136
+ $("#form-dialog").close();
8137
+ openOutlineDialog(item);
8138
+ });
8139
+ } catch (error) {
8140
+ toast(`读取章节大纲详情失败:${error.message}`, "error");
8141
+ }
8142
+ }
8143
+
8144
+ function outlineBoardCard(chapter, volume) {
8145
+ const outline = chapter.outline;
8146
+ const canOpenChapter = canReadModule("editor");
8147
+ const unresolvedCount = outlineBoardUnresolvedCount(chapter);
8148
+ const statusLabel = outline ? outlineStatusLabel(outline.status) : "未规划";
8149
+ const summaryField = (label, value) => `<section><b>${esc(label)}</b><p>${esc(value || "未填写")}</p></section>`;
8150
+ const foreshadows = chapter.foreshadows ?? [];
8151
+ const foreshadowTags = foreshadows.length
8152
+ ? foreshadows.map((item) => `<span class="outline-board-foreshadow" data-status="${esc(item.status)}"><strong>${esc(item.title)}</strong><small>${esc(foreshadowStatusLabel(item.status))} · ${esc(outlineBoardForeshadowRelation(item))}</small></span>`).join("")
8153
+ : '<span class="outline-board-no-foreshadow">无关联伏笔</span>';
8154
+ return `<article class="outline-board-card${outline ? "" : " is-empty-outline"}${outline?.status === "completed" ? " is-complete" : ""}" data-outline-board-card="${esc(chapter.id)}" ${canOpenChapter ? `role="link" tabindex="0" aria-label="打开章节 ${esc(chapter.title)}"` : ""}>
8155
+ <header><div><small>${esc(chapter.chapterType || "正文")} · ${esc(statusLabel)}</small><h3>${esc(chapter.title)}</h3></div>${unresolvedCount ? `<span class="outline-board-unresolved">${unresolvedCount} 个未回收</span>` : ""}</header>
8156
+ <div class="outline-board-card-summary">${summaryField("目标", outline?.goal)}${summaryField("冲突", outline?.conflict)}${summaryField("转折", outline?.turningPoint)}</div>
8157
+ <div class="outline-board-foreshadows" aria-label="${esc(chapter.title)}关联伏笔">${foreshadowTags}</div>
8158
+ ${outline?.truncated ? '<small class="outline-board-truncated">摘要已截断,可查看完整详情</small>' : ""}
8159
+ <div class="outline-board-card-actions"><button type="button" data-outline-board-detail="${esc(chapter.id)}">查看详情</button>${canOpenChapter ? `<button type="button" data-outline-board-open="${esc(chapter.id)}">打开章节</button>` : ""}${canEditModule("outlines") ? `<button type="button" data-edit-outline="${esc(chapter.id)}">编辑大纲</button>` : ""}</div>
8160
+ </article>`;
8161
+ }
8162
+
8163
+ function bindOutlineBoardCards(board, workId) {
8164
+ const locations = new Map(board.volumes.flatMap((volume) => volume.chapters.map((chapter) => [String(chapter.id), { chapter, volume }])));
8165
+ $("#outline-board-results").querySelectorAll("[data-outline-board-card]").forEach((card) => {
8166
+ if (!canReadModule("editor")) return;
8167
+ const open = () => { void openOutlineBoardChapter(card.dataset.outlineBoardCard, workId); };
8168
+ card.addEventListener("click", (event) => { if (!event.target.closest("button, a")) open(); });
8169
+ card.addEventListener("keydown", (event) => {
8170
+ if (!["Enter", " "].includes(event.key) || event.target.closest("button, a")) return;
8171
+ event.preventDefault();
8172
+ open();
8173
+ });
8174
+ });
8175
+ $("#outline-board-results").querySelectorAll("[data-outline-board-open]").forEach((button) => button.addEventListener("click", () => {
8176
+ void openOutlineBoardChapter(button.dataset.outlineBoardOpen, workId);
8177
+ }));
8178
+ $("#outline-board-results").querySelectorAll("[data-outline-board-detail]").forEach((button) => button.addEventListener("click", () => {
8179
+ const location = locations.get(String(button.dataset.outlineBoardDetail));
8180
+ if (location) void openOutlineBoardDetail(location.chapter, location.volume);
8181
+ }));
8182
+ $("#outline-board-results").querySelectorAll("[data-edit-outline]").forEach((button) => button.addEventListener("click", async () => {
8183
+ const location = locations.get(String(button.dataset.editOutline));
8184
+ if (!location) return;
8185
+ try {
8186
+ const item = await loadOutlineBoardDialogItem(location.chapter, location.volume);
8187
+ if (item) openOutlineDialog(item);
8188
+ } catch (error) {
8189
+ toast(`读取章节大纲失败:${error.message}`, "error");
8190
+ }
8191
+ }));
8192
+ }
8193
+
8194
+ async function renderOutlines(foreshadowPage = moduleListPages.foreshadows) {
8195
+ const workId = state.work.id;
8196
+ const generation = workScopedUiGeneration;
8197
+ const requestId = ++outlineBoardRenderRequestId;
6824
8198
  const currentChapterId = state.chapter?.id;
6825
- const [outlines, foreshadows] = await Promise.all([
6826
- moduleApiAllPages("outlines", `/api/works/${state.work.id}/outlines`),
6827
- moduleApiAllPages("outlines", `/api/works/${state.work.id}/foreshadows?status=all${currentChapterId ? `&currentChapterId=${encodeURIComponent(currentChapterId)}` : ""}`)
6828
- ]);
6829
- mountModuleCount(outlines.length + foreshadows.length);
6830
- const outlinePageResult = paginateModuleItems(outlines, outlinePage, "outlines");
8199
+ let board;
8200
+ let foreshadows;
8201
+ try {
8202
+ [board, foreshadows] = await Promise.all([
8203
+ moduleApi("outlines", `/api/works/${encodeURIComponent(workId)}/outline-board`),
8204
+ moduleApiAllPages("outlines", `/api/works/${encodeURIComponent(workId)}/foreshadows?status=all${currentChapterId ? `&currentChapterId=${encodeURIComponent(currentChapterId)}` : ""}`)
8205
+ ]);
8206
+ } catch (error) {
8207
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== outlineBoardRenderRequestId || state.module !== "outlines") return;
8208
+ throw error;
8209
+ }
8210
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration || requestId !== outlineBoardRenderRequestId || state.module !== "outlines") return;
8211
+
8212
+ if (outlineBoardFilters.volumeId && !board.volumes.some((volume) => String(volume.id) === outlineBoardFilters.volumeId)) {
8213
+ outlineBoardFilters.volumeId = "";
8214
+ }
6831
8215
  const foreshadowPageResult = paginateModuleItems(foreshadows, foreshadowPage, "outlines");
6832
- moduleListPages.outlinePlans = outlinePageResult.page;
6833
8216
  moduleListPages.foreshadows = foreshadowPageResult.page;
6834
8217
  const layout = readModuleLayout();
6835
8218
  const unresolved = foreshadows.filter((item) => item.unresolved);
6836
8219
  const overdue = unresolved.filter((item) => item.overdue);
6837
- 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>`;
8220
+ const canEditOutlines = canEditModule("outlines");
8221
+ const foreshadowActions = (item) => `${canEditOutlines ? `<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>`;
6838
8222
  const foreshadowCards = () => `<div class="card-grid foreshadow-grid">${foreshadowPageResult.items.map((item) => `
6839
8223
  <article class="record-card foreshadow-card ${item.overdue ? "is-overdue" : ""}">
6840
8224
  <small>${esc(levelLabel(item.importance))} · ${esc(foreshadowStatusLabel(item.status))}${item.overdue ? " · 已逾期" : ""}</small>
@@ -6859,21 +8243,65 @@ async function renderOutlines(outlinePage = moduleListPages.outlinePlans, foresh
6859
8243
  ? `${layout === "rows" ? foreshadowRows() : foreshadowCards()}${renderModulePagination(foreshadowPageResult, "foreshadows", "伏笔列表")}`
6860
8244
  : emptyModule("还没有伏笔", "创建伏笔并关联埋设、提醒与回收章节,未回收项会持续显示。\n");
6861
8245
  if (foreshadows.length) mountModuleLayoutToggle(layout, "伏笔列表样式");
6862
- const outlineHtml = outlines.length ? `<div class="outline-list">${outlinePageResult.items.map((item) => `
6863
- <article class="outline-row ${item.status === "completed" ? "is-complete" : ""}">
6864
- <div><small>${esc(item.volumeTitle)} · ${esc(outlineStatusLabel(item.status))}</small><h3>${esc(item.chapterTitle)}</h3></div>
6865
- <div><b>目标</b><p>${esc(item.goal || "未填写")}</p></div>
6866
- <div><b>冲突</b><p>${esc(item.conflict || "未填写")}</p></div>
6867
- <div><b>转折</b><p>${esc(item.turningPoint || "未填写")}</p></div>
6868
- <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>
6869
- </article>`).join("")}</div>${renderModulePagination(outlinePageResult, "outlinePlans", "章节规划")}` : emptyModule("还没有章节", "先创建章节,再为每章维护目标、冲突和转折。\n");
6870
- $("#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>`;
6871
- bindModuleLayoutToggle(() => renderOutlines(outlinePageResult.page, foreshadowPageResult.page));
6872
- bindModulePagination("foreshadows", (page) => renderOutlines(outlinePageResult.page, page));
6873
- bindModulePagination("outlinePlans", (page) => renderOutlines(page, foreshadowPageResult.page));
6874
- $("#module-content").querySelectorAll("[data-edit-outline]").forEach((button) => button.addEventListener("click", () => openOutlineDialog(outlines.find((item) => item.chapterId === button.dataset.editOutline))));
8246
+ else $("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
8247
+
8248
+ const filterState = normalizeOutlineBoardState(outlineBoardFilters);
8249
+ Object.assign(outlineBoardFilters, filterState);
8250
+ const volumeOptions = board.volumes.map((volume) => `<option value="${esc(volume.id)}" ${filterState.volumeId === String(volume.id) ? "selected" : ""}>${esc(volume.title)}</option>`).join("");
8251
+ const filterToolbar = `<section id="outline-board-filter-panel" class="character-filter-toolbar outline-board-filter-toolbar${outlineBoardFiltersPanelOpen ? "" : " hidden"}" aria-label="章节大纲看板筛选">
8252
+ <label class="setting-filter-field outline-board-search-field" for="outline-board-search"><span>搜索章节与摘要</span><input id="outline-board-search" type="search" value="${esc(filterState.query)}" placeholder="章节、目标、冲突、转折或伏笔" autocomplete="off"></label>
8253
+ <label class="setting-filter-field" for="outline-board-volume-filter"><span>按分卷筛选</span><select id="outline-board-volume-filter"><option value="">全部分卷</option>${volumeOptions}</select></label>
8254
+ <label class="setting-filter-field" for="outline-board-status-filter"><span>按大纲状态筛选</span><select id="outline-board-status-filter"><option value="all" ${filterState.outlineStatus === "all" ? "selected" : ""}>全部大纲状态</option><option value="empty" ${filterState.outlineStatus === "empty" ? "selected" : ""}>尚未规划</option><option value="draft" ${filterState.outlineStatus === "draft" ? "selected" : ""}>草稿</option><option value="ready" ${filterState.outlineStatus === "ready" ? "selected" : ""}>可执行</option><option value="completed" ${filterState.outlineStatus === "completed" ? "selected" : ""}>已完成</option></select></label>
8255
+ <label class="setting-filter-field" for="outline-board-foreshadow-filter"><span>按伏笔状态筛选</span><select id="outline-board-foreshadow-filter"><option value="all" ${filterState.foreshadowStatus === "all" ? "selected" : ""}>全部伏笔状态</option><option value="none" ${filterState.foreshadowStatus === "none" ? "selected" : ""}>无关联伏笔</option><option value="unresolved" ${filterState.foreshadowStatus === "unresolved" ? "selected" : ""}>有未回收伏笔</option><option value="resolved" ${filterState.foreshadowStatus === "resolved" ? "selected" : ""}>有已回收伏笔</option><option value="abandoned" ${filterState.foreshadowStatus === "abandoned" ? "selected" : ""}>有已放弃伏笔</option></select></label>
8256
+ <label class="setting-filter-field" for="outline-board-sort"><span>卷内排序</span><select id="outline-board-sort"><option value="tree" ${filterState.sort === "tree" ? "selected" : ""}>章节树顺序</option><option value="status" ${filterState.sort === "status" ? "selected" : ""}>大纲状态</option><option value="foreshadows" ${filterState.sort === "foreshadows" ? "selected" : ""}>未回收伏笔优先</option><option value="title" ${filterState.sort === "title" ? "selected" : ""}>章节标题</option></select></label>
8257
+ <div class="character-filter-toolbar-actions"><span id="outline-board-filter-count" class="character-filter-result-count" role="status" aria-live="polite"></span><button id="clear-outline-board-filters" class="ghost-button" type="button">重置筛选</button></div>
8258
+ </section>`;
8259
+ $("#module-content").innerHTML = `${filterToolbar}<div id="outline-board-results"></div><section class="planning-section outline-foreshadow-library"><div class="section-title"><div><span class="eyebrow">伏笔追踪</span><h2>尚未回收与历史伏笔</h2></div></div>${foreshadowHtml}</section>`;
8260
+ mountOutlineBoardFilterToggle();
8261
+
8262
+ const renderOutlineBoardResults = () => {
8263
+ const prepared = prepareOutlineBoard(board, outlineBoardFilters);
8264
+ const controlsActive = prepared.filtersActive || prepared.state.sort !== "tree";
8265
+ const summary = `<div class="outline-summary"><article><strong>${prepared.filtersActive ? prepared.visibleChapterCount : board.stats.chapterCount}</strong><span>${prepared.filtersActive ? `筛选结果 / 共 ${board.stats.chapterCount} 章` : "全书章节"}</span></article><article><strong>${board.stats.outlinedChapterCount}</strong><span>已有章节大纲</span></article><article class="${board.stats.unresolvedForeshadowCount ? "danger-text" : ""}"><strong>${board.stats.unresolvedForeshadowCount}</strong><span>未回收伏笔</span></article></div>`;
8266
+ const boardHtml = prepared.volumes.length
8267
+ ? `<div class="outline-board">${prepared.volumes.map((volume) => `<section class="outline-board-volume" data-outline-board-volume="${esc(volume.id)}" aria-labelledby="outline-board-volume-${esc(volume.id)}"><header><div><span class="eyebrow">分卷</span><h3 id="outline-board-volume-${esc(volume.id)}">${esc(volume.title)}</h3></div><span>${volume.chapters.length} 章</span></header>${volume.chapters.length ? `<div class="outline-board-grid">${volume.chapters.map((chapter) => outlineBoardCard(chapter, volume)).join("")}</div>` : '<div class="outline-board-volume-empty"><b>本卷暂无章节</b><span>空分卷会保留在全书看板中。</span></div>'}</section>`).join("")}</div>`
8268
+ : emptyModule(board.stats.chapterCount ? "没有符合筛选条件的章节" : board.volumes.length ? "当前分卷还没有章节" : "还没有分卷", board.stats.chapterCount ? "可以调整关键词、状态、分卷或排序条件。" : "先在作品目录创建分卷和章节,再维护每章目标、冲突与转折。");
8269
+ $("#outline-board-results").innerHTML = `${summary}<section class="planning-section"><div class="section-title"><div><span class="eyebrow">全书总览</span><h2>章节大纲看板</h2></div><span class="outline-board-result-note">${prepared.visibleChapterCount} / ${board.stats.chapterCount} 章</span></div>${boardHtml}</section>`;
8270
+ $("#outline-board-filter-count").textContent = prepared.filtersActive ? `筛选后剩余 ${prepared.visibleChapterCount} 章` : "";
8271
+ $("#clear-outline-board-filters").disabled = !controlsActive;
8272
+ mountModuleCount(prepared.visibleChapterCount + foreshadows.length);
8273
+ bindOutlineBoardCards(prepared, workId);
8274
+ };
8275
+
8276
+ const updateFilters = () => {
8277
+ Object.assign(outlineBoardFilters, normalizeOutlineBoardState({
8278
+ query: $("#outline-board-search").value,
8279
+ volumeId: $("#outline-board-volume-filter").value,
8280
+ outlineStatus: $("#outline-board-status-filter").value,
8281
+ foreshadowStatus: $("#outline-board-foreshadow-filter").value,
8282
+ sort: $("#outline-board-sort").value
8283
+ }));
8284
+ outlineBoardFiltersPanelOpen = true;
8285
+ renderOutlineBoardResults();
8286
+ };
8287
+ $("#outline-board-search").addEventListener("input", updateFilters);
8288
+ ["#outline-board-volume-filter", "#outline-board-status-filter", "#outline-board-foreshadow-filter", "#outline-board-sort"].forEach((selector) => $(selector).addEventListener("change", updateFilters));
8289
+ $("#clear-outline-board-filters").addEventListener("click", () => {
8290
+ Object.assign(outlineBoardFilters, normalizeOutlineBoardState());
8291
+ $("#outline-board-search").value = "";
8292
+ $("#outline-board-volume-filter").value = "";
8293
+ $("#outline-board-status-filter").value = "all";
8294
+ $("#outline-board-foreshadow-filter").value = "all";
8295
+ $("#outline-board-sort").value = "tree";
8296
+ outlineBoardFiltersPanelOpen = true;
8297
+ renderOutlineBoardResults();
8298
+ $("#outline-board-search").focus();
8299
+ });
8300
+ renderOutlineBoardResults();
8301
+ bindModuleLayoutToggle(() => renderOutlines(foreshadowPageResult.page));
8302
+ bindModulePagination("foreshadows", renderOutlines);
6875
8303
  $("#module-content").querySelectorAll("[data-edit-foreshadow]").forEach((button) => button.addEventListener("click", () => openForeshadowDialog(foreshadows.find((item) => item.id === button.dataset.editForeshadow))));
6876
- bindEntityHistoryButtons(() => renderOutlines(outlinePageResult.page, foreshadowPageResult.page));
8304
+ bindEntityHistoryButtons(() => renderOutlines(foreshadowPageResult.page));
6877
8305
  }
6878
8306
 
6879
8307
  async function renderRelationships(page = moduleListPages.relationships) {
@@ -7671,6 +9099,188 @@ function renderRelationshipChangePreview(task, result) {
7671
9099
  </section>`;
7672
9100
  }
7673
9101
 
9102
+ function renderCharacterExtractionPreview(task, result) {
9103
+ const preview = result.characterExtractionPreview && typeof result.characterExtractionPreview === "object"
9104
+ ? result.characterExtractionPreview
9105
+ : null;
9106
+ if (!preview) return "";
9107
+ const totalCount = Number(preview.totalCount ?? 0);
9108
+ if (preview.status === "pending") {
9109
+ return `<section class="character-extraction-preview is-pending" data-character-extraction-preview aria-label="角色抽取结果待确认">
9110
+ <div><small>角色档案写入前预览</small><h4>角色库尚未修改</h4><p>已生成 ${totalCount} 个结构化候选。加载预览后可逐项勾选,并明确选择新建、合并或跳过;现有非空字段不会被静默覆盖。</p></div>
9111
+ <div class="character-extraction-preview-actions">
9112
+ ${totalCount > 0 && canEditModule("characters") ? `<button class="primary-button" type="button" data-load-character-extraction-preview="${esc(task.id)}">加载并编辑 ${totalCount} 个候选</button>` : ""}
9113
+ ${totalCount === 0 ? "<small>本次没有可应用的角色候选。</small>" : ""}
9114
+ ${!canEditModule("characters") && totalCount > 0 ? "<small>当前账号没有角色模块编辑权限,只能查看任务摘要。</small>" : ""}
9115
+ </div>
9116
+ <div data-character-extraction-preview-content></div>
9117
+ </section>`;
9118
+ }
9119
+ const counts = `新建 ${Number(preview.createdCount ?? 0)} 个 · 合并 ${Number(preview.mergedCount ?? 0)} 个 · 保持不变 ${Number(preview.unchangedCount ?? 0)} 个 · 跳过 ${Number(preview.skippedCount ?? 0)} 个`;
9120
+ return `<section class="character-extraction-preview is-applied" aria-label="角色抽取结果已应用">
9121
+ <div><small>角色档案写入结果</small><h4>确认结果已应用</h4><p>${esc(counts)}。原始抽取结果仍保留在任务记录中,已创建或更新的档案可在角色库继续编辑。</p></div>
9122
+ </section>`;
9123
+ }
9124
+
9125
+ function characterExtractionMatchLabel(match) {
9126
+ if (match.matchType === "stable") return "稳定匹配";
9127
+ if (match.matchType === "name") return "标准名匹配";
9128
+ return "别名匹配";
9129
+ }
9130
+
9131
+ function renderCharacterExtractionEditor(preview) {
9132
+ const items = Array.isArray(preview.items) ? preview.items : [];
9133
+ if (!items.length) return '<p class="character-extraction-empty">没有可应用的角色候选。</p>';
9134
+ return `<div class="character-extraction-editor" data-character-extraction-editor data-preview-token="${esc(preview.previewToken)}">
9135
+ <div class="character-extraction-policy" role="note"><strong>合并规则</strong><span>优先使用任务保存的稳定角色引用,其次使用当前标准名和别名匹配。合并只追加无冲突别名、空缺身份、种族和首次登场;已有非空字段保持不变。</span></div>
9136
+ <div class="character-extraction-editor-toolbar"><span data-character-extraction-selected-count>已选择 0 项</span><button class="ghost-button" type="button" data-character-extraction-select-all>全选可处理项</button><button class="ghost-button" type="button" data-character-extraction-clear>全部跳过</button></div>
9137
+ <div class="character-extraction-candidate-list">${items.map((item, index) => {
9138
+ const matches = Array.isArray(item.matchCandidates) ? item.matchCandidates : [];
9139
+ const suggestedAction = item.suggestedAction === "merge" && matches.length ? "merge" : item.suggestedAction === "skip" ? "skip" : "create";
9140
+ const selected = suggestedAction !== "skip";
9141
+ const actionOptions = [
9142
+ ...(matches.length ? [["merge", "合并到已有角色"]] : []),
9143
+ ["create", "创建新角色档案"],
9144
+ ["skip", "跳过,不写入"]
9145
+ ];
9146
+ const conflicts = Array.isArray(item.conflicts) ? item.conflicts : [];
9147
+ const evidence = item.firstEvidence && typeof item.firstEvidence === "object" ? item.firstEvidence : null;
9148
+ return `<article class="character-extraction-candidate${selected ? "" : " is-skipped"}" data-character-extraction-candidate="${esc(item.candidateId)}">
9149
+ <header>
9150
+ <label class="character-extraction-candidate-toggle"><input type="checkbox" data-character-extraction-selected ${selected ? "checked" : ""} aria-describedby="character-extraction-candidate-note-${index}"><span>选择候选</span></label>
9151
+ <div><strong>${esc(item.name || `候选 ${index + 1}`)}</strong><small>${matches.length ? `${matches.length} 个已有角色匹配` : "未命中已有角色"}</small></div>
9152
+ <span class="character-extraction-strategy">${suggestedAction === "merge" ? "建议合并" : suggestedAction === "create" ? "建议新建" : "需要确认"}</span>
9153
+ </header>
9154
+ <p id="character-extraction-candidate-note-${index}" class="character-extraction-candidate-note">${conflicts.length ? esc(conflicts.join(";")) : matches.length ? `匹配依据:${esc(matches.map(characterExtractionMatchLabel).join("、"))}` : "名称与别名当前均可用于新建。"}</p>
9155
+ <div class="character-extraction-fields">
9156
+ <label>处理策略<select data-character-extraction-action>${actionOptions.map(([value, label]) => `<option value="${value}" ${value === suggestedAction ? "selected" : ""}>${label}</option>`).join("")}</select></label>
9157
+ <label class="character-extraction-target ${suggestedAction === "merge" ? "" : "hidden"}">合并目标<select data-character-extraction-target>${matches.map((match) => `<option value="${esc(match.characterId)}">${esc(match.name)} · v${Number(match.versionNo)} · ${esc(characterExtractionMatchLabel(match))}</option>`).join("")}</select></label>
9158
+ <label>标准名<input type="text" maxlength="200" value="${esc(item.name || "")}" data-character-extraction-name></label>
9159
+ <label>种族<input type="text" maxlength="200" value="${esc(item.species || "")}" data-character-extraction-species placeholder="仅匹配当前作品已有种族"></label>
9160
+ <label class="character-extraction-wide-field">别名<textarea maxlength="20100" rows="2" data-character-extraction-aliases placeholder="每行或逗号分隔一个别名">${esc((Array.isArray(item.aliases) ? item.aliases : []).join("\n"))}</textarea></label>
9161
+ <label class="character-extraction-wide-field">身份与定位<textarea maxlength="20000" rows="2" data-character-extraction-identity>${esc(item.identity || "")}</textarea></label>
9162
+ </div>
9163
+ ${evidence ? `<details><summary>查看首次出现证据</summary><p><strong>${esc(evidence.chapterTitle || "原文章节")}</strong><q>${esc(evidence.quote || "")}</q></p></details>` : ""}
9164
+ </article>`;
9165
+ }).join("")}</div>
9166
+ <p class="character-extraction-apply-error hidden" data-character-extraction-error role="alert"></p>
9167
+ <div class="character-extraction-apply-actions"><button class="primary-button" type="button" data-apply-character-extraction>确认并应用所选候选</button><small>确认采用整批事务;任一新建项名称冲突时全部回滚。重复点击或网络重试不会重复创建。</small></div>
9168
+ </div>`;
9169
+ }
9170
+
9171
+ function parseCharacterExtractionAliases(value) {
9172
+ return String(value ?? "").split(/[\n,,]/u).map((alias) => alias.trim()).filter(Boolean);
9173
+ }
9174
+
9175
+ function bindCharacterExtractionEditor(container, taskId) {
9176
+ const editor = container.querySelector("[data-character-extraction-editor]");
9177
+ if (!editor) return;
9178
+ editor.addEventListener("keydown", (event) => {
9179
+ if (event.key === "Enter" && event.target instanceof HTMLInputElement && event.target.type === "text") {
9180
+ event.preventDefault();
9181
+ }
9182
+ });
9183
+ const candidates = [...editor.querySelectorAll("[data-character-extraction-candidate]")];
9184
+ const applyButton = editor.querySelector("[data-apply-character-extraction]");
9185
+ const selectedCount = editor.querySelector("[data-character-extraction-selected-count]");
9186
+ const updateCandidate = (candidate) => {
9187
+ const selected = candidate.querySelector("[data-character-extraction-selected]");
9188
+ const action = candidate.querySelector("[data-character-extraction-action]");
9189
+ const targetField = candidate.querySelector(".character-extraction-target");
9190
+ const enabled = Boolean(selected?.checked) && action?.value !== "skip";
9191
+ candidate.classList.toggle("is-skipped", !enabled);
9192
+ targetField?.classList.toggle("hidden", action?.value !== "merge" || !enabled);
9193
+ candidate.querySelectorAll(".character-extraction-fields input, .character-extraction-fields textarea, .character-extraction-fields select").forEach((control) => {
9194
+ control.disabled = !enabled;
9195
+ });
9196
+ if (action) action.disabled = !selected?.checked;
9197
+ };
9198
+ const updateCount = () => {
9199
+ candidates.forEach(updateCandidate);
9200
+ const count = candidates.filter((candidate) => candidate.querySelector("[data-character-extraction-selected]")?.checked
9201
+ && candidate.querySelector("[data-character-extraction-action]")?.value !== "skip").length;
9202
+ if (selectedCount) selectedCount.textContent = `已选择 ${count} 项,共 ${candidates.length} 项`;
9203
+ if (applyButton) {
9204
+ applyButton.disabled = count === 0;
9205
+ applyButton.textContent = count > 0 ? `确认并应用 ${count} 项` : "请至少选择一项";
9206
+ }
9207
+ };
9208
+ for (const candidate of candidates) {
9209
+ const selected = candidate.querySelector("[data-character-extraction-selected]");
9210
+ const action = candidate.querySelector("[data-character-extraction-action]");
9211
+ selected?.addEventListener("change", () => {
9212
+ if (selected.checked && action?.value === "skip") {
9213
+ action.value = candidate.querySelector("[data-character-extraction-target] option") ? "merge" : "create";
9214
+ }
9215
+ updateCount();
9216
+ });
9217
+ action?.addEventListener("change", () => {
9218
+ if (selected) selected.checked = action.value !== "skip";
9219
+ updateCount();
9220
+ });
9221
+ }
9222
+ editor.querySelector("[data-character-extraction-select-all]")?.addEventListener("click", () => {
9223
+ for (const candidate of candidates) {
9224
+ const selected = candidate.querySelector("[data-character-extraction-selected]");
9225
+ const action = candidate.querySelector("[data-character-extraction-action]");
9226
+ if (selected) selected.checked = true;
9227
+ if (action?.value === "skip") action.value = candidate.querySelector("[data-character-extraction-target] option") ? "merge" : "create";
9228
+ }
9229
+ updateCount();
9230
+ });
9231
+ editor.querySelector("[data-character-extraction-clear]")?.addEventListener("click", () => {
9232
+ for (const candidate of candidates) {
9233
+ const selected = candidate.querySelector("[data-character-extraction-selected]");
9234
+ const action = candidate.querySelector("[data-character-extraction-action]");
9235
+ if (selected) selected.checked = false;
9236
+ if (action) action.value = "skip";
9237
+ }
9238
+ updateCount();
9239
+ });
9240
+ applyButton?.addEventListener("click", async () => {
9241
+ if (applyButton.disabled) return;
9242
+ const errorHost = editor.querySelector("[data-character-extraction-error]");
9243
+ const selections = candidates.map((candidate) => {
9244
+ const selected = candidate.querySelector("[data-character-extraction-selected]")?.checked;
9245
+ const chosenAction = candidate.querySelector("[data-character-extraction-action]")?.value;
9246
+ const action = selected && chosenAction !== "skip" ? chosenAction : "skip";
9247
+ if (action === "skip") return { candidateId: candidate.dataset.characterExtractionCandidate, action };
9248
+ return {
9249
+ candidateId: candidate.dataset.characterExtractionCandidate,
9250
+ action,
9251
+ ...(action === "merge" ? { targetCharacterId: candidate.querySelector("[data-character-extraction-target]")?.value } : {}),
9252
+ name: candidate.querySelector("[data-character-extraction-name]")?.value.trim(),
9253
+ aliases: parseCharacterExtractionAliases(candidate.querySelector("[data-character-extraction-aliases]")?.value),
9254
+ species: candidate.querySelector("[data-character-extraction-species]")?.value.trim(),
9255
+ attributes: { identity: candidate.querySelector("[data-character-extraction-identity]")?.value.trim() }
9256
+ };
9257
+ });
9258
+ editor.querySelectorAll("button, input, textarea, select").forEach((control) => { control.disabled = true; });
9259
+ applyButton.textContent = "正在应用角色档案";
9260
+ errorHost?.classList.add("hidden");
9261
+ try {
9262
+ const applied = await api(`/api/tasks/${encodeURIComponent(taskId)}/character-extraction/apply`, {
9263
+ method: "POST",
9264
+ body: { previewToken: editor.dataset.previewToken, selections }
9265
+ });
9266
+ $("#form-dialog").close();
9267
+ toast(`角色候选已处理:新建 ${Number(applied.createdCount ?? 0)} 个,合并 ${Number(applied.mergedCount ?? 0)} 个,跳过 ${Number(applied.skippedCount ?? 0)} 个`);
9268
+ await loadAiReferences();
9269
+ if (state.module === "tasks") await renderTasks();
9270
+ } catch (error) {
9271
+ const message = error instanceof Error ? error.message : "角色候选应用失败";
9272
+ if (errorHost) {
9273
+ errorHost.textContent = message;
9274
+ errorHost.classList.remove("hidden");
9275
+ }
9276
+ toast(message, "error");
9277
+ editor.querySelectorAll("button, input, textarea, select").forEach((control) => { control.disabled = false; });
9278
+ updateCount();
9279
+ }
9280
+ });
9281
+ updateCount();
9282
+ }
9283
+
7674
9284
  function renderTaskResult(task) {
7675
9285
  const result = task.resultSummary && typeof task.resultSummary === "object" ? task.resultSummary : {};
7676
9286
  const metrics = Array.isArray(result.metrics) ? result.metrics : [];
@@ -7684,6 +9294,7 @@ function renderTaskResult(task) {
7684
9294
  ${result.restricted ? '<p class="task-result-warning">部分结果因当前账号权限受限而隐藏。</p>' : ""}
7685
9295
  </section>
7686
9296
  ${renderRelationshipChangePreview(task, result)}
9297
+ ${renderCharacterExtractionPreview(task, result)}
7687
9298
  <section class="task-result-section">
7688
9299
  <h4>结果保存位置</h4>
7689
9300
  <p><strong>作品</strong> ${esc(state.work?.title || "当前作品")}</p>
@@ -7704,6 +9315,27 @@ function renderTaskResult(task) {
7704
9315
  }
7705
9316
 
7706
9317
  function bindTaskResultActions(container) {
9318
+ container.querySelectorAll("[data-load-character-extraction-preview]").forEach((button) => button.addEventListener("click", async () => {
9319
+ if (button.disabled) return;
9320
+ const section = button.closest("[data-character-extraction-preview]");
9321
+ const content = section?.querySelector("[data-character-extraction-preview-content]");
9322
+ if (!section || !content) return;
9323
+ button.disabled = true;
9324
+ button.textContent = "正在加载角色候选";
9325
+ content.innerHTML = '<p class="character-extraction-loading" role="status">正在根据当前角色库重新核对名称、别名与稳定引用…</p>';
9326
+ try {
9327
+ const taskId = button.dataset.loadCharacterExtractionPreview;
9328
+ const preview = await api(`/api/tasks/${encodeURIComponent(taskId)}/character-extraction/preview`);
9329
+ content.innerHTML = renderCharacterExtractionEditor(preview);
9330
+ button.textContent = "角色候选已加载";
9331
+ bindCharacterExtractionEditor(content, taskId);
9332
+ } catch (error) {
9333
+ content.innerHTML = `<p class="character-extraction-apply-error" role="alert">${esc(error.message)}</p>`;
9334
+ button.disabled = false;
9335
+ button.textContent = "重新加载角色候选";
9336
+ toast(error.message, "error");
9337
+ }
9338
+ }));
7707
9339
  container.querySelectorAll("[data-load-task-result-json]").forEach((button) => button.addEventListener("click", async () => {
7708
9340
  if (button.disabled) return;
7709
9341
  const content = button.closest(".task-result-json-loader")?.querySelector("[data-task-result-json-content]");
@@ -7936,12 +9568,24 @@ function renderProviderCards(providers, models) {
7936
9568
 
7937
9569
  function bindPlatformProviderActions(host, providers, models) {
7938
9570
  host.querySelectorAll("[data-test-provider]").forEach((button) => button.addEventListener("click", async () => {
9571
+ const providerId = button.dataset.testProvider;
7939
9572
  button.disabled = true;
7940
9573
  button.textContent = "测试中";
7941
- const result = await api(`/api/providers/${button.dataset.testProvider}/test`, { method: "POST", body: {} });
7942
- toast(result.ok ? "连接测试成功" : `连接失败:${result.error}`, result.ok ? "info" : "error");
7943
- await renderPlatformAiConfig();
7944
- await loadModels();
9574
+ try {
9575
+ const result = await api(`/api/providers/${providerId}/test`, { method: "POST", body: {} });
9576
+ const notification = connectivityTestResultToast(result, "provider");
9577
+ toast(notification.message, notification.type);
9578
+ await renderPlatformAiConfig();
9579
+ await loadModels();
9580
+ } catch (error) {
9581
+ const notification = connectivityTestErrorToast(error, "provider");
9582
+ toast(notification.message, notification.type);
9583
+ } finally {
9584
+ button.disabled = false;
9585
+ button.textContent = "测试连接";
9586
+ const focusTarget = button.isConnected ? button : host.querySelector(`[data-test-provider="${CSS.escape(providerId)}"]`);
9587
+ focusTarget?.focus({ preventScroll: true });
9588
+ }
7945
9589
  }));
7946
9590
  host.querySelectorAll("[data-add-model]").forEach((button) => button.addEventListener("click", () => openModelDialog(button.dataset.addModel, null, providers.find((provider) => provider.id === button.dataset.addModel))));
7947
9591
  host.querySelectorAll("[data-edit-model]").forEach((button) => button.addEventListener("click", () => {
@@ -9026,7 +10670,13 @@ function renderKnowledgeMarkdownSections() {
9026
10670
  const label = knowledgeEditorKind === "race" ? "种族" : "组织";
9027
10671
  const canEdit = !entityEditorReadOnly && canEditModule(knowledgeEditorKind === "race" ? "races" : "organizations");
9028
10672
  const sections = Array.isArray(knowledgeEditorSections) ? knowledgeEditorSections : [];
9029
- host.innerHTML = `<div class="knowledge-markdown-list-toolbar"><div><b>${label} Markdown 设定</b><span>将每条设定单独保存为章节,需要编辑时打开大编辑器。</span></div>${canEdit ? '<button type="button" class="ghost-button" data-knowledge-section-create>新建设定</button>' : ""}</div>${sections.length ? `<div class="knowledge-markdown-list">${sections.map((section, index) => `<article class="knowledge-markdown-section" data-knowledge-section-index="${index}"><header><div><span>设定 ${index + 1}</span><h4>${esc(section.title || `未命名设定 ${index + 1}`)}</h4>${section.summary ? `<p>${esc(section.summary)}</p>` : ""}</div><div>${canEdit ? `<button type="button" data-knowledge-section-edit="${index}">编辑</button><button type="button" data-knowledge-section-delete="${index}">删除</button>` : ""}</div></header><p class="knowledge-section-card-preview">${esc(knowledgeSectionPreviewText(section))}</p></article>`).join("")}</div>` : '<p class="knowledge-markdown-empty">还没有 Markdown 设定,点击“新建设定”开始记录。</p>'}`;
10673
+ const sectionCards = sections.map((section, index) => {
10674
+ const content = knowledgeEditorKind === "race"
10675
+ ? `<div class="knowledge-markdown-document character-markdown-document message-body">${renderMarkdown(section.contentMarkdown) || '<p class="character-markdown-empty">本设定暂无正文。</p>'}</div>`
10676
+ : `<p class="knowledge-section-card-preview">${esc(knowledgeSectionPreviewText(section))}</p>`;
10677
+ return `<article class="knowledge-markdown-section" data-knowledge-section-index="${index}"><header><div><span>设定 ${index + 1}</span><h4>${esc(section.title || `未命名设定 ${index + 1}`)}</h4>${section.summary ? `<p>${esc(section.summary)}</p>` : ""}</div><div>${canEdit ? `<button type="button" data-knowledge-section-edit="${index}">编辑</button><button type="button" data-knowledge-section-delete="${index}">删除</button>` : ""}</div></header>${content}</article>`;
10678
+ }).join("");
10679
+ host.innerHTML = `<div class="knowledge-markdown-list-toolbar"><div><b>${label} Markdown 设定</b><span>将每条设定单独保存为章节,需要编辑时打开大编辑器。</span></div>${canEdit ? '<button type="button" class="ghost-button" data-knowledge-section-create>新建设定</button>' : ""}</div>${sectionCards ? `<div class="knowledge-markdown-list">${sectionCards}</div>` : '<p class="knowledge-markdown-empty">还没有 Markdown 设定,点击“新建设定”开始记录。</p>'}`;
9030
10680
  host.querySelector("[data-knowledge-section-create]")?.addEventListener("click", () => void openKnowledgeSectionEditor());
9031
10681
  host.querySelectorAll("[data-knowledge-section-edit]").forEach((button) => button.addEventListener("click", () => void openKnowledgeSectionEditor(Number(button.dataset.knowledgeSectionEdit))));
9032
10682
  host.querySelectorAll("[data-knowledge-section-delete]").forEach((button) => button.addEventListener("click", async () => {
@@ -9223,21 +10873,43 @@ function openWorkDialog() {
9223
10873
  }
9224
10874
 
9225
10875
  function workCoverFieldHtml(work) {
10876
+ const coverUpload = state.coverUpload?.workId === work.id ? state.coverUpload : null;
10877
+ const preview = coverUpload
10878
+ ? `<div class="work-cover-preview is-uploading" data-work-cover-preview role="status" aria-live="polite" aria-busy="true" aria-label="正在上传封面">
10879
+ <div class="image-upload-placeholder-box" aria-hidden="true"><span class="image-upload-placeholder-icon">IMG</span><span class="image-upload-placeholder-shine"></span></div>
10880
+ </div>`
10881
+ : `<div class="work-cover-preview ${work.coverUrl ? "has-cover" : ""}" data-work-cover-preview aria-hidden="${work.coverUrl ? "false" : "true"}">
10882
+ ${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面预览">` : "<span>暂无封面</span>"}
10883
+ </div>`;
9226
10884
  return `<section class="work-cover-field" aria-labelledby="work-cover-title">
9227
10885
  <div class="work-cover-copy">
9228
- <strong id="work-cover-title">封面</strong>
9229
- <small>用于书架展示。支持 PNG、JPEG、WebP;文件不超过 ${formatUploadLimit(imageUploadLimits.coverBytes)}。</small>
9230
- </div>
9231
- <div class="work-cover-preview ${work.coverUrl ? "has-cover" : ""}" aria-hidden="${work.coverUrl ? "false" : "true"}">
9232
- ${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面预览">` : "<span>暂无封面</span>"}
10886
+ <strong id="work-cover-title">${coverUpload ? "正在上传封面" : "封面"}</strong>
10887
+ <small>${coverUpload ? esc(coverUpload.fileName) : `用于书架展示。支持 PNG、JPEG、WebP;文件不超过 ${formatUploadLimit(imageUploadLimits.coverBytes)}。`}</small>
9233
10888
  </div>
10889
+ ${preview}
10890
+ ${coverUpload ? `<div class="work-cover-upload-progress">${uploadProgressHtml("封面上传进度", coverUpload.fileName, coverUpload.progress)}</div>` : ""}
9234
10891
  <div class="work-cover-actions">
9235
- <button id="work-cover-upload" class="ghost-button" type="button">${work.coverUrl ? "更换封面" : "设置封面"}</button>
9236
- ${work.coverUrl ? '<button id="work-cover-remove" class="ghost-button" type="button">移除封面</button>' : ""}
10892
+ <button id="work-cover-upload" class="ghost-button" type="button"${coverUpload ? " disabled" : ""}>${coverUpload ? "上传中…" : work.coverUrl ? "更换封面" : "设置封面"}</button>
10893
+ ${work.coverUrl ? `<button id="work-cover-remove" class="ghost-button" type="button"${coverUpload ? " disabled" : ""}>移除封面</button>` : ""}
9237
10894
  </div>
9238
10895
  </section>`;
9239
10896
  }
9240
10897
 
10898
+ function renderWorkCoverUploadProgress(workId, progress) {
10899
+ if (state.coverUpload?.workId !== workId) return;
10900
+ state.coverUpload.progress = progress;
10901
+ const field = $("#dialog-fields")?.querySelector(".work-cover-field");
10902
+ if (!field) return;
10903
+ updateUploadProgress(field.querySelector("[data-upload-progress]"), progress);
10904
+ }
10905
+
10906
+ function refreshWorkCoverField(work) {
10907
+ const field = $("#dialog-fields")?.querySelector(".work-cover-field");
10908
+ if (!field || !$("#form-dialog")?.open) return;
10909
+ field.outerHTML = workCoverFieldHtml(work);
10910
+ bindWorkCoverControls(work);
10911
+ }
10912
+
9241
10913
  function bindWorkCoverControls(work) {
9242
10914
  $("#work-cover-upload")?.addEventListener("click", () => {
9243
10915
  state.pendingCoverWorkId = work.id;
@@ -9249,11 +10921,7 @@ function bindWorkCoverControls(work) {
9249
10921
  state.works = (await apiPage("/api/works")).items;
9250
10922
  const updated = state.works.find((item) => item.id === work.id) ?? { ...work, coverUrl: null };
9251
10923
  Object.assign(work, updated);
9252
- const coverField = $("#dialog-fields")?.querySelector(".work-cover-field");
9253
- if (coverField) {
9254
- coverField.outerHTML = workCoverFieldHtml(work);
9255
- bindWorkCoverControls(work);
9256
- }
10924
+ refreshWorkCoverField(work);
9257
10925
  renderShelf();
9258
10926
  toast("封面已移除");
9259
10927
  } catch (error) {
@@ -9262,21 +10930,69 @@ function bindWorkCoverControls(work) {
9262
10930
  });
9263
10931
  }
9264
10932
 
9265
- function downloadWorkManuscript(work, format = "markdown") {
10933
+ async function prepareEpubDownload(path, trigger) {
10934
+ if (!path || trigger?.disabled) return;
10935
+ if (trigger) {
10936
+ trigger.disabled = true;
10937
+ trigger.setAttribute("aria-busy", "true");
10938
+ }
10939
+ toast("正在准备 EPUB 下载…");
10940
+ try {
10941
+ let response;
10942
+ try {
10943
+ response = await fetch(path, { method: "HEAD", headers: { Accept: "application/epub+zip" } });
10944
+ } catch (error) {
10945
+ updateSystemHealth({ status: "offline" });
10946
+ throw error;
10947
+ }
10948
+ updateSystemHealth({ status: response.status >= 500 ? "degraded" : "ready" });
10949
+ if (!response.ok) {
10950
+ if (response.status === 401) {
10951
+ const restarted = await checkSystemBoot(true);
10952
+ if (!restarted) invalidateAuthentication();
10953
+ }
10954
+ throw new Error(`请求失败:${response.status}`);
10955
+ }
10956
+ window.location.href = path;
10957
+ toast("EPUB 下载已开始");
10958
+ } catch (error) {
10959
+ toast(`EPUB 导出失败:${error instanceof Error ? error.message : "未知错误"}`, "error");
10960
+ } finally {
10961
+ if (trigger?.isConnected) {
10962
+ trigger.disabled = false;
10963
+ trigger.removeAttribute("aria-busy");
10964
+ trigger.focus();
10965
+ }
10966
+ }
10967
+ }
10968
+
10969
+ function downloadVolumeEpub(volume, trigger) {
10970
+ if (!volume?.id) return;
10971
+ return prepareEpubDownload(`/api/volumes/${encodeURIComponent(volume.id)}/export?format=epub`, trigger);
10972
+ }
10973
+
10974
+ function downloadWorkManuscript(work, format = "markdown", trigger = null) {
9266
10975
  if (!work?.id) return;
9267
- const exportFormat = format === "docx" ? "docx" : "markdown";
10976
+ const exportFormat = ["docx", "epub"].includes(format) ? format : "markdown";
10977
+ if (exportFormat === "epub") {
10978
+ return prepareEpubDownload(`/api/works/${encodeURIComponent(work.id)}/export?format=epub`, trigger);
10979
+ }
9268
10980
  window.location.href = `/api/works/${encodeURIComponent(work.id)}/export?format=${exportFormat}`;
9269
10981
  }
9270
10982
 
9271
10983
  let manuscriptExportWork = null;
10984
+ let manuscriptExportAnchor = null;
9272
10985
 
9273
- function closeManuscriptExportMenu() {
10986
+ function closeManuscriptExportMenu(restoreFocus = false) {
9274
10987
  const menu = $("#manuscript-export-menu");
9275
10988
  if (!menu) return;
10989
+ const anchor = manuscriptExportAnchor;
9276
10990
  menu.classList.add("hidden");
9277
10991
  manuscriptExportWork = null;
10992
+ manuscriptExportAnchor = null;
9278
10993
  $("#export-button")?.setAttribute("aria-expanded", "false");
9279
10994
  $("#work-export-button")?.setAttribute("aria-expanded", "false");
10995
+ if (restoreFocus && anchor?.isConnected) anchor.focus();
9280
10996
  }
9281
10997
 
9282
10998
  function showManuscriptExportMenu(anchor, work) {
@@ -9284,6 +11000,7 @@ function showManuscriptExportMenu(anchor, work) {
9284
11000
  const menu = $("#manuscript-export-menu");
9285
11001
  if (!menu) return;
9286
11002
  manuscriptExportWork = work;
11003
+ manuscriptExportAnchor = anchor;
9287
11004
  menu.classList.remove("hidden");
9288
11005
  const anchorRect = anchor.getBoundingClientRect();
9289
11006
  const menuRect = menu.getBoundingClientRect();
@@ -9314,19 +11031,23 @@ function openWorkSettingsDialog(work) {
9314
11031
  <button id="import-history-button" class="ghost-button" type="button" aria-controls="import-history-dialog" aria-haspopup="dialog" ${canOpenImportHistory ? "" : "disabled"}>${importHistoryAction}</button>
9315
11032
  </section>`;
9316
11033
  const exportField = `<section class="work-access-field" aria-labelledby="work-export-settings-title">
9317
- <div><strong id="work-export-settings-title">导出正文</strong><small>点击后选择导出 Markdown ZIP 或 DOCX(书名、分卷、章节为一级至三级标题;若已设置封面则嵌入为首页)。不包含角色、设定、关系、时间轴、大纲、伏笔或 AI 分析资料。</small></div>
11034
+ <div><strong id="work-export-settings-title">导出正文</strong><small>点击后选择 Markdown ZIP、DOCXEPUB;EPUB 包含作品元信息、封面、分卷层级和可跳转目录。不包含角色、设定、关系、时间轴、大纲、伏笔或 AI 分析资料。</small></div>
9318
11035
  <button id="work-export-button" class="ghost-button" type="button" aria-haspopup="menu" aria-controls="manuscript-export-menu" aria-expanded="false">导出正文</button>
9319
11036
  </section>`;
9320
11037
  const recycleBinField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="chapter-recycle-bin-settings-title">
9321
- <div><strong id="chapter-recycle-bin-settings-title">章节回收站</strong><small>恢复已软删除的章节,或彻底删除正文、版本和关联资料。</small></div>
11038
+ <div><strong id="chapter-recycle-bin-settings-title">正文回收站</strong><small>恢复已删除的分卷与章节,或在确认后彻底清理正文、版本和关联资料。</small></div>
9322
11039
  <button id="chapter-recycle-bin-button" class="ghost-button" type="button" aria-controls="chapter-recycle-bin-dialog" aria-haspopup="dialog" ${canEditProse() ? "" : "disabled"}>打开回收站</button>
9323
11040
  </section>` : "";
11041
+ const deleteField = canManageAccess ? `<section class="work-access-field work-danger-field" aria-labelledby="work-delete-settings-title">
11042
+ <div><strong id="work-delete-settings-title">删除作品</strong><small>作品会先移入回收站并完整保留正文、设定、关联资料与版本历史,默认 30 天后自动清理。</small></div>
11043
+ <button id="work-delete-button" class="danger-button" type="button">移入作品回收站</button>
11044
+ </section>` : "";
9324
11045
  const whitespaceField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="whitespace-settings-title">
9325
11046
  <div><strong id="whitespace-settings-title">正文空白符</strong><small>在编辑器正文中显示或隐藏空格、全角空格和 Tab 的可视标记。</small></div>
9326
11047
  <button id="toggle-whitespace-settings" class="ghost-button" data-toggle-whitespace type="button" aria-pressed="${chapterWhitespaceVisible}" title="用点标记半角空格,用方框标记全角空格,用箭头标记 Tab">${chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符"}</button>
9327
11048
  </section>` : "";
9328
11049
  openDialog("作品信息",
9329
- workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + whitespaceField + accessField + importHistoryField + exportField + recycleBinField,
11050
+ workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + whitespaceField + accessField + importHistoryField + exportField + recycleBinField + deleteField,
9330
11051
  async (form) => {
9331
11052
  await api(`/api/works/${work.id}`, { method: "PATCH", body: { title: form.get("title"), author: form.get("author"), description: form.get("description") } });
9332
11053
  state.works = (await apiPage("/api/works")).items;
@@ -9358,12 +11079,45 @@ function openWorkSettingsDialog(work) {
9358
11079
  $("#form-dialog").close();
9359
11080
  void openChapterRecycleBin();
9360
11081
  });
11082
+ $("#work-delete-button")?.addEventListener("click", () => {
11083
+ void deleteWork(work);
11084
+ });
9361
11085
  $("#work-access-manage")?.addEventListener("click", () => {
9362
11086
  $("#form-dialog").close();
9363
11087
  openMembersDialog(work);
9364
11088
  });
9365
11089
  }
9366
11090
 
11091
+ async function deleteWork(work) {
11092
+ if (!work || !canManageWork(work)) return toast("仅作品创建者或系统管理员可以删除作品", "error");
11093
+ $("#form-dialog").close();
11094
+ const deletingCurrentWork = state.work?.id === work.id;
11095
+ const unsavedWarning = deletingCurrentWork && state.dirty ? " 当前章节尚有未保存修改,这些本地修改不会进入回收站。" : "";
11096
+ if (!await confirmToast(`将作品“${work.title}”移入回收站吗?正文、设定、关联资料和版本历史会完整保留,默认 30 天内可恢复。${unsavedWarning}`, {
11097
+ title: "删除作品",
11098
+ confirmLabel: "移入回收站"
11099
+ })) return openWorkSettingsDialog(work);
11100
+ if (deletingCurrentWork) cancelChapterAutoSave();
11101
+ try {
11102
+ await api(`/api/works/${encodeURIComponent(work.id)}`, {
11103
+ method: "DELETE",
11104
+ body: { expectedVersionNo: work.versionNo }
11105
+ });
11106
+ if (deletingCurrentWork) {
11107
+ state.dirty = false;
11108
+ resetWorkScopedUiCaches();
11109
+ state.work = null;
11110
+ state.chapter = null;
11111
+ }
11112
+ await loadWorks();
11113
+ toast(`作品“${work.title}”已移入回收站`);
11114
+ } catch (error) {
11115
+ if (deletingCurrentWork && state.dirty) scheduleChapterAutoSave();
11116
+ toast(error.message, "error");
11117
+ openWorkSettingsDialog(work);
11118
+ }
11119
+ }
11120
+
9367
11121
  async function openChapterDialog(volumeId = null) {
9368
11122
  if (!state.work) return openWorkDialog();
9369
11123
  if (!canEditProse()) return toast("当前权限只能编辑设定资料,正文为只读", "error");
@@ -9385,8 +11139,8 @@ function openVolumeDialog(item) {
9385
11139
  if (!canEditProse()) return toast("当前权限只能编辑设定资料,不能修改分卷", "error");
9386
11140
  const kindOptions = [["main", "正文卷"], ["prequel", "前传"], ["extra", "番外"], ["epilogue", "后记"], ["appendix", "附录"]];
9387
11141
  const management = item ? `<section class="entity-dialog-management" aria-label="分卷操作">
9388
- <div><strong>分卷操作</strong><small>仅空分卷可以删除;回收站章节需先彻底删除,或恢复后移动到其他分卷。</small></div>
9389
- <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-volume-delete>删除分卷</button></div>
11142
+ <div><strong>分卷操作</strong><small>分卷会连同其中章节移入回收站,正文、版本和关联资料默认保留 30 天。</small></div>
11143
+ <div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-volume-delete>移入回收站</button></div>
9390
11144
  </section>` : "";
9391
11145
  openDialog(item ? "编辑分卷" : "新建分卷",
9392
11146
  field("title", "分卷名称", "text", item?.title) +
@@ -9414,16 +11168,16 @@ function openVolumeDialog(item) {
9414
11168
  async function deleteVolume(item) {
9415
11169
  if (!state.work || !item || !canEditProse()) return toast("当前权限不能删除分卷", "error");
9416
11170
  $("#form-dialog").close();
9417
- if (!await confirmToast(`确认删除空分卷“${item.title}”吗?分卷信息和版本记录仍会保留。`, {
11171
+ if (!await confirmToast(`将分卷“${item.title}”移入回收站吗?其中章节会一并隐藏,正文、版本和关联资料默认保留 30 天。`, {
9418
11172
  title: "删除分卷",
9419
- confirmLabel: "确认删除"
11173
+ confirmLabel: "移入回收站"
9420
11174
  })) return openVolumeDialog(item);
9421
11175
  try {
9422
11176
  await api(`/api/volumes/${item.id}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
9423
11177
  state.collapsedVolumeIds.delete(item.id);
9424
11178
  state.work = await api(`/api/works/${state.work.id}`);
9425
11179
  renderTree();
9426
- toast(`已删除分卷“${item.title}”`);
11180
+ toast(`分卷“${item.title}”已移入回收站`);
9427
11181
  } catch (error) {
9428
11182
  toast(error.message, "error");
9429
11183
  openVolumeDialog(item);
@@ -9726,11 +11480,51 @@ function markdownImageLabel(file, fallback = "图片附件") {
9726
11480
  return String(file?.name ?? "").replace(/[\[\]\r\n]/gu, "").trim() || fallback;
9727
11481
  }
9728
11482
 
9729
- async function uploadMarkdownAttachment(file, module = "settings") {
11483
+ function createVditorUploadPlaceholder(editor, file) {
11484
+ const host = editor?.vditor?.element;
11485
+ if (!host) return { update: () => {}, complete: () => {}, fail: () => {} };
11486
+ let queue = host.querySelector("[data-vditor-upload-queue]");
11487
+ if (!queue) {
11488
+ queue = document.createElement("div");
11489
+ queue.className = "vditor-upload-queue";
11490
+ queue.dataset.vditorUploadQueue = "";
11491
+ queue.setAttribute("aria-live", "polite");
11492
+ host.append(queue);
11493
+ }
11494
+ queue.hidden = false;
11495
+ const item = document.createElement("article");
11496
+ item.className = "vditor-upload-placeholder";
11497
+ item.setAttribute("role", "status");
11498
+ item.innerHTML = `<div class="image-upload-placeholder-box" aria-hidden="true"><span class="image-upload-placeholder-icon">IMG</span><span class="image-upload-placeholder-shine"></span></div>
11499
+ <div class="vditor-upload-placeholder-copy"><strong data-vditor-upload-status>正在上传图片</strong><small title="${esc(file.name)}">${esc(file.name)}</small>${uploadProgressHtml("图片上传进度", file.name)}</div>`;
11500
+ queue.append(item);
11501
+ const remove = () => {
11502
+ item.remove();
11503
+ if (!queue.children.length) queue.hidden = true;
11504
+ };
11505
+ return {
11506
+ update: (progress) => updateUploadProgress(item.querySelector("[data-upload-progress]"), progress),
11507
+ complete: () => {
11508
+ item.classList.add("is-complete");
11509
+ const status = item.querySelector("[data-vditor-upload-status]");
11510
+ if (status) status.textContent = "图片上传完成";
11511
+ updateUploadProgress(item.querySelector("[data-upload-progress]"), 100);
11512
+ window.setTimeout(remove, 900);
11513
+ },
11514
+ fail: () => {
11515
+ item.classList.add("is-failed");
11516
+ const status = item.querySelector("[data-vditor-upload-status]");
11517
+ if (status) status.textContent = "图片上传失败";
11518
+ window.setTimeout(remove, 3200);
11519
+ }
11520
+ };
11521
+ }
11522
+
11523
+ async function uploadMarkdownAttachment(file, module = "settings", onProgress = () => {}) {
9730
11524
  assertAttachmentFileSize(file);
9731
11525
  const body = new FormData();
9732
11526
  body.append("file", file);
9733
- const attachment = await api(`/api/works/${state.work.id}/attachments?module=${encodeURIComponent(module)}`, { method: "POST", body });
11527
+ const attachment = await uploadWithProgress(`/api/works/${state.work.id}/attachments?module=${encodeURIComponent(module)}`, { method: "POST", body }, onProgress);
9734
11528
  if (!attachment.deduplicated) markdownEditorPendingAttachments.push(String(attachment.id));
9735
11529
  return { attachment, imageLabel: markdownImageLabel(file) };
9736
11530
  }
@@ -9739,16 +11533,19 @@ function createVditorUploadHandler(uploadAttachment, getEditor) {
9739
11533
  return async (files) => {
9740
11534
  const selection = window.getSelection();
9741
11535
  const range = selection?.rangeCount ? selection.getRangeAt(0).cloneRange() : null;
11536
+ const editor = getEditor();
9742
11537
  const insertions = [];
9743
11538
  for (const file of files) {
11539
+ const placeholder = createVditorUploadPlaceholder(editor, file);
9744
11540
  try {
9745
- const { attachment, imageLabel } = await uploadAttachment(file);
11541
+ const { attachment, imageLabel } = await uploadAttachment(file, (progress) => placeholder.update(progress));
9746
11542
  insertions.push(`![${imageLabel}](attachment://${attachment.id})`);
11543
+ placeholder.complete();
9747
11544
  } catch (error) {
11545
+ placeholder.fail();
9748
11546
  toast(error.message, "error");
9749
11547
  }
9750
11548
  }
9751
- const editor = getEditor();
9752
11549
  if (editor && insertions.length > 0) {
9753
11550
  if (range && editor.vditor?.element?.contains(range.startContainer)) {
9754
11551
  selection?.removeAllRanges();
@@ -9792,7 +11589,7 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
9792
11589
  accept: "image/*",
9793
11590
  max: 10 * 1024 * 1024,
9794
11591
  multiple: true,
9795
- handler: createVditorUploadHandler(uploadAttachment ?? ((file) => uploadMarkdownAttachment(file, attachmentModule)), () => editor)
11592
+ handler: createVditorUploadHandler(uploadAttachment ?? ((file, onProgress) => uploadMarkdownAttachment(file, attachmentModule, onProgress)), () => editor)
9796
11593
  },
9797
11594
  input: (markdown) => {
9798
11595
  normalizeVditorAttachmentImages(editor);
@@ -10034,11 +11831,11 @@ function characterSectionImageLabel(file, fallback = "图片附件") {
10034
11831
  return String(file?.name ?? "").replace(/[\[\]\r\n]/gu, "").trim() || fallback;
10035
11832
  }
10036
11833
 
10037
- async function uploadCharacterSectionAttachment(file) {
11834
+ async function uploadCharacterSectionAttachment(file, onProgress = () => {}) {
10038
11835
  assertAttachmentFileSize(file);
10039
11836
  const body = new FormData();
10040
11837
  body.append("file", file);
10041
- const attachment = await api(`/api/works/${state.work.id}/attachments?module=characters`, { method: "POST", body });
11838
+ const attachment = await uploadWithProgress(`/api/works/${state.work.id}/attachments?module=characters`, { method: "POST", body }, onProgress);
10042
11839
  if (!attachment.deduplicated) characterSectionPendingAttachments.push(String(attachment.id));
10043
11840
  return {
10044
11841
  attachment,
@@ -10862,7 +12659,7 @@ function openOutlineDialog(item) {
10862
12659
  field("notes", "补充说明", "textarea", item.notes) +
10863
12660
  field("status", "规划状态", "select", item.status ?? "draft", [["draft", "草稿"], ["ready", "可执行"], ["completed", "已完成"]]),
10864
12661
  async (form) => {
10865
- await api(`/api/chapters/${item.chapterId}/outline`, { method: "PUT", body: {
12662
+ await api(`/api/chapters/${encodeURIComponent(item.chapterId)}/outline`, { method: "PUT", body: {
10866
12663
  goal: form.get("goal"), conflict: form.get("conflict"), turningPoint: form.get("turningPoint"), notes: form.get("notes"), status: form.get("status"), expectedVersionNo: item.versionNo
10867
12664
  } });
10868
12665
  await renderOutlines();
@@ -11443,6 +13240,7 @@ function openProviderDialog(item) {
11443
13240
  await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
11444
13241
  await renderPlatformAiConfig();
11445
13242
  await loadModels();
13243
+ if (item) toast(connectivityConfigurationSavedToast("provider"));
11446
13244
  },
11447
13245
  item ? "协议、限流与凭据" : "OpenAI / Anthropic / Google Vertex"
11448
13246
  );
@@ -11477,6 +13275,7 @@ function openModelDialog(providerId, item = null, provider = null) {
11477
13275
  await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
11478
13276
  await renderPlatformAiConfig();
11479
13277
  await loadModels();
13278
+ if (item) toast(connectivityConfigurationSavedToast("model"));
11480
13279
  }, item ? "模型配置" : "供应商模型");
11481
13280
  const modelIdInput = $("#dialog-fields input[name='modelId']");
11482
13281
  const contextWindowInput = $("#model-context-window");
@@ -11514,14 +13313,17 @@ function openModelDialog(providerId, item = null, provider = null) {
11514
13313
  button.textContent = "测试中";
11515
13314
  try {
11516
13315
  const result = await api(`/api/models/${button.dataset.testModel}/test`, { method: "POST", body: {} });
11517
- toast(result.ok ? (result.multimodalTested ? "模型连接测试成功,图片请求已验证" : "模型连接测试成功") : `模型连接失败:${result.error}`, result.ok ? "info" : "error");
13316
+ const notification = connectivityTestResultToast(result, "model");
13317
+ toast(notification.message, notification.type);
11518
13318
  await renderPlatformAiConfig();
11519
13319
  await loadModels();
11520
13320
  } catch (error) {
11521
- toast(`模型连接测试失败:${error.message}`, "error");
13321
+ const notification = connectivityTestErrorToast(error, "model");
13322
+ toast(notification.message, notification.type);
11522
13323
  } finally {
11523
13324
  button.disabled = false;
11524
13325
  button.textContent = "测试连接";
13326
+ if (button.isConnected) button.focus({ preventScroll: true });
11525
13327
  }
11526
13328
  });
11527
13329
  syncModelContextWindowGuidance();
@@ -11531,63 +13333,94 @@ function openModelDialog(providerId, item = null, provider = null) {
11531
13333
 
11532
13334
  async function sendAi() {
11533
13335
  if (!state.work) return toast("请先选择作品", "error");
11534
- try {
11535
- await ensureAiModelsLoaded();
11536
- } catch (error) {
11537
- setAiAssistantStatus("error");
11538
- return toast(`创作助手加载失败:${error.message}`, "error");
11539
- }
11540
- const modelId = $("#ai-model").value;
11541
- if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
11542
- const instruction = aiPromptText().trim();
13336
+ const composerSnapshot = captureAiPromptComposer();
13337
+ const instruction = composerSnapshot.text.trim();
11543
13338
  if (!instruction) return toast("请输入指令", "error");
11544
13339
  if ($("#ai-task").value === "roleplay" && !state.aiRoleplayCharacter) return toast("请先选择角色卡", "error");
11545
13340
  const requestScope = currentAiRequestScope();
11546
13341
  if (!requestScope) return toast("请先选择章节", "error");
11547
13342
  const { taskType, scope, selection } = requestScope;
11548
13343
  if (taskType === "polish" && !selection) return toast("请先在正文中选中一段文本", "error");
13344
+ const citations = composerSnapshot.citations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
13345
+ const requestHolder = {
13346
+ snapshot: aiRequestManager.begin({
13347
+ workId: state.work.id,
13348
+ conversationId: state.aiConversationId
13349
+ })
13350
+ };
13351
+ syncAiRequestControls();
11549
13352
  try {
11550
- await persistAiConversationTaskType($("#ai-task").value);
11551
- await persistAiConversationContextScope(requestScope.conversationScope);
11552
- } catch (error) {
11553
- return toast(`对话配置锁定失败:${error.message}`, "error");
11554
- }
11555
- setAiAssistantStatus("ready");
11556
- const citations = state.aiCitations.map(({ chapterId, chapterTitle, startLine, endLine, text }) => ({ chapterId, chapterTitle, startLine, endLine, text }));
11557
- let persistedUserMessage = null;
11558
- if (taskType !== "chat") {
11559
13353
  try {
11560
- persistedUserMessage = await persistAiConversationMessage("user", instruction, citations);
13354
+ await ensureAiModelsLoaded();
11561
13355
  } catch (error) {
13356
+ if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
11562
13357
  setAiAssistantStatus("error");
11563
- return toast(`对话记录创建失败:${error.message}`, "error");
13358
+ return toast(`创作助手加载失败:${error.message}`, "error");
13359
+ }
13360
+ assertAiRequestCurrent(requestHolder.snapshot);
13361
+ const modelId = $("#ai-model").value;
13362
+ if (!modelId) return toast("请先在 AI 管理中配置并选择模型", "error");
13363
+ try {
13364
+ await prepareAiRequestConversation(requestHolder, $("#ai-task").value, requestScope.conversationScope);
13365
+ } catch (error) {
13366
+ if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
13367
+ return toast(`对话配置锁定失败:${error.message}`, "error");
13368
+ }
13369
+ setAiAssistantStatus("ready");
13370
+ if (taskType !== "chat") {
13371
+ try {
13372
+ const request = assertAiRequestCurrent(requestHolder.snapshot);
13373
+ const persistedUserMessage = await persistAiConversationMessage(
13374
+ request.conversationId,
13375
+ "user",
13376
+ instruction,
13377
+ citations,
13378
+ {},
13379
+ { signal: request.signal }
13380
+ );
13381
+ assertAiRequestCurrent(request);
13382
+ updateAiConversationSummaryFromMessage(persistedUserMessage);
13383
+ requestHolder.snapshot = aiRequestManager.bind(request, { userMessageId: persistedUserMessage.id });
13384
+ state.aiPromptSent = true;
13385
+ syncAiTaskOptions();
13386
+ renderAiRoleplayCharacterSelect();
13387
+ renderAiQuickActions();
13388
+ appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
13389
+ clearAiPromptComposer();
13390
+ } catch (error) {
13391
+ if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
13392
+ setAiAssistantStatus("error");
13393
+ return toast(`对话记录创建失败:${error.message}`, "error");
13394
+ }
11564
13395
  }
11565
- state.aiPromptSent = true;
11566
- syncAiTaskOptions();
11567
- renderAiRoleplayCharacterSelect();
11568
- renderAiQuickActions();
11569
- appendMessage("user", instruction, citations, persistedUserMessage.createdAt, {}, persistedUserMessage.id);
11570
- clearAiPromptComposer();
11571
- }
11572
- $("#ai-send").disabled = true;
11573
- $("#ai-send").textContent = "发送中";
11574
- $("#ai-roleplay-character").disabled = true;
11575
- try {
11576
13396
  let assistantContent = "";
11577
13397
  let assistantMessage;
11578
13398
  let assistantMetadata = {};
11579
13399
  let persistedStreamMessage = null;
11580
13400
  let suggestion = null;
11581
13401
  if (taskType === "chat") {
11582
- const streamed = await streamChat({ instruction, scope, modelId, citations, conversationId: state.aiConversationId || undefined });
13402
+ const streamed = await streamChat(requestHolder, {
13403
+ instruction,
13404
+ scope,
13405
+ modelId,
13406
+ citations,
13407
+ conversationId: requestHolder.snapshot.conversationId
13408
+ }, createAiIdempotencyKey());
13409
+ const request = assertAiRequestCurrent(requestHolder.snapshot);
11583
13410
  if (streamed.action === "warn") return;
11584
13411
  assistantContent = streamed.content;
11585
13412
  assistantMessage = streamed.message;
11586
13413
  assistantMetadata = streamed.metadata;
11587
13414
  persistedStreamMessage = streamed.messageId ? { id: streamed.messageId, createdAt: streamed.createdAt } : null;
11588
- applyAiConversationTitle(streamed.conversationTitle);
13415
+ applyAiConversationTitle(streamed.conversationTitle, request.conversationId);
11589
13416
  } else {
11590
- suggestion = await api(`/api/works/${state.work.id}/suggestions`, { method: "POST", body: { taskType, instruction, scope, modelId, citations } });
13417
+ const request = assertAiRequestCurrent(requestHolder.snapshot);
13418
+ suggestion = await api(`/api/works/${encodeURIComponent(request.workId)}/suggestions`, {
13419
+ method: "POST",
13420
+ body: { taskType, instruction, scope, modelId, citations },
13421
+ signal: request.signal
13422
+ });
13423
+ assertAiRequestCurrent(request);
11591
13424
  const suggestionFailed = suggestion.guard?.status === "failed"
11592
13425
  || suggestion.toolCalls?.some((toolCall) => toolCall.status === "failed")
11593
13426
  || suggestion.processSteps?.some((step) => step?.toolCall?.status === "failed");
@@ -11597,9 +13430,10 @@ async function sendAi() {
11597
13430
  assistantMetadata = { modelDisplayName: suggestion.model?.displayName, outputTokens: suggestion.outputTokens, cacheHitPercent: suggestion.cacheHitPercent };
11598
13431
  }
11599
13432
  try {
13433
+ const request = assertAiRequestCurrent(requestHolder.snapshot);
11600
13434
  if (persistedStreamMessage) {
11601
13435
  updateAiConversationSummaryFromMessage({
11602
- conversationId: state.aiConversationId,
13436
+ conversationId: request.conversationId,
11603
13437
  role: "assistant",
11604
13438
  content: assistantContent,
11605
13439
  createdAt: persistedStreamMessage.createdAt
@@ -11607,31 +13441,104 @@ async function sendAi() {
11607
13441
  updateMessageCreatedAt(assistantMessage, persistedStreamMessage.createdAt);
11608
13442
  attachMessageIdentity(assistantMessage, persistedStreamMessage.id);
11609
13443
  } else {
11610
- const persistedAssistantMessage = await persistAiConversationMessage("assistant", assistantContent, [], assistantMetadata);
13444
+ const persistedAssistantMessage = await persistAiConversationMessage(
13445
+ request.conversationId,
13446
+ "assistant",
13447
+ assistantContent,
13448
+ [],
13449
+ assistantMetadata,
13450
+ { signal: request.signal, requestId: aiAssistantRequestId(request) }
13451
+ );
13452
+ assertAiRequestCurrent(request);
13453
+ updateAiConversationSummaryFromMessage(persistedAssistantMessage);
11611
13454
  if (assistantMessage) {
11612
13455
  updateMessageCreatedAt(assistantMessage, persistedAssistantMessage.createdAt);
11613
13456
  attachMessageIdentity(assistantMessage, persistedAssistantMessage.id);
11614
13457
  } else if (suggestion) appendSuggestion(suggestion, persistedAssistantMessage.createdAt, persistedAssistantMessage.id);
11615
13458
  }
11616
13459
  } catch (error) {
13460
+ if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) throw error;
11617
13461
  setAiAssistantStatus("error");
11618
13462
  if (suggestion) appendSuggestion(suggestion);
11619
13463
  toast(`AI 回复已生成,但历史记录保存失败:${error.message}`, "error");
11620
13464
  }
11621
13465
  } catch (error) {
13466
+ const request = requestHolder.snapshot;
13467
+ if (isAiRequestCancellation(error, request) || !aiRequestTargetsCurrentState(request)) {
13468
+ await persistAiRequestInterruption(request, error?.streamInterruption);
13469
+ return;
13470
+ }
13471
+ if (error?.code === "AI_CONVERSATION_RESPONSE_IN_PROGRESS") {
13472
+ restoreAiPromptComposer(composerSnapshot);
13473
+ toast("当前对话仍在生成回复,请等待完成或取消后再发送", "error");
13474
+ $("#ai-prompt").focus();
13475
+ return;
13476
+ }
13477
+ if (error?.code === "AI_IDEMPOTENT_REQUEST_IN_PROGRESS") {
13478
+ toast("当前对话仍在生成回复,请等待完成或取消后再发送", "error");
13479
+ $("#ai-prompt").focus();
13480
+ return;
13481
+ }
13482
+ if (["IDEMPOTENCY_KEY_REUSED", "AI_IDEMPOTENT_REQUEST_TERMINAL"].includes(error?.code)) {
13483
+ toast(error.message, "error");
13484
+ return;
13485
+ }
11622
13486
  setAiAssistantStatus("error");
13487
+ const interruption = error?.streamInterruption;
13488
+ if (interruption?.content) {
13489
+ try {
13490
+ const persistedAssistantMessage = await persistAiConversationMessage(
13491
+ request.conversationId,
13492
+ "assistant",
13493
+ interruption.content,
13494
+ [],
13495
+ interruption.metadata,
13496
+ { requestId: aiAssistantRequestId(request) }
13497
+ );
13498
+ updateAiConversationSummaryFromMessage(persistedAssistantMessage);
13499
+ const persistedContent = typeof persistedAssistantMessage.content === "string"
13500
+ ? persistedAssistantMessage.content
13501
+ : interruption.content;
13502
+ if (aiRequestTargetsCurrentState(request)) {
13503
+ const messageBody = interruption.message?.querySelector(".message-body");
13504
+ if (messageBody && persistedContent !== interruption.content) messageBody.innerHTML = renderMarkdown(persistedContent);
13505
+ const messageMeta = interruption.message?.querySelector(".message-meta");
13506
+ if (messageMeta) messageMeta.textContent = formatAiStreamInterruptionMeta(error.code, persistedContent.length);
13507
+ attachAssistantCopyAction(interruption.message, persistedContent);
13508
+ updateMessageCreatedAt(interruption.message, persistedAssistantMessage.createdAt);
13509
+ attachMessageIdentity(interruption.message, persistedAssistantMessage.id);
13510
+ }
13511
+ } catch {
13512
+ /* 主请求错误已显示,已收到内容仍保留在当前页面。 */
13513
+ }
13514
+ if (aiRequestTargetsCurrentState(request)) {
13515
+ toast(`${aiStreamInterruptionLabel(error.code)}:${error.message}`, "error");
13516
+ }
13517
+ return;
13518
+ }
11623
13519
  const failureMessage = formatAiFailureMessage(error);
11624
13520
  let persistedFailureMessage = null;
11625
- try { persistedFailureMessage = await persistAiConversationMessage("assistant", failureMessage); } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
11626
- appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, {}, persistedFailureMessage?.id);
13521
+ try {
13522
+ persistedFailureMessage = await persistAiConversationMessage(
13523
+ request.conversationId,
13524
+ "assistant",
13525
+ failureMessage,
13526
+ [],
13527
+ {},
13528
+ { requestId: aiAssistantRequestId(request) }
13529
+ );
13530
+ updateAiConversationSummaryFromMessage(persistedFailureMessage);
13531
+ } catch { /* 主请求错误已显示,历史记录保存失败不覆盖原始错误 */ }
13532
+ if (aiRequestTargetsCurrentState(request)) {
13533
+ appendMessage("assistant", failureMessage, [], persistedFailureMessage?.createdAt, {}, persistedFailureMessage?.id);
13534
+ }
11627
13535
  } finally {
11628
- $("#ai-send").disabled = false;
11629
- $("#ai-send").textContent = "发送";
11630
- renderAiRoleplayCharacterSelect();
13536
+ aiRequestManager.finish(requestHolder.snapshot);
13537
+ syncAiRequestControls();
11631
13538
  }
11632
13539
  }
11633
13540
 
11634
- async function streamChat(body) {
13541
+ async function streamChat(requestHolder, body, idempotencyKey) {
11635
13542
  const message = document.createElement("div");
11636
13543
  message.className = "assistant-message is-streaming";
11637
13544
  message.dataset.testid = "ai-stream-message";
@@ -11640,15 +13547,17 @@ async function streamChat(body) {
11640
13547
  const meta = message.querySelector(".message-meta");
11641
13548
  let messageMounted = false;
11642
13549
  const mountAssistantMessage = () => {
11643
- if (messageMounted) return;
13550
+ if (messageMounted) return true;
13551
+ if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return false;
11644
13552
  attachMessageHeading(message, aiAssistantLabel("正在生成"));
11645
13553
  $("#ai-feed").append(message);
11646
13554
  messageMounted = true;
11647
13555
  scrollAiFeedToBottom();
13556
+ return true;
11648
13557
  };
11649
13558
  const typewriter = createStreamTypewriter({
11650
13559
  onRender: (text, progress) => {
11651
- mountAssistantMessage();
13560
+ if (!aiRequestTargetsCurrentState(requestHolder.snapshot) || !mountAssistantMessage()) return;
11652
13561
  content.innerHTML = renderMarkdown(text);
11653
13562
  const receivedCharacters = progress.visibleCharacters + progress.pendingCharacters;
11654
13563
  meta.textContent = progress.pendingCharacters > 0
@@ -11673,6 +13582,7 @@ async function streamChat(body) {
11673
13582
  const processStepTypewriters = new Map();
11674
13583
  const processStepVisibleContents = new Map();
11675
13584
  const renderStreamingProcessSteps = (completed, durationMs = elapsedProcessTime()) => {
13585
+ if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return;
11676
13586
  renderAiProcessSteps(message, processSteps, completed, durationMs, processStepVisibleContents);
11677
13587
  };
11678
13588
  const processStepTypewriter = (step) => {
@@ -11681,6 +13591,7 @@ async function streamChat(body) {
11681
13591
  processStepVisibleContents.set(step, "");
11682
13592
  const typewriter = createStreamTypewriter({
11683
13593
  onRender: (text) => {
13594
+ if (!aiRequestTargetsCurrentState(requestHolder.snapshot)) return;
11684
13595
  processStepVisibleContents.set(step, text);
11685
13596
  renderStreamingProcessSteps(finalAnswerStarted);
11686
13597
  scrollAiFeedToBottom();
@@ -11694,35 +13605,30 @@ async function streamChat(body) {
11694
13605
  for (const typewriter of processStepTypewriters.values()) typewriter.reveal();
11695
13606
  };
11696
13607
  try {
11697
- const response = await fetch(`/api/works/${state.work.id}/chat/stream`, {
13608
+ const request = assertAiRequestCurrent(requestHolder.snapshot);
13609
+ const response = await fetch(`/api/works/${encodeURIComponent(request.workId)}/chat/stream`, {
11698
13610
  method: "POST",
11699
- headers: { "Content-Type": "application/json", Accept: "text/event-stream", "X-CSRF-Token": state.csrfToken },
11700
- body: JSON.stringify(body)
13611
+ headers: { "Content-Type": "application/json", Accept: "text/event-stream", "X-CSRF-Token": state.csrfToken, "Idempotency-Key": idempotencyKey },
13612
+ body: JSON.stringify(body),
13613
+ signal: request.signal
11701
13614
  });
13615
+ assertAiRequestCurrent(requestHolder.snapshot);
11702
13616
  if (!response.ok || !response.body) {
11703
13617
  const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
11704
13618
  throw createClientError(payload.error, `请求失败:${response.status}`, response.status);
11705
13619
  }
11706
- const reader = response.body.getReader();
11707
- const decoder = new TextDecoder();
11708
- let buffer = "";
11709
13620
  let streamError = null;
11710
- const consume = async (eventText) => {
11711
- let eventName = "message";
11712
- const dataLines = [];
11713
- for (const line of eventText.split(/\r?\n/)) {
11714
- if (line.startsWith("event:")) eventName = line.slice(6).trim();
11715
- if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
11716
- }
11717
- if (!dataLines.length) return;
11718
- const payload = JSON.parse(dataLines.join("\n"));
13621
+ const consume = async (eventName, payload) => {
13622
+ assertAiRequestCurrent(requestHolder.snapshot);
11719
13623
  if (eventName === "context") {
11720
13624
  contextAction = typeof payload.action === "string" ? payload.action : "ready";
11721
13625
  if (!state.aiPromptSent) setAiContextMeter(payload.usage);
11722
13626
  if (payload.conversation?.id) {
11723
- state.aiConversationId = payload.conversation.id;
13627
+ if (String(payload.conversation.id) !== requestHolder.snapshot.conversationId) {
13628
+ throw new Error("流式上下文返回了其他对话");
13629
+ }
11724
13630
  upsertAiConversationSummary(payload.conversation);
11725
- $("#ai-conversation-title").textContent = payload.conversation.title;
13631
+ applyAiConversationTitle(payload.conversation.title, requestHolder.snapshot.conversationId);
11726
13632
  }
11727
13633
  if (contextAction === "warn") showAiContextWarning(payload.usage);
11728
13634
  else {
@@ -11735,7 +13641,10 @@ async function streamChat(body) {
11735
13641
  } else if (eventName === "user_message") {
11736
13642
  persistedUserMessage = payload.message;
11737
13643
  if (persistedUserMessage?.id) {
11738
- state.aiConversationId = persistedUserMessage.conversationId;
13644
+ if (String(persistedUserMessage.conversationId ?? "") !== requestHolder.snapshot.conversationId) {
13645
+ throw new Error("流式用户消息返回了其他对话");
13646
+ }
13647
+ requestHolder.snapshot = aiRequestManager.bind(requestHolder.snapshot, { userMessageId: persistedUserMessage.id });
11739
13648
  updateAiConversationSummaryFromMessage(persistedUserMessage);
11740
13649
  state.aiPromptSent = true;
11741
13650
  syncAiTaskOptions();
@@ -11796,6 +13705,7 @@ async function streamChat(body) {
11796
13705
  const announcedCompaction = contextAction === "compacted" || streamContextCompacted;
11797
13706
  setAiContextMeter(payload.contextUsage, announcedCompaction);
11798
13707
  await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
13708
+ assertAiRequestCurrent(requestHolder.snapshot);
11799
13709
  message.classList.remove("is-streaming");
11800
13710
  content.setAttribute("aria-busy", "false");
11801
13711
  message.querySelector(".message-heading > span").textContent = "助手";
@@ -11807,41 +13717,72 @@ async function streamChat(body) {
11807
13717
  meta.textContent = formatAiMessageMeta(payload.model?.displayName, payload.outputTokens, payload.cacheHitPercent);
11808
13718
  attachAssistantCopyAction(message, streamedText);
11809
13719
  scrollAiFeedToBottom();
13720
+ } else if (eventName === "request_status") {
13721
+ streamError = createClientError(payload, "AI 请求状态不可重放", response.status);
11810
13722
  } else if (eventName === "error") {
11811
13723
  setAiAssistantStatus("error");
11812
13724
  streamError = createClientError(payload, "AI 流式调用失败", response.status);
11813
13725
  }
11814
13726
  };
11815
- while (true) {
11816
- const chunk = await reader.read();
11817
- buffer += decoder.decode(chunk.value, { stream: !chunk.done });
11818
- const events = buffer.split(/\r?\n\r?\n/);
11819
- buffer = events.pop() ?? "";
11820
- for (const eventText of events) await consume(eventText);
11821
- if (chunk.done) break;
11822
- }
11823
- if (buffer.trim()) await consume(buffer);
13727
+ const { completed: streamCompleted } = await readAiEventStream(response.body, consume);
11824
13728
  await Promise.all([typewriter.finish(), finishProcessStepTypewriters()]);
13729
+ assertAiRequestCurrent(requestHolder.snapshot);
11825
13730
  if (streamError) throw streamError;
13731
+ assertAiStreamCompleted(streamCompleted);
11826
13732
  return { action: contextAction, content: streamedText, message, metadata: generatedMetadata, messageId: persistedMessageId, createdAt: persistedMessageCreatedAt, conversationTitle, userMessage: persistedUserMessage };
11827
13733
  } catch (error) {
11828
- mountAssistantMessage();
13734
+ const streamFailure = error instanceof Error ? error : new Error(String(error ?? "AI 流式调用失败"));
13735
+ const interruptionCode = typeof streamFailure.code === "string" ? streamFailure.code.slice(0, 100) : "AI_STREAM_FAILED";
13736
+ const interruption = streamedText ? {
13737
+ content: streamedText,
13738
+ message,
13739
+ metadata: {
13740
+ interrupted: true,
13741
+ interruptionCode,
13742
+ interruptionMessage: streamFailure.message.slice(0, 500),
13743
+ processDurationMs: Math.min(86_400_000, elapsedProcessTime())
13744
+ }
13745
+ } : null;
13746
+ if (interruption) streamFailure.streamInterruption = interruption;
13747
+ if (isAiRequestCancellation(error, requestHolder.snapshot) || !aiRequestTargetsCurrentState(requestHolder.snapshot)) {
13748
+ typewriter.reveal();
13749
+ revealProcessStepTypewriters();
13750
+ if (messageMounted) message.remove();
13751
+ throw streamFailure;
13752
+ }
13753
+ assertAiRequestCurrent(requestHolder.snapshot);
11829
13754
  typewriter.reveal();
11830
13755
  revealProcessStepTypewriters();
13756
+ if (!interruption) {
13757
+ if (messageMounted) message.remove();
13758
+ throw streamFailure;
13759
+ }
13760
+ const interruptionLabel = aiStreamInterruptionLabel(interruptionCode);
11831
13761
  message.classList.remove("is-streaming");
13762
+ message.classList.add("is-error");
13763
+ message.dataset.status = "interrupted";
11832
13764
  content.setAttribute("aria-busy", "false");
11833
- message.querySelector(".message-heading > span").textContent = aiAssistantLabel("生成中断");
13765
+ const headingRole = message.querySelector(".message-heading > span");
13766
+ headingRole.textContent = aiAssistantLabel(interruptionLabel);
13767
+ const interruptionBadge = document.createElement("strong");
13768
+ interruptionBadge.className = "ai-message-status is-error";
13769
+ interruptionBadge.textContent = "中断";
13770
+ interruptionBadge.setAttribute("aria-label", `消息状态:${interruptionLabel}`);
13771
+ headingRole.append(interruptionBadge);
11834
13772
  renderStreamingProcessSteps(true, elapsedProcessTime());
11835
- meta.textContent = "生成中断";
13773
+ meta.textContent = formatAiStreamInterruptionMeta(interruptionCode, streamedText.length);
13774
+ if (streamedText) attachAssistantCopyAction(message, streamedText);
11836
13775
  scrollAiFeedToBottom();
11837
- throw error;
13776
+ throw streamFailure;
11838
13777
  }
11839
13778
  }
11840
13779
 
11841
13780
  function appendMessage(role, text, citations = [], createdAt = null, metadata = {}, messageId = null) {
11842
13781
  const message = document.createElement("div");
11843
13782
  const isFailure = role === "assistant" && text.startsWith("调用失败:");
11844
- message.className = `${role === "user" ? "user-message" : "assistant-message"}${isFailure ? " is-error" : ""}`;
13783
+ const isInterrupted = role === "assistant" && metadata?.interrupted === true;
13784
+ const interruptionCode = typeof metadata?.interruptionCode === "string" ? metadata.interruptionCode : "AI_STREAM_FAILED";
13785
+ message.className = `${role === "user" ? "user-message" : "assistant-message"}${isFailure || isInterrupted ? " is-error" : ""}`;
11845
13786
  const messageBody = isFailure
11846
13787
  ? `<p class="ai-error-text">${esc(text)}</p>${aiToolCallSettingsLinkMarkup(text)}`
11847
13788
  : renderMarkdown(text);
@@ -11850,13 +13791,17 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
11850
13791
  event.preventDefault();
11851
13792
  openAiToolCallSettings().catch((error) => toast(`打开 AI 设置失败:${error.message}`, "error"));
11852
13793
  });
11853
- const heading = attachMessageHeading(message, role === "user" ? "作者" : aiAssistantLabel(), createdAt ?? undefined);
11854
- if (isFailure) {
11855
- message.dataset.status = "failed";
13794
+ const heading = attachMessageHeading(
13795
+ message,
13796
+ role === "user" ? "作者" : aiAssistantLabel(isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : ""),
13797
+ createdAt ?? undefined
13798
+ );
13799
+ if (isFailure || isInterrupted) {
13800
+ message.dataset.status = isInterrupted ? "interrupted" : "failed";
11856
13801
  const failureBadge = document.createElement("strong");
11857
13802
  failureBadge.className = "ai-message-status is-error";
11858
- failureBadge.textContent = "失败";
11859
- failureBadge.setAttribute("aria-label", "消息状态:失败");
13803
+ failureBadge.textContent = isInterrupted ? "中断" : "失败";
13804
+ failureBadge.setAttribute("aria-label", `消息状态:${isInterrupted ? aiStreamInterruptionLabel(interruptionCode) : "失败"}`);
11860
13805
  heading.firstElementChild?.append(failureBadge);
11861
13806
  }
11862
13807
  const mentionNames = role === "user"
@@ -11899,7 +13844,9 @@ function appendMessage(role, text, citations = [], createdAt = null, metadata =
11899
13844
  const outputTokens = Number.isFinite(metadata?.outputTokens) ? metadata.outputTokens : estimateAiMessageTokens(text);
11900
13845
  const meta = document.createElement("div");
11901
13846
  meta.className = "message-meta";
11902
- meta.textContent = formatAiMessageMeta(modelDisplayName, outputTokens, metadata?.cacheHitPercent);
13847
+ meta.textContent = isInterrupted
13848
+ ? formatAiStreamInterruptionMeta(interruptionCode, text.length)
13849
+ : formatAiMessageMeta(modelDisplayName, outputTokens, metadata?.cacheHitPercent);
11903
13850
  message.append(meta);
11904
13851
  attachAssistantCopyAction(message, text);
11905
13852
  }
@@ -11943,26 +13890,95 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
11943
13890
  return message;
11944
13891
  }
11945
13892
 
13893
+ function chapterVersionCompareOption(version) {
13894
+ return `<option value="version:${Number(version.versionNo)}">v${Number(version.versionNo)} · ${esc(chapterVersionSourceLabel(version.source))}</option>`;
13895
+ }
13896
+
13897
+ function chapterVersionCompareTarget(value, versions) {
13898
+ if (value === "current") {
13899
+ return {
13900
+ label: `当前正文 · v${Number(state.chapter?.versionNo ?? 0)}`,
13901
+ title: state.chapter?.title ?? "",
13902
+ content: state.chapter?.content ?? ""
13903
+ };
13904
+ }
13905
+ const versionNo = Number(String(value).replace("version:", ""));
13906
+ const version = versions.find((item) => Number(item.versionNo) === versionNo);
13907
+ return version ? { label: `历史版本 v${versionNo}`, title: version.title, content: version.content } : null;
13908
+ }
13909
+
13910
+ function chapterDiffLineContent(content) {
13911
+ return content === "" ? '<span class="chapter-diff-empty">空行</span>' : esc(content);
13912
+ }
13913
+
13914
+ function renderChapterVersionDiff(versions) {
13915
+ const before = chapterVersionCompareTarget($("#chapter-version-before").value, versions);
13916
+ const after = chapterVersionCompareTarget($("#chapter-version-after").value, versions);
13917
+ if (!before || !after) return;
13918
+ const rows = diffChapterLines(before.content, after.content);
13919
+ const summary = chapterDiffSummary(rows);
13920
+ const titleChanged = before.title !== after.title;
13921
+ $("#chapter-version-diff-summary").textContent = `${before.label} → ${after.label} · ${summary.added} 行新增 · ${summary.deleted} 行删除 · ${summary.modified} 行修改${titleChanged ? ` · 标题由“${before.title}”改为“${after.title}”` : ""}`;
13922
+ $("#chapter-version-diff").innerHTML = rows.map((row) => {
13923
+ if (row.type === "modified") {
13924
+ return `<div class="chapter-diff-row is-modified">
13925
+ <span class="chapter-diff-line-number">${row.beforeLine ?? ""}</span><span class="chapter-diff-marker">−</span><code>${chapterDiffLineContent(row.before)}</code>
13926
+ <span class="chapter-diff-line-number">${row.afterLine ?? ""}</span><span class="chapter-diff-marker">+</span><code>${chapterDiffLineContent(row.after)}</code>
13927
+ </div>`;
13928
+ }
13929
+ const lineNumber = row.type === "added" ? row.afterLine : row.beforeLine;
13930
+ const marker = row.type === "added" ? "+" : row.type === "deleted" ? "−" : "";
13931
+ const content = row.type === "added" ? row.after : row.before;
13932
+ return `<div class="chapter-diff-row is-${row.type}"><span class="chapter-diff-line-number">${lineNumber ?? ""}</span><span class="chapter-diff-marker">${marker}</span><code>${chapterDiffLineContent(content)}</code></div>`;
13933
+ }).join("");
13934
+ }
13935
+
13936
+ function prepareChapterVersionCompare(versions) {
13937
+ const options = versions.map(chapterVersionCompareOption).join("");
13938
+ const previous = versions.find((version) => Number(version.versionNo) !== Number(state.chapter?.versionNo)) ?? versions[0];
13939
+ $("#chapter-version-before").innerHTML = `${options}<option value="current">当前正文</option>`;
13940
+ $("#chapter-version-after").innerHTML = `${options}<option value="current">当前正文</option>`;
13941
+ $("#chapter-version-before").value = previous ? `version:${Number(previous.versionNo)}` : "current";
13942
+ $("#chapter-version-after").value = "current";
13943
+ renderChapterVersionDiff(versions);
13944
+ $("#chapter-version-compare").onclick = () => renderChapterVersionDiff(versions);
13945
+ $("#chapter-version-before").onchange = () => renderChapterVersionDiff(versions);
13946
+ $("#chapter-version-after").onchange = () => renderChapterVersionDiff(versions);
13947
+ }
13948
+
11946
13949
  async function showVersions() {
11947
13950
  if (!state.chapter) return;
11948
13951
  const versions = await api(`/api/chapters/${state.chapter.id}/versions`);
11949
13952
  $("#versions-list").innerHTML = versions.map((version) => `<div class="version-row"><div><b>v${version.versionNo}</b><small>${esc(chapterVersionSourceLabel(version.source))} · ${esc(version.actor || "历史数据")}</small></div><p>${esc(version.content.slice(0, 300) || "空白章节")}</p>${canEditProse() ? `<button class="ghost-button" data-restore-version="${version.versionNo}">恢复</button>` : ""}</div>`).join("");
11950
13953
  $("#versions-list").querySelectorAll("[data-restore-version]").forEach((button) => button.addEventListener("click", async () => {
13954
+ const dialog = $("#versions-dialog");
13955
+ dialog.close();
11951
13956
  if (!(await confirmToast(
11952
13957
  `将版本 v${button.dataset.restoreVersion} 恢复为一个新的保存版本?`,
11953
13958
  { title: "恢复历史版本", confirmLabel: "确认恢复" }
11954
- ))) return;
11955
- state.chapter = await api(`/api/chapters/${state.chapter.id}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.restoreVersion) } });
11956
- lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
11957
- $("#chapter-title").value = state.chapter.title;
11958
- $("#chapter-content").value = state.chapter.content;
11959
- scheduleChapterLineNumbers();
11960
- updateChapterStats();
11961
- setSaveState("已保存");
11962
- $("#versions-dialog").close();
11963
- toast("历史内容已恢复为新版本");
13959
+ ))) {
13960
+ dialog.showModal();
13961
+ button.focus();
13962
+ return;
13963
+ }
13964
+ try {
13965
+ state.chapter = await api(`/api/chapters/${state.chapter.id}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.restoreVersion) } });
13966
+ lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
13967
+ $("#chapter-title").value = state.chapter.title;
13968
+ $("#chapter-content").value = state.chapter.content;
13969
+ scheduleChapterLineNumbers();
13970
+ updateChapterStats();
13971
+ setSaveState("已保存");
13972
+ toast("历史内容已恢复为新版本");
13973
+ } catch (error) {
13974
+ dialog.showModal();
13975
+ button.focus();
13976
+ toast(error.message, "error");
13977
+ }
11964
13978
  }));
11965
13979
  $("#versions-dialog").showModal();
13980
+ prepareChapterVersionCompare(versions);
13981
+ $("#chapter-version-before").focus();
11966
13982
  }
11967
13983
 
11968
13984
  function importHistoryRecordLabel(version) {
@@ -12079,22 +14095,196 @@ async function openImportHistory() {
12079
14095
  }
12080
14096
  }
12081
14097
 
12082
- function renderChapterRecycleBin(chapters) {
14098
+ function recycleBinExpiryLabel(expiresAt) {
14099
+ return expiresAt ? `自动清理时间:${formatDateTime(expiresAt)}` : "按回收站保留期自动清理";
14100
+ }
14101
+
14102
+ function recycleBinSection(title, count, body) {
14103
+ return `<section class="recycle-bin-section" aria-label="${esc(title)}">
14104
+ <header class="recycle-bin-section-heading"><h3>${esc(title)}</h3><span>${Number(count).toLocaleString("zh-CN")} 项</span></header>
14105
+ <div class="recycle-bin-section-list">${body}</div>
14106
+ </section>`;
14107
+ }
14108
+
14109
+ function renderWorkRecycleBin(result) {
14110
+ const host = $("#work-recycle-bin-list");
14111
+ const works = Array.isArray(result?.works) ? result.works : [];
14112
+ const retentionDays = Number(result?.retentionDays) || 30;
14113
+ $("#work-recycle-bin-description").textContent = `删除的作品默认保留 ${retentionDays} 天,期间会完整保留正文、层级、设定、关联资料与版本历史。恢复后可继续编辑;彻底删除后无法恢复。`;
14114
+ if (!works.length) {
14115
+ host.innerHTML = '<p class="entity-history-empty">作品回收站为空。</p>';
14116
+ return;
14117
+ }
14118
+ host.innerHTML = works.map((work) => `<article class="entity-version-card recycle-bin-card" data-deleted-work="${esc(work.id)}">
14119
+ <header><strong>${esc(work.title)}</strong><span class="import-history-kind">作品</span></header>
14120
+ <time>${esc(formatDateTime(work.deletedAt))} · ${esc(work.actor)}</time>
14121
+ <p>${esc(work.description || "尚未填写作品简介")}</p>
14122
+ <small>${Number(work.volumeCount ?? 0).toLocaleString("zh-CN")} 卷 · ${Number(work.chapterCount ?? 0).toLocaleString("zh-CN")} 章 · v${Number(work.versionNo)}<br>${esc(recycleBinExpiryLabel(work.expiresAt))}</small>
14123
+ <footer class="entity-version-actions">
14124
+ <button type="button" data-restore-deleted-work="${esc(work.id)}">恢复作品</button>
14125
+ <button class="danger-button" type="button" data-purge-deleted-work="${esc(work.id)}">彻底删除</button>
14126
+ </footer>
14127
+ </article>`).join("");
14128
+ host.querySelectorAll("[data-restore-deleted-work]").forEach((button) => button.addEventListener("click", async () => {
14129
+ const work = works.find((item) => item.id === button.dataset.restoreDeletedWork);
14130
+ if (!work) return;
14131
+ const dialog = $("#work-recycle-bin-dialog");
14132
+ dialog.close();
14133
+ const confirmed = await confirmToast(`恢复作品“${work.title}”吗?正文、层级、设定、关联资料与版本历史都会回到删除前状态。`, {
14134
+ title: "恢复作品",
14135
+ confirmLabel: "确认恢复"
14136
+ });
14137
+ if (!confirmed) {
14138
+ dialog.showModal();
14139
+ return;
14140
+ }
14141
+ try {
14142
+ await api(`/api/recycle-bin/works/${encodeURIComponent(work.id)}/restore`, {
14143
+ method: "POST",
14144
+ body: { expectedVersionNo: work.versionNo }
14145
+ });
14146
+ state.works = (await apiPage("/api/works")).items;
14147
+ showShelf();
14148
+ await loadWorkRecycleBin();
14149
+ dialog.showModal();
14150
+ toast(`已恢复作品“${work.title}”`);
14151
+ } catch (error) {
14152
+ dialog.showModal();
14153
+ toast(error.message, "error");
14154
+ }
14155
+ }));
14156
+ host.querySelectorAll("[data-purge-deleted-work]").forEach((button) => button.addEventListener("click", async () => {
14157
+ const work = works.find((item) => item.id === button.dataset.purgeDeletedWork);
14158
+ if (!work) return;
14159
+ const dialog = $("#work-recycle-bin-dialog");
14160
+ dialog.close();
14161
+ const confirmed = await confirmToast(`彻底删除作品“${work.title}”吗?正文、设定、关联资料、版本历史与附件将无法恢复。`, {
14162
+ title: "彻底删除作品",
14163
+ confirmLabel: "确认彻底删除"
14164
+ });
14165
+ if (!confirmed) {
14166
+ dialog.showModal();
14167
+ return;
14168
+ }
14169
+ try {
14170
+ await api(`/api/recycle-bin/works/${encodeURIComponent(work.id)}/permanent`, {
14171
+ method: "DELETE",
14172
+ body: { expectedVersionNo: work.versionNo }
14173
+ });
14174
+ await loadWorkRecycleBin();
14175
+ dialog.showModal();
14176
+ toast(`已彻底删除作品“${work.title}”`);
14177
+ } catch (error) {
14178
+ dialog.showModal();
14179
+ toast(error.message, "error");
14180
+ }
14181
+ }));
14182
+ }
14183
+
14184
+ async function loadWorkRecycleBin() {
14185
+ renderWorkRecycleBin(await api("/api/recycle-bin/works"));
14186
+ }
14187
+
14188
+ async function openWorkRecycleBin() {
14189
+ $("#work-recycle-bin-list").innerHTML = '<p class="entity-history-empty">正在读取已删除作品…</p>';
14190
+ const dialog = $("#work-recycle-bin-dialog");
14191
+ dialog.showModal();
14192
+ $("#work-recycle-bin-close").focus();
14193
+ try {
14194
+ await loadWorkRecycleBin();
14195
+ } catch (error) {
14196
+ dialog.close();
14197
+ toast(error.message, "error");
14198
+ }
14199
+ }
14200
+
14201
+ function renderChapterRecycleBin(result) {
12083
14202
  const host = $("#chapter-recycle-bin-list");
12084
- if (!chapters.length) {
12085
- host.innerHTML = '<p class="entity-history-empty">回收站为空,没有可恢复的章节。</p>';
14203
+ const volumes = Array.isArray(result?.volumes) ? result.volumes : [];
14204
+ const chapters = Array.isArray(result?.chapters) ? result.chapters : [];
14205
+ const retentionDays = Number(result?.retentionDays) || 30;
14206
+ $("#chapter-recycle-bin-description").textContent = `删除的分卷与章节默认保留 ${retentionDays} 天。分卷会连同其中章节一起恢复;独立章节会恢复到原分卷。彻底删除会清理正文、版本和关联资料,且无法恢复。`;
14207
+ if (!volumes.length && !chapters.length) {
14208
+ host.innerHTML = '<p class="entity-history-empty">正文回收站为空。</p>';
12086
14209
  return;
12087
14210
  }
12088
- host.innerHTML = chapters.map((chapter) => `<article class="entity-version-card" data-deleted-chapter="${esc(chapter.id)}">
14211
+ const volumeCards = volumes.map((volume) => `<article class="entity-version-card recycle-bin-card" data-deleted-volume="${esc(volume.id)}">
14212
+ <header><strong>${esc(volume.title)}</strong><span class="import-history-kind">分卷</span></header>
14213
+ <time>${esc(formatDateTime(volume.deletedAt))} · ${esc(volume.actor)}</time>
14214
+ <p>${esc(volume.description || "尚未填写分卷简介")}</p>
14215
+ <small>${Number(volume.chapterCount ?? 0).toLocaleString("zh-CN")} 章 · 删除版本 v${Number(volume.versionNo)}<br>${esc(recycleBinExpiryLabel(volume.expiresAt))}</small>
14216
+ <footer class="entity-version-actions">
14217
+ <button type="button" data-restore-deleted-volume="${esc(volume.id)}">恢复分卷</button>
14218
+ <button class="danger-button" type="button" data-purge-deleted-volume="${esc(volume.id)}">彻底删除</button>
14219
+ </footer>
14220
+ </article>`).join("");
14221
+ const chapterCards = chapters.map((chapter) => `<article class="entity-version-card recycle-bin-card" data-deleted-chapter="${esc(chapter.id)}">
12089
14222
  <header><strong>${esc(chapter.title)}</strong><span class="import-history-kind">${esc(chapter.volumeTitle)}</span></header>
12090
14223
  <time>${esc(formatDateTime(chapter.deletedAt))} · ${esc(chapter.actor)}</time>
12091
14224
  <p>${esc(chapter.contentPreview || "空白章节")}</p>
12092
- <small>${Number(chapter.wordCount).toLocaleString("zh-CN")} 字 · 删除版本 v${Number(chapter.versionNo)}</small>
14225
+ <small>${Number(chapter.wordCount ?? 0).toLocaleString("zh-CN")} 字 · 删除版本 v${Number(chapter.versionNo)}<br>${esc(recycleBinExpiryLabel(chapter.expiresAt))}</small>
12093
14226
  <footer class="entity-version-actions">
12094
14227
  <button type="button" data-restore-deleted-chapter="${esc(chapter.id)}">恢复章节</button>
12095
14228
  <button class="danger-button" type="button" data-purge-deleted-chapter="${esc(chapter.id)}">彻底删除</button>
12096
14229
  </footer>
12097
14230
  </article>`).join("");
14231
+ host.innerHTML = `${volumes.length ? recycleBinSection("已删除分卷", volumes.length, volumeCards) : ""}${chapters.length ? recycleBinSection("已删除章节", chapters.length, chapterCards) : ""}`;
14232
+ host.querySelectorAll("[data-restore-deleted-volume]").forEach((button) => button.addEventListener("click", async () => {
14233
+ const volume = volumes.find((item) => item.id === button.dataset.restoreDeletedVolume);
14234
+ if (!volume || !state.work) return;
14235
+ const dialog = $("#chapter-recycle-bin-dialog");
14236
+ dialog.close();
14237
+ const confirmed = await confirmToast(`恢复分卷“${volume.title}”及其中 ${Number(volume.chapterCount ?? 0).toLocaleString("zh-CN")} 个章节吗?`, {
14238
+ title: "恢复分卷",
14239
+ confirmLabel: "确认恢复"
14240
+ });
14241
+ if (!confirmed) {
14242
+ dialog.showModal();
14243
+ return;
14244
+ }
14245
+ try {
14246
+ await api(`/api/volumes/${encodeURIComponent(volume.id)}/restore`, {
14247
+ method: "POST",
14248
+ body: { expectedVersionNo: volume.versionNo }
14249
+ });
14250
+ state.work = await api(`/api/works/${encodeURIComponent(state.work.id)}`);
14251
+ renderTree();
14252
+ await loadChapterRecycleBin();
14253
+ dialog.showModal();
14254
+ toast(`已恢复分卷“${volume.title}”`);
14255
+ } catch (error) {
14256
+ dialog.showModal();
14257
+ toast(error.message, "error");
14258
+ }
14259
+ }));
14260
+ host.querySelectorAll("[data-purge-deleted-volume]").forEach((button) => button.addEventListener("click", async () => {
14261
+ const volume = volumes.find((item) => item.id === button.dataset.purgeDeletedVolume);
14262
+ if (!volume || !state.work) return;
14263
+ const dialog = $("#chapter-recycle-bin-dialog");
14264
+ dialog.close();
14265
+ const confirmed = await confirmToast(`彻底删除分卷“${volume.title}”吗?其中章节的正文、版本和关联资料将无法恢复。`, {
14266
+ title: "彻底删除分卷",
14267
+ confirmLabel: "确认彻底删除"
14268
+ });
14269
+ if (!confirmed) {
14270
+ dialog.showModal();
14271
+ return;
14272
+ }
14273
+ try {
14274
+ await api(`/api/volumes/${encodeURIComponent(volume.id)}/permanent`, {
14275
+ method: "DELETE",
14276
+ body: { expectedVersionNo: volume.versionNo }
14277
+ });
14278
+ state.work = await api(`/api/works/${encodeURIComponent(state.work.id)}`);
14279
+ renderTree();
14280
+ await loadChapterRecycleBin();
14281
+ dialog.showModal();
14282
+ toast(`已彻底删除分卷“${volume.title}”`);
14283
+ } catch (error) {
14284
+ dialog.showModal();
14285
+ toast(error.message, "error");
14286
+ }
14287
+ }));
12098
14288
  host.querySelectorAll("[data-restore-deleted-chapter]").forEach((button) => button.addEventListener("click", async () => {
12099
14289
  const chapter = chapters.find((item) => item.id === button.dataset.restoreDeletedChapter);
12100
14290
  if (!chapter || !state.work) return;
@@ -12108,7 +14298,6 @@ function renderChapterRecycleBin(chapters) {
12108
14298
  dialog.showModal();
12109
14299
  return;
12110
14300
  }
12111
- button.disabled = true;
12112
14301
  try {
12113
14302
  await api(`/api/chapters/${encodeURIComponent(chapter.id)}/restore`, {
12114
14303
  method: "POST",
@@ -12120,7 +14309,6 @@ function renderChapterRecycleBin(chapters) {
12120
14309
  dialog.showModal();
12121
14310
  toast(`已恢复章节“${chapter.title}”`);
12122
14311
  } catch (error) {
12123
- button.disabled = false;
12124
14312
  dialog.showModal();
12125
14313
  toast(error.message, "error");
12126
14314
  }
@@ -12138,7 +14326,6 @@ function renderChapterRecycleBin(chapters) {
12138
14326
  dialog.showModal();
12139
14327
  return;
12140
14328
  }
12141
- button.disabled = true;
12142
14329
  try {
12143
14330
  await api(`/api/chapters/${encodeURIComponent(chapter.id)}/permanent`, {
12144
14331
  method: "DELETE",
@@ -12150,7 +14337,6 @@ function renderChapterRecycleBin(chapters) {
12150
14337
  dialog.showModal();
12151
14338
  toast(`已彻底删除章节“${chapter.title}”`);
12152
14339
  } catch (error) {
12153
- button.disabled = false;
12154
14340
  dialog.showModal();
12155
14341
  toast(error.message, "error");
12156
14342
  }
@@ -12159,18 +14345,19 @@ function renderChapterRecycleBin(chapters) {
12159
14345
 
12160
14346
  async function loadChapterRecycleBin() {
12161
14347
  if (!state.work) return;
12162
- const chapters = await api(`/api/works/${encodeURIComponent(state.work.id)}/deleted-chapters`);
12163
- renderChapterRecycleBin(chapters);
14348
+ renderChapterRecycleBin(await api(`/api/works/${encodeURIComponent(state.work.id)}/recycle-bin`));
12164
14349
  }
12165
14350
 
12166
14351
  async function openChapterRecycleBin() {
12167
14352
  if (!state.work || !canEditProse()) return toast("当前权限不能恢复正文", "error");
12168
- $("#chapter-recycle-bin-list").innerHTML = '<p class="entity-history-empty">正在读取已删除章节…</p>';
12169
- $("#chapter-recycle-bin-dialog").showModal();
14353
+ $("#chapter-recycle-bin-list").innerHTML = '<p class="entity-history-empty">正在读取已删除正文…</p>';
14354
+ const dialog = $("#chapter-recycle-bin-dialog");
14355
+ dialog.showModal();
14356
+ $("#chapter-recycle-bin-close").focus();
12170
14357
  try {
12171
14358
  await loadChapterRecycleBin();
12172
14359
  } catch (error) {
12173
- $("#chapter-recycle-bin-dialog").close();
14360
+ dialog.close();
12174
14361
  toast(error.message, "error");
12175
14362
  }
12176
14363
  }
@@ -12331,6 +14518,7 @@ const avatarCropSession = {
12331
14518
  crop: { x: 0, y: 0, size: 0 },
12332
14519
  display: { x: 0, y: 0, width: 0, height: 0, scale: 0 },
12333
14520
  drag: null,
14521
+ fileName: "",
12334
14522
  uploading: false
12335
14523
  };
12336
14524
 
@@ -12351,6 +14539,7 @@ function resetAvatarCropDialog() {
12351
14539
  avatarCropSession.imageWidth = 0;
12352
14540
  avatarCropSession.imageHeight = 0;
12353
14541
  avatarCropSession.crop = { x: 0, y: 0, size: 0 };
14542
+ avatarCropSession.fileName = "";
12354
14543
  const image = $("#avatar-crop-image");
12355
14544
  if (image) image.removeAttribute("src");
12356
14545
  $("#avatar-crop-selection")?.setAttribute("hidden", "");
@@ -12513,6 +14702,16 @@ function exportAvatarCropBlob() {
12513
14702
  });
12514
14703
  }
12515
14704
 
14705
+ function renderAvatarUploadProgress(fileName, progress, visible = true) {
14706
+ const panel = $("#avatar-upload-progress");
14707
+ if (!panel) return;
14708
+ panel.classList.toggle("hidden", !visible);
14709
+ if (!visible) return;
14710
+ const name = panel.querySelector("#avatar-upload-progress-name");
14711
+ if (name) name.textContent = fileName;
14712
+ updateUploadProgress(panel.querySelector("[data-upload-progress]"), progress);
14713
+ }
14714
+
12516
14715
  $("#avatar-file").addEventListener("change", async (event) => {
12517
14716
  const file = event.target.files[0];
12518
14717
  if (!file) return;
@@ -12522,6 +14721,7 @@ $("#avatar-file").addEventListener("change", async (event) => {
12522
14721
  return;
12523
14722
  }
12524
14723
  try {
14724
+ avatarCropSession.fileName = file.name;
12525
14725
  await openAvatarCropDialog(file);
12526
14726
  } catch (error) {
12527
14727
  toast(error.message, "error");
@@ -12609,11 +14809,12 @@ $("#avatar-crop-confirm").addEventListener("click", async () => {
12609
14809
  $("#avatar-crop-cancel").disabled = true;
12610
14810
  $("#avatar-upload-button").disabled = true;
12611
14811
  $("#avatar-remove-button").disabled = true;
14812
+ renderAvatarUploadProgress(avatarCropSession.fileName || "avatar.png", 0);
12612
14813
  try {
12613
14814
  const blob = await exportAvatarCropBlob();
12614
14815
  const body = new FormData();
12615
14816
  body.append("file", blob, "avatar.png");
12616
- const updated = await api("/api/auth/avatar", { method: "PUT", body });
14817
+ const updated = await uploadWithProgress("/api/auth/avatar", { method: "PUT", body }, (progress) => renderAvatarUploadProgress(avatarCropSession.fileName || "avatar.png", progress));
12617
14818
  applyAuthenticatedUser({ user: updated, csrfToken: state.csrfToken });
12618
14819
  renderProfileAvatar();
12619
14820
  avatarCropSession.uploading = false;
@@ -12627,6 +14828,7 @@ $("#avatar-crop-confirm").addEventListener("click", async () => {
12627
14828
  $("#avatar-crop-cancel").disabled = false;
12628
14829
  $("#avatar-upload-button").disabled = false;
12629
14830
  $("#avatar-remove-button").disabled = false;
14831
+ renderAvatarUploadProgress("", 0, false);
12630
14832
  }
12631
14833
  });
12632
14834
 
@@ -12970,8 +15172,30 @@ $("#member-permission-form").addEventListener("submit", async (event) => {
12970
15172
  });
12971
15173
  $("#platform-new-provider").addEventListener("click", () => openProviderDialog());
12972
15174
  $("#shelf-new-work").addEventListener("click", openWorkDialog);
15175
+ $("#shelf-recycle-bin").addEventListener("click", () => { void openWorkRecycleBin(); });
12973
15176
  $("#welcome-new-work").addEventListener("click", () => state.work ? openChapterDialog() : openWorkDialog());
12974
15177
  $("#save-button").addEventListener("click", saveChapter);
15178
+ $("#chapter-foreshadow-reminder-previous").addEventListener("click", () => {
15179
+ showChapterForeshadowReminderAt(chapterForeshadowReminderIndex - 1);
15180
+ });
15181
+ $("#chapter-foreshadow-reminder-next").addEventListener("click", () => {
15182
+ showChapterForeshadowReminderAt(chapterForeshadowReminderIndex + 1);
15183
+ });
15184
+ $("#chapter-foreshadow-reminder-details-button").addEventListener("click", () => {
15185
+ chapterForeshadowReminderDetailsExpanded = !chapterForeshadowReminderDetailsExpanded;
15186
+ renderChapterForeshadowReminder();
15187
+ });
15188
+ $("#chapter-foreshadow-reminder-snooze").addEventListener("click", snoozeCurrentChapterForeshadowReminder);
15189
+ $("#chapter-foreshadow-reminder-resolve").addEventListener("click", () => {
15190
+ void resolveCurrentChapterForeshadowReminder();
15191
+ });
15192
+ $("#chapter-foreshadow-reminder").addEventListener("keydown", (event) => {
15193
+ if (event.key !== "Escape" || !chapterForeshadowReminderDetailsExpanded) return;
15194
+ event.preventDefault();
15195
+ chapterForeshadowReminderDetailsExpanded = false;
15196
+ renderChapterForeshadowReminder();
15197
+ $("#chapter-foreshadow-reminder-details-button").focus();
15198
+ });
12975
15199
  $("#chapter-delete-button").addEventListener("click", () => {
12976
15200
  if (state.chapter) void deleteChapter(state.chapter.id);
12977
15201
  });
@@ -12999,6 +15223,7 @@ $("#insight-button").addEventListener("click", () => showChapterInsight().catch(
12999
15223
  $("#versions-button").addEventListener("click", showVersions);
13000
15224
  $("#versions-close").addEventListener("click", () => $("#versions-dialog").close());
13001
15225
  $("#import-history-close").addEventListener("click", () => $("#import-history-dialog").close());
15226
+ $("#work-recycle-bin-close").addEventListener("click", () => $("#work-recycle-bin-dialog").close());
13002
15227
  $("#chapter-recycle-bin-close").addEventListener("click", () => $("#chapter-recycle-bin-dialog").close());
13003
15228
  $("#entity-history-close").addEventListener("click", () => $("#entity-history-dialog").close());
13004
15229
  $("#ai-tool-call-close").addEventListener("click", () => $("#ai-tool-call-dialog").close());
@@ -13118,6 +15343,7 @@ $("#ai-panel-toggle").addEventListener("click", () => {
13118
15343
  setupPanelResize($("#left-panel-resize"), "left");
13119
15344
  setupPanelResize($("#ai-panel-resize"), "ai");
13120
15345
  if (typeof ResizeObserver !== "undefined") new ResizeObserver(scheduleChapterLineNumbers).observe($("#chapter-content"));
15346
+ if (typeof ResizeObserver !== "undefined") new ResizeObserver(syncMobileAiPanelSafeTop).observe($("#chapter-foreshadow-reminder"));
13121
15347
  window.addEventListener("resize", () => {
13122
15348
  if (isMobileViewport() && $("#onboarding-dialog").open) completeOnboarding();
13123
15349
  applyPanelLayout();
@@ -13300,20 +15526,26 @@ $("#cover-file").addEventListener("change", async (event) => {
13300
15526
  }
13301
15527
  const body = new FormData();
13302
15528
  body.append("file", file);
15529
+ const work = state.works.find((item) => item.id === workId) ?? (state.work?.id === workId ? state.work : null);
15530
+ if (!work) {
15531
+ state.pendingCoverWorkId = null;
15532
+ event.target.value = "";
15533
+ return;
15534
+ }
15535
+ state.coverUpload = { workId, fileName: file.name, progress: 0 };
15536
+ refreshWorkCoverField(work);
13303
15537
  try {
13304
- await api(`/api/works/${workId}/cover`, { method: "PUT", body });
15538
+ await uploadWithProgress(`/api/works/${workId}/cover`, { method: "PUT", body }, (progress) => renderWorkCoverUploadProgress(workId, progress));
13305
15539
  state.works = (await apiPage("/api/works")).items;
13306
15540
  const updated = state.works.find((item) => item.id === workId);
13307
- const coverField = $("#dialog-fields")?.querySelector(".work-cover-field");
13308
- if (updated && coverField && $("#form-dialog")?.open) {
13309
- coverField.outerHTML = workCoverFieldHtml(updated);
13310
- bindWorkCoverControls(updated);
13311
- }
15541
+ if (updated) Object.assign(work, updated);
13312
15542
  renderShelf();
13313
15543
  toast("封面已更新");
13314
15544
  } catch (error) {
13315
15545
  toast(error.message, "error");
13316
15546
  } finally {
15547
+ state.coverUpload = null;
15548
+ refreshWorkCoverField(state.works.find((item) => item.id === workId) ?? work);
13317
15549
  state.pendingCoverWorkId = null;
13318
15550
  event.target.value = "";
13319
15551
  }
@@ -13363,8 +15595,9 @@ document.addEventListener("pointerdown", (event) => {
13363
15595
  if (!event.target.closest("#line-citation-menu")) closeLineCitationMenu();
13364
15596
  if (!event.target.closest("#markdown-table-menu")) closeMarkdownTableMenu();
13365
15597
  if (!event.target.closest("#manuscript-export-menu") && !event.target.closest("#export-button") && !event.target.closest("#work-export-button")) {
13366
- closeManuscriptExportMenu();
15598
+ closeManuscriptExportMenu(true);
13367
15599
  }
15600
+ if (!event.target.closest("#ai-history-action-menu") && !event.target.closest(".ai-history-more")) closeAiHistoryActionMenu();
13368
15601
  if (!event.target.closest(".prompt-composer")) hideAiMentionMenu();
13369
15602
  if (!event.target.closest("#ai-context-meter") && !event.target.closest("#ai-context-popover")) setAiContextDistributionVisible(false);
13370
15603
  if (!event.target.closest("#account-button") && !event.target.closest("#account-menu")) {
@@ -13384,10 +15617,12 @@ document.addEventListener("keydown", (event) => {
13384
15617
  return;
13385
15618
  }
13386
15619
  if (event.key === "Escape") {
15620
+ if (!$("#ai-history-action-menu").classList.contains("hidden")) return;
13387
15621
  closeChapterTypeMenu();
13388
15622
  closeLineCitationMenu();
13389
15623
  closeMarkdownTableMenu(true);
13390
15624
  closeManuscriptExportMenu();
15625
+ closeAiHistoryActionMenu(true);
13391
15626
  hideAiMentionMenu();
13392
15627
  setAiContextDistributionVisible(false);
13393
15628
  }
@@ -13490,8 +15725,33 @@ $("#ai-history-next").addEventListener("click", async () => {
13490
15725
  });
13491
15726
  $("#ai-history-close").addEventListener("click", () => setAiHistoryVisible(false));
13492
15727
  $("#ai-history-dialog").addEventListener("close", () => {
15728
+ closeAiHistoryActionMenu();
13493
15729
  $("#ai-history-toggle").setAttribute("aria-expanded", "false");
13494
15730
  });
15731
+ $("#ai-history-dialog").addEventListener("cancel", (event) => {
15732
+ if ($("#ai-history-action-menu").classList.contains("hidden")) return;
15733
+ event.preventDefault();
15734
+ closeAiHistoryActionMenu(true);
15735
+ });
15736
+ $("#ai-history-action-menu").addEventListener("click", async (event) => {
15737
+ const option = event.target.closest("[data-ai-history-action=\"export\"]");
15738
+ const conversation = aiHistoryActionConversation;
15739
+ if (!option || !conversation || option.disabled) return;
15740
+ const label = option.querySelector("span");
15741
+ option.disabled = true;
15742
+ if (label) label.textContent = "下载中";
15743
+ try {
15744
+ await downloadAiConversation(conversation);
15745
+ closeAiHistoryActionMenu(true);
15746
+ toast("对话 Markdown 已开始下载");
15747
+ } catch (error) {
15748
+ toast(`对话导出失败:${error.message}`, "error");
15749
+ option.focus();
15750
+ } finally {
15751
+ option.disabled = false;
15752
+ if (label) label.textContent = "导出 Markdown";
15753
+ }
15754
+ });
13495
15755
  $("#ai-prompt").addEventListener("keydown", (event) => {
13496
15756
  const mentionMenuVisible = !$("#ai-mention-menu").classList.contains("hidden");
13497
15757
  if (mentionMenuVisible) {
@@ -13576,11 +15836,45 @@ $("#export-button").addEventListener("click", (event) => {
13576
15836
  $("#manuscript-export-menu").addEventListener("click", (event) => {
13577
15837
  const option = event.target.closest("[data-export-format]");
13578
15838
  if (!option || !manuscriptExportWork) return;
13579
- const format = option.getAttribute("data-export-format") === "docx" ? "docx" : "markdown";
15839
+ const requestedFormat = option.getAttribute("data-export-format");
15840
+ const format = ["docx", "epub"].includes(requestedFormat) ? requestedFormat : "markdown";
13580
15841
  const work = manuscriptExportWork;
13581
- closeManuscriptExportMenu();
13582
- downloadWorkManuscript(work, format);
15842
+ const trigger = manuscriptExportAnchor;
15843
+ closeManuscriptExportMenu(true);
15844
+ void downloadWorkManuscript(work, format, trigger);
15845
+ });
15846
+ $("#reader-open-button").addEventListener("click", () => {
15847
+ void openReadingPreview({ restorePosition: true });
13583
15848
  });
15849
+ $("#chapter-reader-button").addEventListener("click", () => {
15850
+ void openReadingPreview({ chapterId: state.chapter?.id ?? null, restorePosition: true });
15851
+ });
15852
+ $("#reader-close").addEventListener("click", closeReadingPreview);
15853
+ $("#reader-previous").addEventListener("click", () => void navigateReadingChapter(-1));
15854
+ $("#reader-next").addEventListener("click", () => void navigateReadingChapter(1));
15855
+ $("#reader-continue").addEventListener("click", () => void navigateReadingChapter(1, { continueFromBoundary: true }));
15856
+ $("#reader-page-previous").addEventListener("click", () => void stepReadingPage(-1));
15857
+ $("#reader-page-next").addEventListener("click", () => void stepReadingPage(1));
15858
+ $("#reader-volume").addEventListener("change", (event) => {
15859
+ const first = readingSequence.find((chapter) => chapter.volumeId === event.currentTarget.value);
15860
+ if (first) {
15861
+ persistReadingPosition();
15862
+ void loadReadingChapter(first.id);
15863
+ }
15864
+ });
15865
+ $("#reader-chapter").addEventListener("change", (event) => {
15866
+ persistReadingPosition();
15867
+ void loadReadingChapter(event.currentTarget.value);
15868
+ });
15869
+ $("#reader-mode").addEventListener("change", (event) => updateReadingPreference({ mode: event.currentTarget.value }));
15870
+ $("#reader-font-size").addEventListener("change", (event) => updateReadingPreference({ fontSize: Number(event.currentTarget.value) }));
15871
+ $("#reader-line-height").addEventListener("change", (event) => updateReadingPreference({ lineHeight: Number(event.currentTarget.value) }));
15872
+ $("#reader-theme").addEventListener("change", (event) => updateReadingPreference({ theme: event.currentTarget.value }));
15873
+ $("#reader-view").addEventListener("keydown", handleReadingKeyboard);
15874
+ $("#reader-viewport").addEventListener("wheel", handleReadingWheel, { passive: false });
15875
+ $("#reader-viewport").addEventListener("scroll", () => {
15876
+ if (readingPreferences.mode === "scroll") scheduleReadingPositionSave();
15877
+ }, { passive: true });
13584
15878
  document.addEventListener("visibilitychange", () => {
13585
15879
  if (document.visibilityState !== "visible") return;
13586
15880
  if (state.user && !systemRestartDetected) scheduleSystemBootCheck(0);
@@ -13595,6 +15889,14 @@ window.addEventListener("online", () => {
13595
15889
  void refreshSystemHealth();
13596
15890
  });
13597
15891
  window.addEventListener("offline", () => updateSystemHealth({ status: "offline" }));
15892
+ window.addEventListener("resize", () => {
15893
+ if ($("#reader-view").classList.contains("hidden") || readingPreferences.mode !== "paged") return;
15894
+ if (readingResizeFrame !== null) window.cancelAnimationFrame(readingResizeFrame);
15895
+ readingResizeFrame = window.requestAnimationFrame(() => {
15896
+ readingResizeFrame = null;
15897
+ layoutReadingPages({ progressRatio: readingProgressRatio() });
15898
+ });
15899
+ });
13598
15900
 
13599
15901
  initializePage().catch((error) => {
13600
15902
  restoringPageRoute = false;