@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.
- package/README.en.md +3 -0
- package/README.md +3 -0
- package/dist/ai-connectivity-test.js +109 -0
- package/dist/ai-connectivity-test.js.map +1 -0
- package/dist/ai-conversation-export.js +70 -0
- package/dist/ai-conversation-export.js.map +1 -0
- package/dist/ai-stream-timeout.js +18 -0
- package/dist/ai-stream-timeout.js.map +1 -0
- package/dist/ai.js +681 -107
- package/dist/ai.js.map +1 -1
- package/dist/app.js +326 -53
- package/dist/app.js.map +1 -1
- package/dist/character-extraction.js +133 -0
- package/dist/character-extraction.js.map +1 -0
- package/dist/cli-core.js +7 -6
- package/dist/cli-core.js.map +1 -1
- package/dist/database.js +175 -2
- package/dist/database.js.map +1 -1
- package/dist/epub-export.js +319 -0
- package/dist/epub-export.js.map +1 -0
- package/dist/hybrid-search.js +8 -0
- package/dist/hybrid-search.js.map +1 -1
- package/dist/public/ai-connectivity-test.d.ts +7 -0
- package/dist/public/ai-connectivity-test.js +82 -0
- package/dist/public/ai-request-manager.js +99 -0
- package/dist/public/ai-stream-protocol.js +51 -0
- package/dist/public/app.js +2567 -265
- package/dist/public/chapter-version-diff.d.ts +20 -0
- package/dist/public/chapter-version-diff.js +116 -0
- package/dist/public/foreshadow-reminder.d.ts +32 -0
- package/dist/public/foreshadow-reminder.js +73 -0
- package/dist/public/global-replace-refresh.js +60 -0
- package/dist/public/index.html +120 -12
- package/dist/public/outline-board.d.ts +61 -0
- package/dist/public/outline-board.js +137 -0
- package/dist/public/page-route.d.ts +1 -0
- package/dist/public/page-route.js +8 -0
- package/dist/public/reading-preview.d.ts +32 -0
- package/dist/public/reading-preview.js +136 -0
- package/dist/public/styles.css +485 -4
- package/dist/public/upload-progress.d.ts +2 -0
- package/dist/public/upload-progress.js +10 -0
- package/dist/security.js +5 -2
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +2 -0
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +825 -88
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +45 -9
- package/dist/user-auth.js.map +1 -1
- package/dist/utils.js +3 -0
- package/dist/utils.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
package/dist/public/app.js
CHANGED
|
@@ -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=
|
|
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) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[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
|
|
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", "
|
|
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
|
|
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`, {
|
|
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
|
-
|
|
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 || !
|
|
2008
|
-
const current = state.aiConversations.find((conversation) => conversation.id ===
|
|
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
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
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
|
-
|
|
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
|
|
2237
|
-
|
|
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
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
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 =
|
|
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
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
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
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5598
|
-
|
|
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
|
-
|
|
5637
|
-
|
|
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
|
|
5641
|
-
if (!state.
|
|
5642
|
-
|
|
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
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
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 ? `¤tChapterId=${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
|
|
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
|
-
|
|
6863
|
-
|
|
6864
|
-
|
|
6865
|
-
|
|
6866
|
-
|
|
6867
|
-
|
|
6868
|
-
|
|
6869
|
-
|
|
6870
|
-
|
|
6871
|
-
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
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(
|
|
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
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
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
|
-
|
|
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"
|
|
9229
|
-
<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 ?
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
11034
|
+
<div><strong id="work-export-settings-title">导出正文</strong><small>点击后选择 Markdown ZIP、DOCX 或 EPUB;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"
|
|
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
|
|
9389
|
-
<div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-volume-delete
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(``);
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11535
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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 {
|
|
11626
|
-
|
|
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
|
-
|
|
11629
|
-
|
|
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
|
|
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 (
|
|
11711
|
-
|
|
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
|
-
|
|
13627
|
+
if (String(payload.conversation.id) !== requestHolder.snapshot.conversationId) {
|
|
13628
|
+
throw new Error("流式上下文返回了其他对话");
|
|
13629
|
+
}
|
|
11724
13630
|
upsertAiConversationSummary(payload.conversation);
|
|
11725
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
|
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
|
-
|
|
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(
|
|
11854
|
-
|
|
11855
|
-
|
|
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 =
|
|
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
|
-
)))
|
|
11955
|
-
|
|
11956
|
-
|
|
11957
|
-
|
|
11958
|
-
|
|
11959
|
-
|
|
11960
|
-
|
|
11961
|
-
|
|
11962
|
-
|
|
11963
|
-
|
|
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
|
|
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
|
-
|
|
12085
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
12169
|
-
$("#chapter-recycle-bin-dialog")
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
15839
|
+
const requestedFormat = option.getAttribute("data-export-format");
|
|
15840
|
+
const format = ["docx", "epub"].includes(requestedFormat) ? requestedFormat : "markdown";
|
|
13580
15841
|
const work = manuscriptExportWork;
|
|
13581
|
-
|
|
13582
|
-
|
|
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;
|