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