@musnows/scriverse 0.4.2 → 0.4.3
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/app.js +23 -0
- package/dist/app.js.map +1 -1
- package/dist/collaboration-presence.js +75 -0
- package/dist/collaboration-presence.js.map +1 -0
- package/dist/public/app.js +461 -120
- package/dist/public/character-version.js +1 -1
- package/dist/public/display-labels.d.ts +16 -0
- package/dist/public/display-labels.js +81 -0
- package/dist/public/entity-version.js +5 -3
- package/dist/public/index.html +21 -10
- package/dist/public/page-route.js +3 -1
- package/dist/public/styles.css +90 -21
- package/dist/user-auth.js +2 -0
- 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
|
@@ -13,14 +13,32 @@ import { copyAiRawMarkdown } from "/ai-message-actions.js?v=20260713-copy-raw-ma
|
|
|
13
13
|
import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "/theme.js?v=20260713-dark-mode";
|
|
14
14
|
import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
|
|
15
15
|
import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260713-character-history";
|
|
16
|
-
import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=
|
|
16
|
+
import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260725-enum-labels-zh";
|
|
17
|
+
import {
|
|
18
|
+
chapterVersionSourceLabel,
|
|
19
|
+
characterVisibilityLabel,
|
|
20
|
+
foreshadowStatusLabel,
|
|
21
|
+
levelLabel,
|
|
22
|
+
occurrenceRoleLabel,
|
|
23
|
+
outlineStatusLabel,
|
|
24
|
+
providerConnectionLabel,
|
|
25
|
+
providerStatusLabel,
|
|
26
|
+
relationshipCategoryLabel,
|
|
27
|
+
relationshipConfirmationLabel,
|
|
28
|
+
reviewItemTypeLabel,
|
|
29
|
+
reviewStatusLabel,
|
|
30
|
+
searchResultTypeLabel,
|
|
31
|
+
settingStatusLabel,
|
|
32
|
+
taskScopeLabel,
|
|
33
|
+
timelineStatusLabel
|
|
34
|
+
} from "/display-labels.js?v=20260725-enum-labels-zh";
|
|
17
35
|
import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260723-knowledge-editor-page";
|
|
18
36
|
import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
|
|
19
37
|
import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
|
|
20
38
|
import { buildRaceForest, eligibleRaceParents, racePathLabel } from "/race-hierarchy.js?v=20260721-race-hierarchy";
|
|
21
39
|
import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
|
|
22
40
|
import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260724-outline-title";
|
|
23
|
-
import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout
|
|
41
|
+
import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout } from "/module-layout.js?v=20260723-module-layout-toggle";
|
|
24
42
|
import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
|
|
25
43
|
|
|
26
44
|
const state = {
|
|
@@ -53,6 +71,23 @@ const state = {
|
|
|
53
71
|
contextChapterId: null
|
|
54
72
|
};
|
|
55
73
|
|
|
74
|
+
function createPresenceClientId() {
|
|
75
|
+
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
76
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
77
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
78
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
79
|
+
const value = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
80
|
+
return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const presenceClientId = createPresenceClientId();
|
|
84
|
+
const presenceHeartbeatInterval = 12_000;
|
|
85
|
+
let presenceParticipants = [];
|
|
86
|
+
let presenceHeartbeatTimer = null;
|
|
87
|
+
let presenceHeartbeatQueued = null;
|
|
88
|
+
let presenceHeartbeatRequest = 0;
|
|
89
|
+
let collaborationAutoSaveDisabled = false;
|
|
90
|
+
|
|
56
91
|
let timelineMultiSelectEnabled = false;
|
|
57
92
|
|
|
58
93
|
const chapterTypes = ["正文", "设定", "作者的话", "其他"];
|
|
@@ -70,7 +105,7 @@ const analysisTaskTypeLabels = new Map([
|
|
|
70
105
|
]);
|
|
71
106
|
|
|
72
107
|
function analysisTaskTypeLabel(taskType) {
|
|
73
|
-
return analysisTaskTypeLabels.get(String(taskType)) ??
|
|
108
|
+
return analysisTaskTypeLabels.get(String(taskType)) ?? "其他分析";
|
|
74
109
|
}
|
|
75
110
|
|
|
76
111
|
function analysisTaskStatusLabel(status) {
|
|
@@ -82,7 +117,11 @@ function analysisTaskStatusLabel(status) {
|
|
|
82
117
|
partial: "部分失败",
|
|
83
118
|
expired: "已过期",
|
|
84
119
|
cancelled: "已取消"
|
|
85
|
-
})[String(status)] ??
|
|
120
|
+
})[String(status)] ?? "未知状态";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function reviewSeverityLabel(severity) {
|
|
124
|
+
return levelLabel(severity);
|
|
86
125
|
}
|
|
87
126
|
|
|
88
127
|
function canEditWork(work = state.work) {
|
|
@@ -409,13 +448,134 @@ function replacePageRoute(route) {
|
|
|
409
448
|
if (restoringPageRoute) return;
|
|
410
449
|
const hash = serializePageRoute(route);
|
|
411
450
|
if (window.location.hash !== hash) window.history.replaceState(null, "", hash);
|
|
451
|
+
schedulePresenceHeartbeat();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function presencePageForRoute(route = currentPageRoute()) {
|
|
455
|
+
if (!state.work || route.view === "shelf" || route.view === "platform-ai") return null;
|
|
456
|
+
if (route.view === "editor") return { kind: "editor", resourceId: String(route.chapterId ?? "") || undefined };
|
|
457
|
+
if (route.view === "entity-editor") return { kind: "entity-editor", module: route.entity, resourceId: String(route.entityId ?? "") || undefined };
|
|
458
|
+
if (route.view === "module") return { kind: "module", module: route.module };
|
|
459
|
+
if (route.view === "settings" || route.view === "platform-ai") return { kind: "settings" };
|
|
460
|
+
return { kind: "welcome" };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function presencePageKey(page) {
|
|
464
|
+
if (!page) return "";
|
|
465
|
+
if (page.kind === "editor") return `editor:${page.resourceId ?? ""}`;
|
|
466
|
+
if (page.kind === "entity-editor") return `entity-editor:${page.module ?? ""}:${page.resourceId ?? ""}`;
|
|
467
|
+
if (page.kind === "module") return `module:${page.module ?? ""}`;
|
|
468
|
+
return page.kind;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function groupedPresenceParticipants() {
|
|
472
|
+
const groups = new Map();
|
|
473
|
+
for (const participant of presenceParticipants) {
|
|
474
|
+
const current = groups.get(participant.userId) ?? { ...participant, pages: [], clientIds: [] };
|
|
475
|
+
if (!current.pages.some((page) => page.key === participant.page.key)) current.pages.push(participant.page);
|
|
476
|
+
current.clientIds.push(participant.clientId);
|
|
477
|
+
groups.set(participant.userId, current);
|
|
478
|
+
}
|
|
479
|
+
return [...groups.values()];
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function hasOtherCollaborators() {
|
|
483
|
+
return presenceParticipants.some((participant) => participant.userId !== state.user?.userId);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function syncChapterAutoSaveWithPresence() {
|
|
487
|
+
const wasDisabled = collaborationAutoSaveDisabled;
|
|
488
|
+
collaborationAutoSaveDisabled = hasOtherCollaborators();
|
|
489
|
+
if (collaborationAutoSaveDisabled) {
|
|
490
|
+
cancelChapterAutoSave();
|
|
491
|
+
if (state.chapter && canEditProse()) {
|
|
492
|
+
setSaveState(state.dirty ? "多人协作,自动保存已关闭" : "自动保存已关闭", state.dirty);
|
|
493
|
+
}
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (wasDisabled && state.dirty && state.chapter && canEditProse()) scheduleChapterAutoSave(250);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function renderPresence() {
|
|
500
|
+
const control = $("#presence-control");
|
|
501
|
+
if (!state.work || !presenceParticipants.length) {
|
|
502
|
+
syncChapterAutoSaveWithPresence();
|
|
503
|
+
control.classList.add("hidden");
|
|
504
|
+
$("#presence-panel").classList.add("hidden");
|
|
505
|
+
$("#presence-button").setAttribute("aria-expanded", "false");
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const groups = groupedPresenceParticipants();
|
|
509
|
+
const localKey = presencePageKey(presencePageForRoute());
|
|
510
|
+
syncChapterAutoSaveWithPresence();
|
|
511
|
+
control.classList.remove("hidden");
|
|
512
|
+
$("#presence-count").textContent = `${groups.length} 人在线`;
|
|
513
|
+
$("#presence-list").innerHTML = groups.map((participant) => {
|
|
514
|
+
const isCurrent = participant.userId === state.user?.userId;
|
|
515
|
+
const samePage = !isCurrent && participant.pages.some((page) => page.key === localKey);
|
|
516
|
+
const pageLabels = participant.pages.map((page) => page.label).join("、");
|
|
517
|
+
const avatar = participant.avatarUrl
|
|
518
|
+
? `<span class="user-avatar"><span class="user-avatar-fallback">${esc(Array.from(participant.displayName || participant.username)[0] ?? "作")}</span><img src="${esc(participant.avatarUrl)}" alt=""></span>`
|
|
519
|
+
: `<span class="user-avatar"><span class="user-avatar-fallback">${esc(Array.from(participant.displayName || participant.username)[0] ?? "作")}</span></span>`;
|
|
520
|
+
return `<div class="presence-person${samePage ? " is-same-page" : ""}">${avatar}<div class="presence-person-copy"><strong>${esc(participant.displayName)}${isCurrent ? "(你)" : ""}</strong><small>${esc(pageLabels)}</small></div>${samePage ? '<span class="presence-same-page">同一页面</span>' : ""}</div>`;
|
|
521
|
+
}).join("");
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function refreshPresence() {
|
|
525
|
+
const requestId = ++presenceHeartbeatRequest;
|
|
526
|
+
if (presenceHeartbeatTimer !== null) clearTimeout(presenceHeartbeatTimer);
|
|
527
|
+
presenceHeartbeatTimer = null;
|
|
528
|
+
const workId = state.work?.id;
|
|
529
|
+
const page = presencePageForRoute();
|
|
530
|
+
if (!workId || !page || !state.user) {
|
|
531
|
+
presenceParticipants = [];
|
|
532
|
+
renderPresence();
|
|
533
|
+
return [];
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
const participants = await api(`/api/works/${encodeURIComponent(workId)}/presence`, {
|
|
537
|
+
method: "POST",
|
|
538
|
+
body: { clientId: presenceClientId, page },
|
|
539
|
+
skipOptimisticVersion: true
|
|
540
|
+
});
|
|
541
|
+
if (state.work?.id === workId && presenceHeartbeatRequest === requestId) {
|
|
542
|
+
presenceParticipants = participants;
|
|
543
|
+
renderPresence();
|
|
544
|
+
}
|
|
545
|
+
} catch {
|
|
546
|
+
if (state.work?.id === workId) renderPresence();
|
|
547
|
+
} finally {
|
|
548
|
+
if (state.work?.id === workId && presenceHeartbeatRequest === requestId) presenceHeartbeatTimer = setTimeout(refreshPresence, presenceHeartbeatInterval);
|
|
549
|
+
}
|
|
550
|
+
return presenceParticipants;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function schedulePresenceHeartbeat() {
|
|
554
|
+
if (presenceHeartbeatQueued !== null) clearTimeout(presenceHeartbeatQueued);
|
|
555
|
+
presenceHeartbeatQueued = setTimeout(() => {
|
|
556
|
+
presenceHeartbeatQueued = null;
|
|
557
|
+
void refreshPresence();
|
|
558
|
+
}, 80);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function confirmConcurrentSave() {
|
|
562
|
+
await refreshPresence();
|
|
563
|
+
const localKey = presencePageKey(presencePageForRoute());
|
|
564
|
+
const peers = presenceParticipants.filter((participant) => participant.clientId !== presenceClientId && participant.page.key === localKey);
|
|
565
|
+
if (!peers.length) return true;
|
|
566
|
+
const names = [...new Set(peers.map((participant) => participant.displayName))];
|
|
567
|
+
return confirmToast(`${names.join("、")}也停留在这个编辑页面。当前项目尚未适配多人同时编辑,继续保存可能覆盖对方的修改。`, {
|
|
568
|
+
title: "检测到同页协作",
|
|
569
|
+
confirmLabel: "仍然保存",
|
|
570
|
+
cancelLabel: "暂不保存"
|
|
571
|
+
});
|
|
412
572
|
}
|
|
413
573
|
|
|
414
574
|
function currentPageRoute() {
|
|
415
575
|
const workId = state.work?.id ?? null;
|
|
416
576
|
if (!$("#entity-editor-view").classList.contains("hidden") && workId && entityEditorType) {
|
|
417
577
|
const entityId = entityEditorType === "setting" ? settingEditorItem?.id : entityEditorType === "character" ? characterEditorItem?.id : knowledgeEditorItem?.id;
|
|
418
|
-
return { view: "entity-editor", workId, entity: entityEditorType, entityId: entityId ?? null };
|
|
578
|
+
return { view: "entity-editor", workId, entity: entityEditorType, entityId: entityId ?? null, entityMode: entityEditorReadOnly ? "read" : "edit" };
|
|
419
579
|
}
|
|
420
580
|
if (!$("#settings-hub-view").classList.contains("hidden")) return { view: "settings", workId, ...settingsRouteContext() };
|
|
421
581
|
if (!$("#platform-ai-view").classList.contains("hidden")) return { view: "platform-ai", workId, ...settingsRouteContext() };
|
|
@@ -520,6 +680,7 @@ let chapterLineDrag = null;
|
|
|
520
680
|
let chapterWhitespaceVisible = true;
|
|
521
681
|
let chapterAutoSaveTimer = null;
|
|
522
682
|
let chapterSaveInFlight = null;
|
|
683
|
+
let chapterSaveGuardInFlight = null;
|
|
523
684
|
let lastSavedChapterSnapshot = null;
|
|
524
685
|
let moduleNavExpanded = false;
|
|
525
686
|
const chapterAutoSaveDelay = 800;
|
|
@@ -528,6 +689,8 @@ let aiMentionRange = null;
|
|
|
528
689
|
let settingsReturnContext = null;
|
|
529
690
|
let entityEditorType = null;
|
|
530
691
|
let entityEditorDirty = false;
|
|
692
|
+
let entityEditorReadOnly = false;
|
|
693
|
+
let characterListPage = 1;
|
|
531
694
|
let settingEditorItem = null;
|
|
532
695
|
let characterEditorItem = null;
|
|
533
696
|
let knowledgeEditorItem = null;
|
|
@@ -547,9 +710,10 @@ let knowledgeSectionVditor = null;
|
|
|
547
710
|
let characterSectionVditor = null;
|
|
548
711
|
let entityHistoryContext = null;
|
|
549
712
|
|
|
550
|
-
function showEntityEditorPage(type) {
|
|
713
|
+
function showEntityEditorPage(type, { readOnly = false } = {}) {
|
|
551
714
|
entityEditorType = type;
|
|
552
715
|
entityEditorDirty = false;
|
|
716
|
+
entityEditorReadOnly = readOnly;
|
|
553
717
|
characterSectionEditorDirty = false;
|
|
554
718
|
knowledgeSectionEditorDirty = false;
|
|
555
719
|
$("#entity-editor-view").classList.remove("hidden");
|
|
@@ -565,7 +729,7 @@ function showEntityEditorPage(type) {
|
|
|
565
729
|
|
|
566
730
|
function markEntityEditorDirty() {
|
|
567
731
|
const module = entityEditorType === "setting" ? "settings" : entityEditorType === "character" ? "characters" : entityEditorType === "race" ? "races" : "organizations";
|
|
568
|
-
if (entityEditorType && canEditModule(module)) entityEditorDirty = true;
|
|
732
|
+
if (entityEditorType && !entityEditorReadOnly && canEditModule(module)) entityEditorDirty = true;
|
|
569
733
|
}
|
|
570
734
|
|
|
571
735
|
async function confirmEntityEditorDiscard(message) {
|
|
@@ -588,6 +752,7 @@ async function closeEntityEditor({ force = false } = {}) {
|
|
|
588
752
|
const module = entityEditorType === "setting" ? "settings" : entityEditorType === "character" ? "characters" : entityEditorType === "race" ? "races" : "organizations";
|
|
589
753
|
entityEditorType = null;
|
|
590
754
|
entityEditorDirty = false;
|
|
755
|
+
entityEditorReadOnly = false;
|
|
591
756
|
settingEditorItem = null;
|
|
592
757
|
characterEditorItem = null;
|
|
593
758
|
knowledgeEditorItem = null;
|
|
@@ -1587,7 +1752,7 @@ function attachOptimisticVersion(path, method, body) {
|
|
|
1587
1752
|
|
|
1588
1753
|
async function api(path, options = {}) {
|
|
1589
1754
|
const method = String(options.method ?? "GET").toUpperCase();
|
|
1590
|
-
const body = attachOptimisticVersion(path, method, options.body);
|
|
1755
|
+
const body = options.skipOptimisticVersion ? options.body : attachOptimisticVersion(path, method, options.body);
|
|
1591
1756
|
const headers = { ...(options.headers ?? {}) };
|
|
1592
1757
|
if (state.csrfToken && !["GET", "HEAD", "OPTIONS"].includes(method)) headers["X-CSRF-Token"] = state.csrfToken;
|
|
1593
1758
|
if (!(body instanceof FormData)) headers["Content-Type"] = "application/json";
|
|
@@ -1805,6 +1970,60 @@ function confirmToast(message, { title = "请再次确认", confirmLabel = "确
|
|
|
1805
1970
|
});
|
|
1806
1971
|
}
|
|
1807
1972
|
|
|
1973
|
+
function inputToast(message, { title = "请输入", inputLabel = title, placeholder = "", confirmLabel = "确认", cancelLabel = "取消", maxLength = 500 } = {}) {
|
|
1974
|
+
const region = $("#toast-region");
|
|
1975
|
+
const element = document.createElement("section");
|
|
1976
|
+
element.className = "toast toast-confirmation toast-input-dialog";
|
|
1977
|
+
element.setAttribute("role", "alertdialog");
|
|
1978
|
+
element.setAttribute("aria-label", title);
|
|
1979
|
+
const heading = document.createElement("strong");
|
|
1980
|
+
heading.textContent = title;
|
|
1981
|
+
const description = document.createElement("p");
|
|
1982
|
+
description.textContent = message;
|
|
1983
|
+
const input = document.createElement("input");
|
|
1984
|
+
input.className = "toast-input";
|
|
1985
|
+
input.type = "text";
|
|
1986
|
+
input.maxLength = maxLength;
|
|
1987
|
+
input.placeholder = placeholder;
|
|
1988
|
+
input.setAttribute("aria-label", inputLabel);
|
|
1989
|
+
const actions = document.createElement("div");
|
|
1990
|
+
actions.className = "toast-confirmation-actions";
|
|
1991
|
+
const cancel = document.createElement("button");
|
|
1992
|
+
cancel.className = "ghost-button";
|
|
1993
|
+
cancel.type = "button";
|
|
1994
|
+
cancel.textContent = cancelLabel;
|
|
1995
|
+
const confirm = document.createElement("button");
|
|
1996
|
+
confirm.className = "primary-button";
|
|
1997
|
+
confirm.type = "button";
|
|
1998
|
+
confirm.textContent = confirmLabel;
|
|
1999
|
+
actions.append(cancel, confirm);
|
|
2000
|
+
element.append(heading, description, input, actions);
|
|
2001
|
+
region.append(element);
|
|
2002
|
+
raiseToastRegion();
|
|
2003
|
+
input.focus();
|
|
2004
|
+
return new Promise((resolve) => {
|
|
2005
|
+
let settled = false;
|
|
2006
|
+
const finish = (value) => {
|
|
2007
|
+
if (settled) return;
|
|
2008
|
+
settled = true;
|
|
2009
|
+
element.remove();
|
|
2010
|
+
if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) region.hidePopover();
|
|
2011
|
+
resolve(value);
|
|
2012
|
+
};
|
|
2013
|
+
cancel.addEventListener("click", () => finish(null), { once: true });
|
|
2014
|
+
confirm.addEventListener("click", () => finish(input.value.trim()), { once: true });
|
|
2015
|
+
element.addEventListener("keydown", (event) => {
|
|
2016
|
+
if (event.key === "Escape") {
|
|
2017
|
+
event.preventDefault();
|
|
2018
|
+
finish(null);
|
|
2019
|
+
} else if (event.key === "Enter") {
|
|
2020
|
+
event.preventDefault();
|
|
2021
|
+
finish(input.value.trim());
|
|
2022
|
+
}
|
|
2023
|
+
});
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
|
|
1808
2027
|
document.addEventListener("toggle", (event) => {
|
|
1809
2028
|
const target = event.target;
|
|
1810
2029
|
if (target instanceof HTMLDialogElement && target.open && $("#toast-region").childElementCount) {
|
|
@@ -1839,6 +2058,10 @@ function cancelChapterAutoSave() {
|
|
|
1839
2058
|
function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
|
|
1840
2059
|
if (!state.chapter || !canEditProse()) return;
|
|
1841
2060
|
cancelChapterAutoSave();
|
|
2061
|
+
if (collaborationAutoSaveDisabled) {
|
|
2062
|
+
setSaveState("多人协作,自动保存已关闭", true);
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
1842
2065
|
setSaveState("等待自动保存", true);
|
|
1843
2066
|
chapterAutoSaveTimer = setTimeout(() => {
|
|
1844
2067
|
chapterAutoSaveTimer = null;
|
|
@@ -1853,12 +2076,23 @@ async function persistChapter({ automatic = false } = {}) {
|
|
|
1853
2076
|
return null;
|
|
1854
2077
|
}
|
|
1855
2078
|
cancelChapterAutoSave();
|
|
2079
|
+
if (automatic) {
|
|
2080
|
+
await refreshPresence();
|
|
2081
|
+
if (collaborationAutoSaveDisabled) {
|
|
2082
|
+
setSaveState("多人协作,自动保存已关闭", true);
|
|
2083
|
+
return null;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
1856
2086
|
if (chapterSaveInFlight) {
|
|
1857
2087
|
await chapterSaveInFlight;
|
|
1858
2088
|
const pendingDraft = chapterDraftSnapshot();
|
|
1859
2089
|
if (!sameChapterSnapshot(pendingDraft, lastSavedChapterSnapshot)) return persistChapter({ automatic });
|
|
1860
2090
|
return state.chapter;
|
|
1861
2091
|
}
|
|
2092
|
+
if (chapterSaveGuardInFlight) {
|
|
2093
|
+
await chapterSaveGuardInFlight;
|
|
2094
|
+
return persistChapter({ automatic });
|
|
2095
|
+
}
|
|
1862
2096
|
const draft = chapterDraftSnapshot();
|
|
1863
2097
|
if (!draft?.title) {
|
|
1864
2098
|
setSaveState("标题不能为空", true);
|
|
@@ -1871,9 +2105,17 @@ async function persistChapter({ automatic = false } = {}) {
|
|
|
1871
2105
|
scheduleChapterLineNumbers();
|
|
1872
2106
|
}
|
|
1873
2107
|
if (sameChapterSnapshot(draft, lastSavedChapterSnapshot)) {
|
|
1874
|
-
setSaveState(automatic ? "已自动保存" : "已保存");
|
|
2108
|
+
setSaveState(automatic ? "已自动保存" : collaborationAutoSaveDisabled ? "已保存 · 自动保存已关闭" : "已保存");
|
|
1875
2109
|
return state.chapter;
|
|
1876
2110
|
}
|
|
2111
|
+
const saveGuard = confirmConcurrentSave();
|
|
2112
|
+
chapterSaveGuardInFlight = saveGuard;
|
|
2113
|
+
const confirmed = await saveGuard;
|
|
2114
|
+
if (chapterSaveGuardInFlight === saveGuard) chapterSaveGuardInFlight = null;
|
|
2115
|
+
if (!confirmed) {
|
|
2116
|
+
setSaveState("检测到同页协作,未保存", true);
|
|
2117
|
+
return null;
|
|
2118
|
+
}
|
|
1877
2119
|
setSaveState(automatic ? "自动保存中" : "保存中", true);
|
|
1878
2120
|
const workId = state.work.id;
|
|
1879
2121
|
const request = (async () => {
|
|
@@ -1895,7 +2137,7 @@ async function persistChapter({ automatic = false } = {}) {
|
|
|
1895
2137
|
updateChapterStats();
|
|
1896
2138
|
const currentDraft = chapterDraftSnapshot();
|
|
1897
2139
|
if (sameChapterSnapshot(currentDraft, draft)) {
|
|
1898
|
-
setSaveState(automatic ? "已自动保存" : "已保存");
|
|
2140
|
+
setSaveState(automatic ? "已自动保存" : collaborationAutoSaveDisabled ? "已保存 · 自动保存已关闭" : "已保存");
|
|
1899
2141
|
if (!automatic) toast(`正文已保存为 v${state.chapter.versionNo}`);
|
|
1900
2142
|
} else {
|
|
1901
2143
|
scheduleChapterAutoSave(250);
|
|
@@ -2002,15 +2244,20 @@ async function initializePage() {
|
|
|
2002
2244
|
if (route.view === "module") return;
|
|
2003
2245
|
if (route.view === "entity-editor") {
|
|
2004
2246
|
const records = route.entity === "setting" ? state.settings : route.entity === "character" ? state.characters : route.entity === "race" ? state.races : state.organizations;
|
|
2005
|
-
const item = route.entityId
|
|
2247
|
+
const item = route.entityId
|
|
2248
|
+
? route.entity === "character"
|
|
2249
|
+
? await api(`/api/characters/${encodeURIComponent(route.entityId)}`)
|
|
2250
|
+
: records.find((record) => record.id === route.entityId)
|
|
2251
|
+
: null;
|
|
2006
2252
|
if (route.entityId && !item) {
|
|
2007
2253
|
toast(({ setting: "未找到要编辑的设定", character: "未找到要编辑的角色", race: "未找到要编辑的种族", organization: "未找到要编辑的组织" }[route.entity] ?? "未找到要编辑的档案"), "error");
|
|
2008
2254
|
return;
|
|
2009
2255
|
}
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
else if (route.entity === "
|
|
2013
|
-
else if (route.entity === "
|
|
2256
|
+
const options = { readOnly: route.entityMode === "read" };
|
|
2257
|
+
if (route.entity === "setting") openSettingEditor(item, options);
|
|
2258
|
+
else if (route.entity === "character") await openCharacterEditor(item, options);
|
|
2259
|
+
else if (route.entity === "race") await openRaceDialog(item, options);
|
|
2260
|
+
else if (route.entity === "organization") await openOrganizationDialog(item, options);
|
|
2014
2261
|
return;
|
|
2015
2262
|
}
|
|
2016
2263
|
if (route.view === "welcome") {
|
|
@@ -2230,14 +2477,6 @@ async function openMembersDialog(targetWork = state.work) {
|
|
|
2230
2477
|
} catch (error) { $("#members-dialog").close(); toast(error.message, "error"); }
|
|
2231
2478
|
}
|
|
2232
2479
|
|
|
2233
|
-
const searchResultTypeLabels = {
|
|
2234
|
-
chapter: "章节",
|
|
2235
|
-
setting: "设定",
|
|
2236
|
-
character: "角色",
|
|
2237
|
-
race: "种族",
|
|
2238
|
-
organization: "组织"
|
|
2239
|
-
};
|
|
2240
|
-
|
|
2241
2480
|
async function openSearchDialog() {
|
|
2242
2481
|
if (!state.work) {
|
|
2243
2482
|
toast("请先打开一部作品", "error");
|
|
@@ -2262,7 +2501,7 @@ function renderSearchResults(results) {
|
|
|
2262
2501
|
}
|
|
2263
2502
|
$("#search-results").innerHTML = results.map((item) => `
|
|
2264
2503
|
<button type="button" class="search-result" data-search-type="${esc(item.type)}" data-search-id="${esc(item.id)}">
|
|
2265
|
-
<div class="search-result-meta"><span>${esc(
|
|
2504
|
+
<div class="search-result-meta"><span>${esc(searchResultTypeLabel(item.type))}</span><strong>${esc(item.title)}</strong></div>
|
|
2266
2505
|
<p>${esc(item.snippet || "无摘要")}</p>
|
|
2267
2506
|
</button>`).join("");
|
|
2268
2507
|
$("#search-results").querySelectorAll(".search-result").forEach((button) => {
|
|
@@ -2412,6 +2651,7 @@ function resetWorkScopedUiCaches() {
|
|
|
2412
2651
|
state.models = [];
|
|
2413
2652
|
state.characters = [];
|
|
2414
2653
|
state.settings = [];
|
|
2654
|
+
characterListPage = 1;
|
|
2415
2655
|
state.collapsedVolumeIds.clear();
|
|
2416
2656
|
lastSavedChapterSnapshot = null;
|
|
2417
2657
|
if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
|
|
@@ -2644,7 +2884,7 @@ async function showModule(module) {
|
|
|
2644
2884
|
$("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
|
|
2645
2885
|
try {
|
|
2646
2886
|
if (module === "settings") await renderSettings();
|
|
2647
|
-
if (module === "characters") await renderCharacters();
|
|
2887
|
+
if (module === "characters") await renderCharacters(characterListPage);
|
|
2648
2888
|
if (module === "races") await renderRaces();
|
|
2649
2889
|
if (module === "organizations") await renderOrganizations();
|
|
2650
2890
|
if (module === "timeline") await renderTimeline();
|
|
@@ -2837,13 +3077,19 @@ function moduleRowPreview(text, max = 180) {
|
|
|
2837
3077
|
return preview.length > max ? `${preview.slice(0, max)}…` : preview;
|
|
2838
3078
|
}
|
|
2839
3079
|
|
|
3080
|
+
function moduleLayoutIconMarkup(layout) {
|
|
3081
|
+
if (layout === "rows") {
|
|
3082
|
+
return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M8 6h13M8 12h13M8 18h13"></path><path d="M3 6h.01M3 12h.01M3 18h.01"></path></svg>';
|
|
3083
|
+
}
|
|
3084
|
+
return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3" y="3" width="7" height="7" rx="1"></rect><rect x="14" y="3" width="7" height="7" rx="1"></rect><rect x="3" y="14" width="7" height="7" rx="1"></rect><rect x="14" y="14" width="7" height="7" rx="1"></rect></svg>';
|
|
3085
|
+
}
|
|
3086
|
+
|
|
2840
3087
|
function renderModuleLayoutToggle(layout, ariaLabel = "列表样式") {
|
|
2841
3088
|
return `<div class="module-layout-toolbar" data-module-header-action="layout-toggle">
|
|
2842
3089
|
<div class="module-layout-toggle" role="group" aria-label="${esc(ariaLabel)}">
|
|
2843
|
-
<button type="button" data-module-layout="cards" aria-pressed="${layout === "cards"}"
|
|
2844
|
-
<button type="button" data-module-layout="rows" aria-pressed="${layout === "rows"}"
|
|
3090
|
+
<button type="button" data-module-layout="cards" aria-label="卡片视图" title="卡片视图" aria-pressed="${layout === "cards"}">${moduleLayoutIconMarkup("cards")}</button>
|
|
3091
|
+
<button type="button" data-module-layout="rows" aria-label="列表视图" title="列表视图" aria-pressed="${layout === "rows"}">${moduleLayoutIconMarkup("rows")}</button>
|
|
2845
3092
|
</div>
|
|
2846
|
-
<span class="module-layout-hint">当前:${esc(moduleLayoutLabel(layout))}</span>
|
|
2847
3093
|
</div>`;
|
|
2848
3094
|
}
|
|
2849
3095
|
|
|
@@ -2859,6 +3105,56 @@ function bindModuleLayoutToggle(refresh) {
|
|
|
2859
3105
|
}));
|
|
2860
3106
|
}
|
|
2861
3107
|
|
|
3108
|
+
function bindRecordPreview(selector, open) {
|
|
3109
|
+
$("#module-content").querySelectorAll(selector).forEach((card) => {
|
|
3110
|
+
const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openReview;
|
|
3111
|
+
card.addEventListener("click", (event) => {
|
|
3112
|
+
if (!event.target.closest("button, a, summary")) void open(id);
|
|
3113
|
+
});
|
|
3114
|
+
card.addEventListener("keydown", (event) => {
|
|
3115
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
3116
|
+
if (event.target.closest("button, a, summary")) return;
|
|
3117
|
+
event.preventDefault();
|
|
3118
|
+
void open(id);
|
|
3119
|
+
});
|
|
3120
|
+
});
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
function openReviewDetailDialog(item) {
|
|
3124
|
+
if (!item) return;
|
|
3125
|
+
const evidence = Array.isArray(item.evidence) ? item.evidence : [];
|
|
3126
|
+
const entityRefs = Array.isArray(item.entityRefs) ? item.entityRefs : [];
|
|
3127
|
+
const evidenceHtml = evidence.length
|
|
3128
|
+
? `<ul>${evidence.map((entry) => {
|
|
3129
|
+
if (!entry || typeof entry !== "object") return `<li>${esc(String(entry))}</li>`;
|
|
3130
|
+
const source = entry.chapterTitle || entry.chapterId || "相关证据";
|
|
3131
|
+
const quote = entry.quote ? `<blockquote>${esc(entry.quote)}</blockquote>` : "";
|
|
3132
|
+
const supports = entry.supports ? `<small>${esc(entry.supports)}</small>` : "";
|
|
3133
|
+
return `<li><strong>${esc(source)}</strong>${quote}${supports}</li>`;
|
|
3134
|
+
}).join("")}</ul>`
|
|
3135
|
+
: "<p>暂无证据</p>";
|
|
3136
|
+
const entityRefsHtml = entityRefs.length
|
|
3137
|
+
? `<ul>${entityRefs.map((reference) => `<li><code>${esc(JSON.stringify(reference))}</code></li>`).join("")}</ul>`
|
|
3138
|
+
: "<p>未关联资料</p>";
|
|
3139
|
+
openDialog("审核详情",
|
|
3140
|
+
`<div class="task-detail review-detail">
|
|
3141
|
+
<p><strong>问题类型</strong> ${esc(reviewItemTypeLabel(item.itemType))} · ${esc(reviewSeverityLabel(item.severity))} · ${esc(reviewStatusLabel(item.status))}</p>
|
|
3142
|
+
<div><strong>问题说明</strong><pre class="task-detail-result">${esc(item.description || "暂无说明")}</pre></div>
|
|
3143
|
+
<div><strong>处理建议</strong><pre class="task-detail-result">${esc(item.suggestion || "暂无建议")}</pre></div>
|
|
3144
|
+
<div><strong>相关证据</strong>${evidenceHtml}</div>
|
|
3145
|
+
<div><strong>关联资料</strong>${entityRefsHtml}</div>
|
|
3146
|
+
${item.resolutionNote ? `<div><strong>处理结果</strong><pre class="task-detail-result">${esc(item.resolutionNote)}</pre></div>` : ""}
|
|
3147
|
+
</div>`,
|
|
3148
|
+
async () => undefined,
|
|
3149
|
+
"审核建议",
|
|
3150
|
+
{
|
|
3151
|
+
submitLabel: "关闭",
|
|
3152
|
+
wide: true,
|
|
3153
|
+
hideCancel: true,
|
|
3154
|
+
meta: `创建于 ${formatDateTime(item.createdAt)} · 更新于 ${formatDateTime(item.updatedAt)}`
|
|
3155
|
+
});
|
|
3156
|
+
}
|
|
3157
|
+
|
|
2862
3158
|
function settingRecordActions(item) {
|
|
2863
3159
|
return canEditModule("settings")
|
|
2864
3160
|
? recordCardEditButton("edit-setting", item.id, `设定“${item.title}”`)
|
|
@@ -2867,7 +3163,7 @@ function settingRecordActions(item) {
|
|
|
2867
3163
|
|
|
2868
3164
|
function renderSettingCards(records) {
|
|
2869
3165
|
return `<div class="card-grid">${records.map((item) => `
|
|
2870
|
-
<article class="record-card"><small>${esc(item.category)} · ${item.locked ? "已锁定" : esc(item.status)}</small>
|
|
3166
|
+
<article class="record-card preview-record-card" data-open-setting="${esc(item.id)}" role="button" tabindex="0" aria-label="查看设定 ${esc(item.title)}"><small>${esc(item.category)} · ${item.locked ? "已锁定" : esc(settingStatusLabel(item.status))}</small>
|
|
2871
3167
|
<h3>${esc(item.title)}</h3><div class="record-markdown-preview message-body">${renderMarkdown(item.content) || '<p class="markdown-editor-empty">暂无正文</p>'}</div>
|
|
2872
3168
|
<div class="card-actions">${settingRecordActions(item)}</div></article>`).join("")}</div>`;
|
|
2873
3169
|
}
|
|
@@ -2876,7 +3172,7 @@ function renderSettingRows(records) {
|
|
|
2876
3172
|
return `<div class="module-row-list">${records.map((item) => {
|
|
2877
3173
|
const preview = moduleRowPreview(item.content);
|
|
2878
3174
|
return `
|
|
2879
|
-
<article class="record-card module-row"><small>${esc(item.category)} · ${item.locked ? "已锁定" : esc(item.status)}</small>
|
|
3175
|
+
<article class="record-card module-row preview-record-card" data-open-setting="${esc(item.id)}" role="button" tabindex="0" aria-label="查看设定 ${esc(item.title)}"><small>${esc(item.category)} · ${item.locked ? "已锁定" : esc(settingStatusLabel(item.status))}</small>
|
|
2880
3176
|
<h3>${esc(item.title)}</h3><p class="module-row-preview" title="${esc(preview)}">${esc(preview)}</p>
|
|
2881
3177
|
<div class="card-actions">${settingRecordActions(item)}</div></article>`;
|
|
2882
3178
|
}).join("")}</div>`;
|
|
@@ -2891,22 +3187,26 @@ async function renderSettings() {
|
|
|
2891
3187
|
? `${layout === "rows" ? renderSettingRows(records) : renderSettingCards(records)}`
|
|
2892
3188
|
: emptyModule("还没有世界观设定", "新建规则、地点、组织、科技或创作约束。AI 提取的候选也会进入这里。");
|
|
2893
3189
|
bindModuleLayoutToggle(renderSettings);
|
|
3190
|
+
bindRecordPreview("[data-open-setting]", (id) => openSettingEditor(records.find((item) => item.id === id), { readOnly: true }));
|
|
2894
3191
|
$("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => openSettingEditor(records.find((item) => item.id === button.dataset.editSetting))));
|
|
2895
3192
|
bindEntityHistoryButtons(async () => { await renderSettings(); await loadAiReferences(); });
|
|
2896
3193
|
}
|
|
2897
3194
|
|
|
2898
|
-
async function renderCharacters() {
|
|
2899
|
-
[
|
|
2900
|
-
apiPage(`/api/works/${state.work.id}/characters
|
|
3195
|
+
async function renderCharacters(page = characterListPage) {
|
|
3196
|
+
const [characterPage, races, organizations] = await Promise.all([
|
|
3197
|
+
apiPage(`/api/works/${state.work.id}/characters`, page),
|
|
2901
3198
|
canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
|
|
2902
3199
|
canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
|
|
2903
3200
|
]);
|
|
3201
|
+
if (!characterPage.items.length && page > 1) return renderCharacters(page - 1);
|
|
3202
|
+
characterListPage = characterPage.page;
|
|
3203
|
+
[state.characters, state.races, state.organizations] = [characterPage.items, races, organizations];
|
|
2904
3204
|
const layout = readModuleLayout();
|
|
2905
3205
|
const characterActions = (item) => recordCardEditButton("edit-character", item.id, `角色“${item.name}”`);
|
|
2906
3206
|
const characterCards = () => `<div class="card-grid">${state.characters.map((item) => {
|
|
2907
3207
|
const details = normalizeCharacterDetails(item.attributes?.details);
|
|
2908
3208
|
return `
|
|
2909
|
-
<article class="record-card character-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}”`)}<small>${item.lockedFields.length ? `锁定 ${item.lockedFields.length} 项` : esc(item.visibility)}</small>
|
|
3209
|
+
<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}”`)}<small>${item.lockedFields.length ? `锁定 ${item.lockedFields.length} 项` : esc(characterVisibilityLabel(item.visibility))}</small>
|
|
2910
3210
|
<h3>${esc(item.name)}</h3>
|
|
2911
3211
|
${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
|
|
2912
3212
|
${item.aliases.length ? `<div class="character-aliases">${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
|
|
@@ -2928,19 +3228,32 @@ async function renderCharacters() {
|
|
|
2928
3228
|
].filter(Boolean).join(" · ");
|
|
2929
3229
|
const line = meta ? `${meta} · ${preview}` : preview;
|
|
2930
3230
|
return `
|
|
2931
|
-
<article class="record-card module-row character-card" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">
|
|
2932
|
-
<small>${item.lockedFields.length ? `锁定 ${item.lockedFields.length} 项` : esc(item.visibility)}</small>
|
|
3231
|
+
<article class="record-card module-row character-card preview-record-card" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">
|
|
3232
|
+
<small>${item.lockedFields.length ? `锁定 ${item.lockedFields.length} 项` : esc(characterVisibilityLabel(item.visibility))}</small>
|
|
2933
3233
|
<h3>${esc(item.name)}</h3>
|
|
2934
3234
|
<p class="module-row-preview" title="${esc(line)}">${esc(line)}</p>
|
|
2935
3235
|
<div class="card-actions">${characterActions(item)}</div>
|
|
2936
3236
|
</article>`;
|
|
2937
3237
|
}).join("")}</div>`;
|
|
2938
|
-
const
|
|
3238
|
+
const hasMultipleCharacters = characterPage.page > 1 || characterPage.hasMore || state.characters.length > 1;
|
|
3239
|
+
const auditPanel = canEditModule("tasks") ? `<section class="character-audit-panel"><div><strong>角色身份确认</strong><small>让 AI 查询角色档案并搜索正文,找出可能被误建成两个档案的同一角色。AI 只提交审核建议,不会自动合并。</small></div><button id="create-character-audit-task" class="ghost-button" type="button" ${hasMultipleCharacters ? "" : "disabled"}>AI 角色查重</button></section>` : "";
|
|
3240
|
+
const pagination = state.characters.length && (characterPage.page > 1 || characterPage.hasMore)
|
|
3241
|
+
? `<nav class="module-pagination" aria-label="角色列表分页">
|
|
3242
|
+
<button type="button" data-character-page="${characterPage.page - 1}" ${characterPage.page <= 1 ? "disabled" : ""}>上一页</button>
|
|
3243
|
+
<span>第 ${characterPage.page} 页 · 本页 ${state.characters.length} 个角色</span>
|
|
3244
|
+
<button type="button" data-character-page="${characterPage.nextPage ?? characterPage.page + 1}" ${characterPage.hasMore ? "" : "disabled"}>下一页</button>
|
|
3245
|
+
</nav>`
|
|
3246
|
+
: "";
|
|
2939
3247
|
if (state.characters.length) mountModuleLayoutToggle(layout, "角色列表样式");
|
|
2940
3248
|
$("#module-content").innerHTML = auditPanel + (state.characters.length
|
|
2941
|
-
? `${layout === "rows" ? characterRows() : characterCards()}`
|
|
3249
|
+
? `${layout === "rows" ? characterRows() : characterCards()}${pagination}`
|
|
2942
3250
|
: emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
|
|
2943
3251
|
bindModuleLayoutToggle(renderCharacters);
|
|
3252
|
+
$("#module-content").querySelectorAll("[data-character-page]").forEach((button) => button.addEventListener("click", async () => {
|
|
3253
|
+
if (button.disabled) return;
|
|
3254
|
+
$("#module-content").querySelectorAll("[data-character-page]").forEach((control) => { control.disabled = true; });
|
|
3255
|
+
await renderCharacters(Number(button.dataset.characterPage));
|
|
3256
|
+
}));
|
|
2944
3257
|
$("#create-character-audit-task")?.addEventListener("click", async () => {
|
|
2945
3258
|
const button = $("#create-character-audit-task");
|
|
2946
3259
|
button.disabled = true;
|
|
@@ -2953,15 +3266,7 @@ async function renderCharacters() {
|
|
|
2953
3266
|
button.disabled = false;
|
|
2954
3267
|
}
|
|
2955
3268
|
});
|
|
2956
|
-
|
|
2957
|
-
const open = () => openCharacterEditor(state.characters.find((item) => item.id === card.dataset.openCharacter));
|
|
2958
|
-
card.addEventListener("click", (event) => { if (!event.target.closest("button")) open(); });
|
|
2959
|
-
card.addEventListener("keydown", (event) => {
|
|
2960
|
-
if (event.key !== "Enter" && event.key !== " ") return;
|
|
2961
|
-
event.preventDefault();
|
|
2962
|
-
open();
|
|
2963
|
-
});
|
|
2964
|
-
});
|
|
3269
|
+
bindRecordPreview("[data-open-character]", (id) => openCharacterEditor(state.characters.find((item) => item.id === id), { readOnly: true }));
|
|
2965
3270
|
$("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterEditor(state.characters.find((item) => item.id === button.dataset.editCharacter))));
|
|
2966
3271
|
}
|
|
2967
3272
|
|
|
@@ -2981,7 +3286,7 @@ async function renderRaces() {
|
|
|
2981
3286
|
const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
|
|
2982
3287
|
<summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
|
|
2983
3288
|
<div class="race-tree-branch">
|
|
2984
|
-
<article class="record-card race-card${canEditRaces ? " has-card-edit" : ""}"><small>${item.memberIds.length} 位直接角色 · ${item.settings.length} 条自身设定</small>
|
|
3289
|
+
<article class="record-card race-card preview-record-card${canEditRaces ? " has-card-edit" : ""}" data-open-race="${esc(item.id)}" role="button" tabindex="0" aria-label="查看种族 ${esc(item.name)}"><small>${item.memberIds.length} 位直接角色 · ${item.settings.length} 条自身设定</small>
|
|
2985
3290
|
<div class="race-path" aria-label="种族路径">${esc(racePathLabel(item))}</div>
|
|
2986
3291
|
<p>${esc(item.description || "尚未填写种族简介")}</p>
|
|
2987
3292
|
<div class="race-settings">${item.effectiveSettings.length ? item.effectiveSettings.map((setting) => `<section class="knowledge-markdown-block${setting.inherited ? " inherited" : ""}"><div class="knowledge-markdown-block-heading"><h4>${esc(setting.title || "未命名章节")}</h4><small>${esc(setting.inherited ? `继承自 ${setting.sourceRaceName}` : `定义于 ${setting.sourceRaceName}`)}</small></div><div class="message-body">${renderMarkdown(setting.value) || '<p class="markdown-editor-empty">暂无内容</p>'}</div></section>`).join("") : '<span class="pill">暂无共同设定</span>'}</div>
|
|
@@ -2995,7 +3300,7 @@ async function renderRaces() {
|
|
|
2995
3300
|
const preview = moduleRowPreview(item.description || "尚未填写种族简介");
|
|
2996
3301
|
const meta = `${item.memberIds.length} 位直接角色 · ${item.settings.length ? "已填写共同设定" : "暂无共同设定"}`;
|
|
2997
3302
|
return `
|
|
2998
|
-
<article class="record-card module-row race-card">
|
|
3303
|
+
<article class="record-card module-row race-card preview-record-card" data-open-race="${esc(item.id)}" role="button" tabindex="0" aria-label="查看种族 ${esc(item.name)}">
|
|
2999
3304
|
<small>${esc(meta)}</small>
|
|
3000
3305
|
<h3>${esc(item.name)}<span class="module-row-path">${esc(racePathLabel(item))}</span></h3>
|
|
3001
3306
|
<p class="module-row-preview" title="${esc(preview)}">${esc(preview)}${item.members.length ? ` · ${esc(item.members.map((member) => member.name).join("、"))}` : ""}</p>
|
|
@@ -3007,6 +3312,7 @@ async function renderRaces() {
|
|
|
3007
3312
|
? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>`}`
|
|
3008
3313
|
: emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
|
|
3009
3314
|
bindModuleLayoutToggle(renderRaces);
|
|
3315
|
+
bindRecordPreview("[data-open-race]", (id) => openRaceDialog(state.races.find((item) => item.id === id), { readOnly: true }));
|
|
3010
3316
|
$("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => openRaceDialog(state.races.find((item) => item.id === button.dataset.editRace))));
|
|
3011
3317
|
bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
|
|
3012
3318
|
}
|
|
@@ -3083,7 +3389,7 @@ async function renderTimeline() {
|
|
|
3083
3389
|
$("#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>`);
|
|
3084
3390
|
state.timelineTracks = tracks;
|
|
3085
3391
|
const lanes = [...tracks, { id: "", name: "未分组时间轴", description: "尚未归入独立大事件的时间节点。", sortOrder: Number.MAX_SAFE_INTEGER }];
|
|
3086
|
-
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(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>`;
|
|
3392
|
+
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>`;
|
|
3087
3393
|
$("#module-content").innerHTML = `<div class="timeline-kanban" data-testid="timeline-kanban">${lanes.map((track) => {
|
|
3088
3394
|
const laneEvents = events.filter((item) => (item.trackId ?? "") === track.id);
|
|
3089
3395
|
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>`;
|
|
@@ -3124,19 +3430,19 @@ async function renderOutlines() {
|
|
|
3124
3430
|
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>`;
|
|
3125
3431
|
const foreshadowCards = () => `<div class="card-grid foreshadow-grid">${foreshadows.map((item) => `
|
|
3126
3432
|
<article class="record-card foreshadow-card ${item.overdue ? "is-overdue" : ""}">
|
|
3127
|
-
<small>${esc(item.importance)} · ${esc(item.status)}${item.overdue ? " · 已逾期" : ""}</small>
|
|
3433
|
+
<small>${esc(levelLabel(item.importance))} · ${esc(foreshadowStatusLabel(item.status))}${item.overdue ? " · 已逾期" : ""}</small>
|
|
3128
3434
|
<h3>${esc(item.title)}</h3><p>${esc(item.description || "暂无说明")}</p>
|
|
3129
|
-
<div class="foreshadow-links">${item.occurrences.length ? item.occurrences.map((link) => `<span class="pill">${esc(
|
|
3435
|
+
<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>
|
|
3130
3436
|
<div class="card-actions">${foreshadowActions(item)}</div>
|
|
3131
3437
|
</article>`).join("")}</div>`;
|
|
3132
3438
|
const foreshadowRows = () => `<div class="module-row-list">${foreshadows.map((item) => {
|
|
3133
3439
|
const preview = moduleRowPreview(item.description || "暂无说明");
|
|
3134
3440
|
const links = item.occurrences.length
|
|
3135
|
-
? item.occurrences.map((link) => `${(
|
|
3441
|
+
? item.occurrences.map((link) => `${occurrenceRoleLabel(link.role)} · ${link.volumeTitle} / ${link.chapterTitle}`).join(";")
|
|
3136
3442
|
: "尚未关联章节";
|
|
3137
3443
|
return `
|
|
3138
3444
|
<article class="record-card module-row foreshadow-card ${item.overdue ? "is-overdue" : ""}">
|
|
3139
|
-
<small>${esc(item.importance)} · ${esc(item.status)}${item.overdue ? " · 已逾期" : ""}</small>
|
|
3445
|
+
<small>${esc(levelLabel(item.importance))} · ${esc(foreshadowStatusLabel(item.status))}${item.overdue ? " · 已逾期" : ""}</small>
|
|
3140
3446
|
<h3>${esc(item.title)}</h3>
|
|
3141
3447
|
<p class="module-row-preview" title="${esc(`${preview} · ${links}`)}">${esc(preview)} · ${esc(links)}</p>
|
|
3142
3448
|
<div class="card-actions">${foreshadowActions(item)}</div>
|
|
@@ -3148,7 +3454,7 @@ async function renderOutlines() {
|
|
|
3148
3454
|
if (foreshadows.length) mountModuleLayoutToggle(layout, "伏笔列表样式");
|
|
3149
3455
|
const outlineHtml = outlines.length ? `<div class="outline-list">${outlines.map((item) => `
|
|
3150
3456
|
<article class="outline-row ${item.status === "completed" ? "is-complete" : ""}">
|
|
3151
|
-
<div><small>${esc(item.volumeTitle)} · ${esc(item.status)}</small><h3>${esc(item.chapterTitle)}</h3></div>
|
|
3457
|
+
<div><small>${esc(item.volumeTitle)} · ${esc(outlineStatusLabel(item.status))}</small><h3>${esc(item.chapterTitle)}</h3></div>
|
|
3152
3458
|
<div><b>目标</b><p>${esc(item.goal || "未填写")}</p></div>
|
|
3153
3459
|
<div><b>冲突</b><p>${esc(item.conflict || "未填写")}</p></div>
|
|
3154
3460
|
<div><b>转折</b><p>${esc(item.turningPoint || "未填写")}</p></div>
|
|
@@ -3172,7 +3478,7 @@ async function renderRelationships() {
|
|
|
3172
3478
|
state.relationshipGraph = graph;
|
|
3173
3479
|
$("#module-content").innerHTML = `<div id="relationship-map-host"></div>${relationships.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>${relationships.map((item) => `
|
|
3174
3480
|
<tr><td>${esc(nameOf(item.fromCharacterId))} ${item.directed ? "→" : "—"} ${esc(nameOf(item.toCharacterId))}</td>
|
|
3175
|
-
<td>${esc(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(item.confirmationStatus)}</td><td class="relationship-actions"><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>` : '<div class="relationship-empty-note">尚无关系边;孤立角色仍显示在力导向图谱中。可人工新建关系,或运行全书人物关系分析。</div>'}`;
|
|
3481
|
+
<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"><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>` : '<div class="relationship-empty-note">尚无关系边;孤立角色仍显示在力导向图谱中。可人工新建关系,或运行全书人物关系分析。</div>'}`;
|
|
3176
3482
|
const openGalaxy = () => {
|
|
3177
3483
|
state.galaxy?.destroy();
|
|
3178
3484
|
state.galaxy = createGalaxyRenderer($("#relationship-galaxy-dialog"), graph, { workId: state.work.id });
|
|
@@ -3216,11 +3522,11 @@ async function renderReviews() {
|
|
|
3216
3522
|
const actions = mergeActions || keepSeparateAction
|
|
3217
3523
|
? `<div class="card-actions character-duplicate-actions">${mergeActions}${keepSeparateAction}</div>`
|
|
3218
3524
|
: "";
|
|
3219
|
-
return `<article class="record-card character-duplicate-review"><small>角色查重 · ${esc(item.severity)} · ${esc(item.status)}</small><h3>${esc(item.title)}</h3><div class="character-duplicate-pair">${sideHtml}</div><p>${esc(item.description)}${item.suggestion ? `\n建议:${esc(item.suggestion)}` : ""}</p>${evidenceHtml ? `<ul class="character-duplicate-evidence">${evidenceHtml}</ul>` : ""}${actions}${item.resolutionNote ? `<p class="review-resolution-note">处理结果:${esc(item.resolutionNote)}</p>` : ""}</article>`;
|
|
3525
|
+
return `<article class="record-card character-duplicate-review preview-record-card" data-open-review="${esc(item.id)}" role="button" tabindex="0" aria-label="查看审核建议 ${esc(item.title)}"><small>角色查重 · ${esc(reviewSeverityLabel(item.severity))} · ${esc(reviewStatusLabel(item.status))}</small><h3>${esc(item.title)}</h3><div class="character-duplicate-pair">${sideHtml}</div><p>${esc(item.description)}${item.suggestion ? `\n建议:${esc(item.suggestion)}` : ""}</p>${evidenceHtml ? `<ul class="character-duplicate-evidence">${evidenceHtml}</ul>` : ""}${actions}${item.resolutionNote ? `<p class="review-resolution-note">处理结果:${esc(item.resolutionNote)}</p>` : ""}</article>`;
|
|
3220
3526
|
};
|
|
3221
3527
|
const layout = readModuleLayout();
|
|
3222
3528
|
const reviewCard = (item) => item.itemType === "character-duplicate" ? duplicateCard(item) : `
|
|
3223
|
-
<article class="record-card"><small>${esc(item.itemType)} · ${esc(item.severity)} · ${esc(item.status)}</small><h3>${esc(item.title)}</h3>
|
|
3529
|
+
<article class="record-card preview-record-card" data-open-review="${esc(item.id)}" role="button" tabindex="0" aria-label="查看审核建议 ${esc(item.title)}"><small>${esc(reviewItemTypeLabel(item.itemType))} · ${esc(reviewSeverityLabel(item.severity))} · ${esc(reviewStatusLabel(item.status))}</small><h3>${esc(item.title)}</h3>
|
|
3224
3530
|
<p>${esc(item.description)}${item.suggestion ? `\n建议:${esc(item.suggestion)}` : ""}</p>
|
|
3225
3531
|
${item.status === "pending" && canResolveReview ? `<div class="card-actions"><button data-review-status="fixed" data-review-id="${esc(item.id)}">标为已修复</button><button data-review-status="ignored" data-review-id="${esc(item.id)}">忽略</button></div>` : ""}</article>`;
|
|
3226
3532
|
const reviewRow = (item) => {
|
|
@@ -3229,8 +3535,8 @@ async function renderReviews() {
|
|
|
3229
3535
|
}
|
|
3230
3536
|
const preview = moduleRowPreview(`${item.description || ""}${item.suggestion ? ` 建议:${item.suggestion}` : ""}`);
|
|
3231
3537
|
return `
|
|
3232
|
-
<article class="record-card module-row">
|
|
3233
|
-
<small>${esc(item.itemType)} · ${esc(item.severity)} · ${esc(item.status)}</small>
|
|
3538
|
+
<article class="record-card module-row preview-record-card" data-open-review="${esc(item.id)}" role="button" tabindex="0" aria-label="查看审核建议 ${esc(item.title)}">
|
|
3539
|
+
<small>${esc(reviewItemTypeLabel(item.itemType))} · ${esc(reviewSeverityLabel(item.severity))} · ${esc(reviewStatusLabel(item.status))}</small>
|
|
3234
3540
|
<h3>${esc(item.title)}</h3>
|
|
3235
3541
|
<p class="module-row-preview" title="${esc(preview)}">${esc(preview)}</p>
|
|
3236
3542
|
<div class="card-actions">${item.status === "pending" && canResolveReview ? `<button data-review-status="fixed" data-review-id="${esc(item.id)}">标为已修复</button><button data-review-status="ignored" data-review-id="${esc(item.id)}">忽略</button>` : ""}</div>
|
|
@@ -3241,6 +3547,7 @@ async function renderReviews() {
|
|
|
3241
3547
|
? `${layout === "rows" ? `<div class="module-row-list">${reviews.map(reviewRow).join("")}</div>` : `<div class="card-grid">${reviews.map(reviewCard).join("")}</div>`}`
|
|
3242
3548
|
: emptyModule("没有待审核事项", "候选设定、冲突与低置信度结论会集中显示在这里。");
|
|
3243
3549
|
bindModuleLayoutToggle(renderReviews);
|
|
3550
|
+
bindRecordPreview("[data-open-review]", (id) => openReviewDetailDialog(reviews.find((item) => item.id === id)));
|
|
3244
3551
|
$("#module-content").querySelectorAll("[data-review-id]").forEach((button) => button.addEventListener("click", async () => {
|
|
3245
3552
|
await api(`/api/reviews/${button.dataset.reviewId}`, { method: "PATCH", body: { status: button.dataset.reviewStatus } });
|
|
3246
3553
|
await renderReviews();
|
|
@@ -3310,8 +3617,8 @@ async function renderTasks() {
|
|
|
3310
3617
|
</section>
|
|
3311
3618
|
${tasks.length ? `<table class="table-list task-table"><thead><tr><th>分析类型</th><th>范围</th><th>状态</th><th>进度</th><th>操作</th></tr></thead><tbody>${tasks.map((item) => `
|
|
3312
3619
|
<tr>
|
|
3313
|
-
<td>${esc(analysisTaskTypeLabel(item.taskType))}
|
|
3314
|
-
<td>${esc(item.scopeSummary || item.scope?.type || "book")}</td>
|
|
3620
|
+
<td>${esc(analysisTaskTypeLabel(item.taskType))}</td>
|
|
3621
|
+
<td>${esc(item.scopeSummary || taskScopeLabel(item.scope?.type || "book"))}</td>
|
|
3315
3622
|
<td>${esc(analysisTaskStatusLabel(item.status))}</td>
|
|
3316
3623
|
<td>${Number(item.progress ?? 0)}%</td>
|
|
3317
3624
|
<td class="task-row-actions">
|
|
@@ -3419,7 +3726,7 @@ function openTaskDetailDialog(task) {
|
|
|
3419
3726
|
openDialog("任务详情",
|
|
3420
3727
|
`<div class="task-detail">
|
|
3421
3728
|
<p><strong>任务 ID</strong><br><code>${esc(task.id)}</code></p>
|
|
3422
|
-
<p><strong>类型</strong> ${esc(analysisTaskTypeLabel(task.taskType))}
|
|
3729
|
+
<p><strong>类型</strong> ${esc(analysisTaskTypeLabel(task.taskType))}</p>
|
|
3423
3730
|
<p><strong>状态</strong> ${esc(analysisTaskStatusLabel(task.status))} · 进度 ${Number(task.progress ?? 0)}%</p>
|
|
3424
3731
|
<p><strong>范围摘要</strong> ${esc(task.scopeSummary || "未指定")}</p>
|
|
3425
3732
|
<div><strong>范围详情</strong><ul>${detailHtml}</ul></div>
|
|
@@ -3434,11 +3741,11 @@ function openTaskDetailDialog(task) {
|
|
|
3434
3741
|
|
|
3435
3742
|
function renderProviderCards(providers, models) {
|
|
3436
3743
|
return providers.length ? `<div class="card-grid provider-card-grid">${providers.map((provider) => `
|
|
3437
|
-
<article class="record-card provider-card"><small>平台级 · ${esc(provider.status)} · ${esc(provider.connectionStatus)}</small><h3>${esc(provider.name)}</h3>
|
|
3438
|
-
<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n并发:${provider.concurrencyLimit} ·
|
|
3439
|
-
<div class="provider-models">${models.filter((model) => model.providerId === provider.id).map((model) => `<button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"} ·
|
|
3744
|
+
<article class="record-card provider-card"><small>平台级 · ${esc(providerStatusLabel(provider.status))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><h3>${esc(provider.name)}</h3>
|
|
3745
|
+
<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit} · 最大输出:${provider.maxTokens ?? 32000}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
|
|
3746
|
+
<div class="provider-models">${models.filter((model) => model.providerId === provider.id).map((model) => `<button class="pill model-pill" type="button" data-edit-model="${esc(model.id)}" aria-label="编辑模型 ${esc(model.displayName)}">${esc(model.displayName)} · ${model.enabled ? "启用" : "停用"} · 思考模式 ${model.thinkingEnabled ? "开启" : "关闭"} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} 令牌 · 最大输出 ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>`).join("")}</div>
|
|
3440
3747
|
<div class="card-actions"><button data-edit-provider="${esc(provider.id)}">编辑配置</button><button data-test-provider="${esc(provider.id)}">测试连接</button><button data-add-model="${esc(provider.id)}">添加模型</button></div></article>`).join("")}</div>`
|
|
3441
|
-
: emptyModule("尚未配置 AI 供应商", "添加 OpenAI
|
|
3748
|
+
: emptyModule("尚未配置 AI 供应商", "添加 OpenAI 兼容接口地址和密钥,测试成功后再添加模型。");
|
|
3442
3749
|
}
|
|
3443
3750
|
|
|
3444
3751
|
function bindPlatformProviderActions(host, providers, models) {
|
|
@@ -3459,10 +3766,10 @@ function renderTaskDefaults(models, providers, taskDefaults) {
|
|
|
3459
3766
|
const providerById = new Map(providers.map((provider) => [provider.id, provider]));
|
|
3460
3767
|
const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
|
|
3461
3768
|
return models.length ? `<section class="config-section">
|
|
3462
|
-
<div class="config-section-header"><div><h2>本书任务默认模型</h2><p
|
|
3769
|
+
<div class="config-section-header"><div><h2>本书任务默认模型</h2><p>选择平台模型作为当前作品的默认模型;所有请求都会携带最大输出令牌数,默认值为 32000。</p></div></div>
|
|
3463
3770
|
<table class="table-list"><thead><tr><th>任务能力</th><th>默认模型</th></tr></thead><tbody>${taskTypeLabels.map(([taskType, label]) => {
|
|
3464
3771
|
const currentModelId = defaultModelByTask.get(taskType) ?? "";
|
|
3465
|
-
return `<tr><td>${esc(label)}
|
|
3772
|
+
return `<tr><td>${esc(label)}</td><td><select class="default-model-select" data-task-default="${esc(taskType)}">
|
|
3466
3773
|
<option value="" disabled ${currentModelId ? "" : "selected"}>请选择模型</option>
|
|
3467
3774
|
${models.map((model) => {
|
|
3468
3775
|
const provider = providerById.get(model.providerId);
|
|
@@ -3830,7 +4137,7 @@ function renderKnowledgeMarkdownSections() {
|
|
|
3830
4137
|
const host = $("#knowledge-markdown-sections");
|
|
3831
4138
|
if (!host) return;
|
|
3832
4139
|
const label = knowledgeEditorKind === "race" ? "种族" : "组织";
|
|
3833
|
-
const canEdit = canEditModule(knowledgeEditorKind === "race" ? "races" : "organizations");
|
|
4140
|
+
const canEdit = !entityEditorReadOnly && canEditModule(knowledgeEditorKind === "race" ? "races" : "organizations");
|
|
3834
4141
|
const sections = Array.isArray(knowledgeEditorSections) ? knowledgeEditorSections : [];
|
|
3835
4142
|
host.innerHTML = `<div class="knowledge-markdown-list-toolbar"><div><b>${label} Markdown 设定</b><span>将每条设定单独保存为章节,需要编辑时打开大编辑器。</span></div>${canEdit ? '<button type="button" class="ghost-button" data-knowledge-section-create>新建设定</button>' : ""}</div>${sections.length ? `<div class="knowledge-markdown-list">${sections.map((section, index) => `<article class="knowledge-markdown-section" data-knowledge-section-index="${index}"><header><div><span>设定 ${index + 1}</span><h4>${esc(section.title || `未命名设定 ${index + 1}`)}</h4>${section.summary ? `<p>${esc(section.summary)}</p>` : ""}</div><div>${canEdit ? `<button type="button" data-knowledge-section-edit="${index}">编辑</button><button type="button" data-knowledge-section-delete="${index}">删除</button>` : ""}</div></header><p class="knowledge-section-card-preview">${esc(knowledgeSectionPreviewText(section))}</p></article>`).join("")}</div>` : '<p class="knowledge-markdown-empty">还没有 Markdown 设定,点击“新建设定”开始记录。</p>'}`;
|
|
3836
4143
|
host.querySelector("[data-knowledge-section-create]")?.addEventListener("click", () => void openKnowledgeSectionEditor());
|
|
@@ -3934,8 +4241,11 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
|
|
|
3934
4241
|
void discardPendingMarkdownAttachments();
|
|
3935
4242
|
$("#dialog-title").textContent = title;
|
|
3936
4243
|
$("#dialog-eyebrow").textContent = eyebrow;
|
|
4244
|
+
$("#dialog-meta").textContent = options.meta ?? "";
|
|
4245
|
+
$("#dialog-meta").classList.toggle("hidden", !options.meta);
|
|
3937
4246
|
$("#dialog-fields").innerHTML = fields;
|
|
3938
4247
|
$("#dialog-submit").textContent = options.submitLabel ?? "保存";
|
|
4248
|
+
$("#dynamic-form .dialog-actions [value='cancel']").classList.toggle("hidden", Boolean(options.hideCancel));
|
|
3939
4249
|
$("#form-dialog").classList.toggle("wide-dialog", Boolean(options.wide));
|
|
3940
4250
|
bindDynamicListControls($("#dialog-fields"));
|
|
3941
4251
|
bindRelationshipKeywordControls($("#dialog-fields"));
|
|
@@ -4111,27 +4421,32 @@ function openVolumeDialog(item) {
|
|
|
4111
4421
|
}, "分卷设置");
|
|
4112
4422
|
}
|
|
4113
4423
|
|
|
4114
|
-
function openSettingEditor(item = null) {
|
|
4424
|
+
function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
4425
|
+
entityEditorReadOnly = readOnly;
|
|
4115
4426
|
destroyVditorEditor(settingEditorVditor);
|
|
4116
4427
|
settingEditorVditor = null;
|
|
4117
4428
|
settingEditorItem = item;
|
|
4118
|
-
$("#setting-editor-eyebrow").textContent = item ? "
|
|
4429
|
+
$("#setting-editor-eyebrow").textContent = item ? "编辑设定" : "新建设定";
|
|
4119
4430
|
$("#setting-editor-name").value = item?.title ?? "";
|
|
4120
4431
|
$("#setting-editor-category").value = item?.category ?? "世界规则";
|
|
4121
4432
|
$("#setting-editor-locked").checked = Boolean(item?.locked);
|
|
4122
4433
|
$("#setting-editor-body").value = item?.content ?? "";
|
|
4123
|
-
$("#setting-change-note").value = "";
|
|
4124
|
-
$("#setting-change-note-field").classList.toggle("hidden", !item);
|
|
4125
4434
|
$("#setting-editor-submit").textContent = item ? "保存新版本" : "创建设定";
|
|
4126
|
-
const viewOnly = !canEditModule("settings");
|
|
4435
|
+
const viewOnly = readOnly || !canEditModule("settings");
|
|
4436
|
+
$("#setting-editor-form").classList.toggle("is-read-only", viewOnly);
|
|
4437
|
+
$("#setting-editor-eyebrow").textContent = readOnly ? "阅读设定" : item ? "编辑设定" : "新建设定";
|
|
4127
4438
|
$("#setting-editor-form").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = viewOnly; });
|
|
4128
4439
|
$("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
|
|
4129
4440
|
$("#setting-editor-submit").classList.toggle("hidden", viewOnly);
|
|
4130
|
-
const
|
|
4131
|
-
|
|
4441
|
+
const editButton = $("#setting-editor-edit");
|
|
4442
|
+
editButton.classList.toggle("hidden", !readOnly || !canEditModule("settings"));
|
|
4443
|
+
editButton.onclick = () => openSettingEditor(item);
|
|
4444
|
+
const showManagementActions = Boolean(item && !viewOnly);
|
|
4132
4445
|
const statusButtons = [$("#setting-editor-confirm"), $("#setting-editor-deprecate")];
|
|
4133
|
-
$("#setting-editor-confirm").classList.toggle("hidden", item?.status !== "pending");
|
|
4134
|
-
$("#setting-editor-deprecate").classList.toggle("hidden", item?.status !== "pending");
|
|
4446
|
+
$("#setting-editor-confirm").classList.toggle("hidden", !showManagementActions || item?.status !== "pending");
|
|
4447
|
+
$("#setting-editor-deprecate").classList.toggle("hidden", !showManagementActions || item?.status !== "pending");
|
|
4448
|
+
$("#setting-editor-history").classList.toggle("hidden", !showManagementActions);
|
|
4449
|
+
$("#setting-editor-delete").classList.toggle("hidden", !showManagementActions);
|
|
4135
4450
|
$("#setting-editor-history").onclick = async () => {
|
|
4136
4451
|
if (!item) return;
|
|
4137
4452
|
if (!(await closeEntityEditor())) return;
|
|
@@ -4166,7 +4481,7 @@ function openSettingEditor(item = null) {
|
|
|
4166
4481
|
});
|
|
4167
4482
|
$("#setting-editor-form").onsubmit = async (event) => {
|
|
4168
4483
|
event.preventDefault();
|
|
4169
|
-
if (!canEditModule("settings")) return;
|
|
4484
|
+
if (readOnly || !canEditModule("settings")) return;
|
|
4170
4485
|
const form = new FormData(event.currentTarget);
|
|
4171
4486
|
const submit = $("#setting-editor-submit");
|
|
4172
4487
|
submit.disabled = true;
|
|
@@ -4183,6 +4498,13 @@ function openSettingEditor(item = null) {
|
|
|
4183
4498
|
$("#setting-editor-body").focus();
|
|
4184
4499
|
return;
|
|
4185
4500
|
}
|
|
4501
|
+
const changeNote = item ? await inputToast("简要说明这次修改,便于以后查看版本历史。可以留空。", {
|
|
4502
|
+
title: "填写版本说明",
|
|
4503
|
+
inputLabel: "版本说明",
|
|
4504
|
+
placeholder: "例如:补充纪元历法限制",
|
|
4505
|
+
confirmLabel: "保存新版本"
|
|
4506
|
+
}) : "";
|
|
4507
|
+
if (changeNote === null) return;
|
|
4186
4508
|
const locked = form.get("locked") === "on";
|
|
4187
4509
|
const body = {
|
|
4188
4510
|
title,
|
|
@@ -4190,8 +4512,9 @@ function openSettingEditor(item = null) {
|
|
|
4190
4512
|
content,
|
|
4191
4513
|
locked,
|
|
4192
4514
|
status: locked ? "confirmed" : (item?.status ?? "draft"),
|
|
4193
|
-
...(item ? { changeNote
|
|
4515
|
+
...(item ? { changeNote } : {})
|
|
4194
4516
|
};
|
|
4517
|
+
if (!(await confirmConcurrentSave())) return;
|
|
4195
4518
|
await api(item ? `/api/settings/${item.id}` : `/api/works/${state.work.id}/settings`, { method: item ? "PATCH" : "POST", body });
|
|
4196
4519
|
await cleanupPendingMarkdownAttachments(body.content);
|
|
4197
4520
|
entityEditorDirty = false;
|
|
@@ -4204,12 +4527,12 @@ function openSettingEditor(item = null) {
|
|
|
4204
4527
|
submit.disabled = false;
|
|
4205
4528
|
}
|
|
4206
4529
|
};
|
|
4207
|
-
showEntityEditorPage("setting");
|
|
4530
|
+
showEntityEditorPage("setting", { readOnly });
|
|
4208
4531
|
settingEditorVditor = createVditorEditor($("#setting-editor-markdown"), item?.content ?? "", {
|
|
4209
4532
|
onInput: (markdown) => { $("#setting-editor-body").value = markdown; markEntityEditorDirty(); },
|
|
4210
4533
|
readOnly: viewOnly
|
|
4211
4534
|
});
|
|
4212
|
-
$("#setting-editor-name").focus();
|
|
4535
|
+
(readOnly ? $("#setting-editor-back") : $("#setting-editor-name")).focus();
|
|
4213
4536
|
}
|
|
4214
4537
|
|
|
4215
4538
|
function characterEditorSection(key, title, description, content) {
|
|
@@ -4236,14 +4559,6 @@ function setCharacterHistoryVisible(visible) {
|
|
|
4236
4559
|
$("#character-history-button").setAttribute("aria-expanded", String(visible));
|
|
4237
4560
|
}
|
|
4238
4561
|
|
|
4239
|
-
const relationshipCategoryLabels = {
|
|
4240
|
-
family: "亲属",
|
|
4241
|
-
social: "社交",
|
|
4242
|
-
emotional: "情感",
|
|
4243
|
-
conflict: "冲突",
|
|
4244
|
-
uncertain: "未确定"
|
|
4245
|
-
};
|
|
4246
|
-
|
|
4247
4562
|
function renderCharacterEditorRelationships() {
|
|
4248
4563
|
const host = $("#character-editor-relationships");
|
|
4249
4564
|
if (!host) return;
|
|
@@ -4261,15 +4576,15 @@ function renderCharacterEditorRelationships() {
|
|
|
4261
4576
|
const isSource = relationship.fromCharacterId === characterId;
|
|
4262
4577
|
const otherCharacterId = isSource ? relationship.toCharacterId : relationship.fromCharacterId;
|
|
4263
4578
|
const direction = relationship.directed ? (isSource ? "→" : "←") : "↔";
|
|
4264
|
-
const category =
|
|
4579
|
+
const category = relationshipCategoryLabel(relationship.category);
|
|
4265
4580
|
const relationLabel = [category, relationship.subtype].filter(Boolean).join(" · ") || "未细分";
|
|
4266
4581
|
const keywords = Array.isArray(relationship.keywords) ? relationship.keywords : [];
|
|
4267
4582
|
return `<article class="character-relationship-row">
|
|
4268
|
-
<div class="character-relationship-heading"><div><strong>${esc(nameOf(otherCharacterId))}</strong><span>${direction} ${esc(relationLabel)}</span></div>${canEditModule("relationships") ? `<button type="button" data-character-relationship-edit="${esc(relationship.id)}">编辑关系</button>` : ""}</div>
|
|
4583
|
+
<div class="character-relationship-heading"><div><strong>${esc(nameOf(otherCharacterId))}</strong><span>${direction} ${esc(relationLabel)}</span></div>${!entityEditorReadOnly && canEditModule("relationships") ? `<button type="button" data-character-relationship-edit="${esc(relationship.id)}">编辑关系</button>` : ""}</div>
|
|
4269
4584
|
<div class="character-relationship-keywords"><small>关系关键词</small><div>${keywords.map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || '<span class="character-relationship-empty-keywords">未填写关键词</span>'}</div></div>
|
|
4270
4585
|
</article>`;
|
|
4271
4586
|
}).join("");
|
|
4272
|
-
host.innerHTML = `<div class="character-relationship-toolbar"><p>与 ${esc(characterEditorItem.name)} 有关的其他人物及关系关键词。</p>${canEditModule("relationships") ? '<button type="button" class="ghost-button" data-character-relationship-create>新建关系</button>' : ""}</div>${rows || '<p class="character-relationship-status">暂未记录与其他人物的关系。</p>'}`;
|
|
4587
|
+
host.innerHTML = `<div class="character-relationship-toolbar"><p>与 ${esc(characterEditorItem.name)} 有关的其他人物及关系关键词。</p>${!entityEditorReadOnly && canEditModule("relationships") ? '<button type="button" class="ghost-button" data-character-relationship-create>新建关系</button>' : ""}</div>${rows || '<p class="character-relationship-status">暂未记录与其他人物的关系。</p>'}`;
|
|
4273
4588
|
host.querySelectorAll("[data-character-relationship-edit]").forEach((button) => button.addEventListener("click", () => {
|
|
4274
4589
|
const relationship = characterEditorRelationships.find((item) => item.id === button.dataset.characterRelationshipEdit);
|
|
4275
4590
|
if (relationship) void openRelationshipDialog(relationship, { characterId });
|
|
@@ -4424,7 +4739,6 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
|
|
|
4424
4739
|
attachmentObserver.observe(host, { subtree: true, childList: true, attributes: true, attributeFilter: ["src"] });
|
|
4425
4740
|
editor.__attachmentObserver = attachmentObserver;
|
|
4426
4741
|
host.__vditor = editor;
|
|
4427
|
-
if (readOnly) editor.disabled();
|
|
4428
4742
|
return editor;
|
|
4429
4743
|
}
|
|
4430
4744
|
|
|
@@ -4632,6 +4946,10 @@ async function openCharacterSectionEditor(section = null) {
|
|
|
4632
4946
|
button.disabled = true;
|
|
4633
4947
|
const contentMarkdown = characterSectionVditor?.getValue() ?? "";
|
|
4634
4948
|
try {
|
|
4949
|
+
if (!(await confirmConcurrentSave())) {
|
|
4950
|
+
button.disabled = false;
|
|
4951
|
+
return;
|
|
4952
|
+
}
|
|
4635
4953
|
const saved = await api(section ? `/api/character-sections/${section.id}` : `/api/characters/${characterEditorItem.id}/sections`, {
|
|
4636
4954
|
method: section ? "PATCH" : "POST",
|
|
4637
4955
|
body: {
|
|
@@ -4691,9 +5009,10 @@ function renderCharacterMarkdownSections() {
|
|
|
4691
5009
|
host.innerHTML = '<div class="character-editor-empty-field" role="note" aria-label="保存角色提示"><b>请先保存当前角色</b><span>完成基础资料后,请点击页面底部的“创建人物档案”。保存成功后即可新建 Markdown 档案章节。</span></div>';
|
|
4692
5010
|
return;
|
|
4693
5011
|
}
|
|
4694
|
-
const
|
|
5012
|
+
const canEdit = !entityEditorReadOnly && canEditModule("characters");
|
|
5013
|
+
const toolbar = `<div class="character-markdown-list-toolbar"><div><b>Markdown 档案章节</b><span>长篇内容独立保存、渲染、检索和版本管理。</span></div>${canEdit ? '<button type="button" class="primary-button" data-character-section-create>新建章节</button>' : ""}</div>`;
|
|
4695
5014
|
const sections = characterEditorSections.map((section) => `<article class="character-markdown-section">
|
|
4696
|
-
<header><div><span>${esc(characterSectionTypeLabels[section.sectionType] ?? section.sectionType)}</span><h4>${esc(section.title)}</h4>${section.summary ? `<p>${esc(section.summary)}</p>` : ""}</div><div>${
|
|
5015
|
+
<header><div><span>${esc(characterSectionTypeLabels[section.sectionType] ?? section.sectionType)}</span><h4>${esc(section.title)}</h4>${section.summary ? `<p>${esc(section.summary)}</p>` : ""}</div><div>${canEdit ? `<button type="button" data-character-section-edit="${esc(section.id)}">编辑</button>` : ""}<button type="button" data-character-section-versions="${esc(section.id)}">版本</button>${canEdit ? `<button type="button" data-character-section-delete="${esc(section.id)}">删除</button>` : ""}</div></header>
|
|
4697
5016
|
<div class="character-markdown-document message-body">${renderMarkdown(section.contentMarkdown) || '<p class="character-markdown-empty">本章节暂无正文。</p>'}</div>
|
|
4698
5017
|
<div data-character-section-versions-host="${esc(section.id)}"></div>
|
|
4699
5018
|
</article>`).join("");
|
|
@@ -4840,7 +5159,7 @@ function renderCharacterHistory() {
|
|
|
4840
5159
|
<div class="character-version-card-heading"><div><strong>v${version.versionNo}</strong><span>${esc(characterVersionSourceLabel(version.source))}</span></div><time>${esc(formatDateTime(version.createdAt))} · ${esc(version.actor || "历史数据")}</time></div>
|
|
4841
5160
|
<p>${esc(version.changeNote || "未填写版本说明")}</p>
|
|
4842
5161
|
<div class="character-version-changes">${changes.map((change) => `<span>${esc(change)}</span>`).join("")}</div>
|
|
4843
|
-
${isCurrent ? '<button type="button" disabled>当前版本</button>' : `<button type="button" data-character-restore="${version.versionNo}">回滚到此版本</button>`}
|
|
5162
|
+
${isCurrent ? '<button type="button" disabled>当前版本</button>' : entityEditorReadOnly ? '<button type="button" disabled>历史版本</button>' : `<button type="button" data-character-restore="${version.versionNo}">回滚到此版本</button>`}
|
|
4844
5163
|
</article>`;
|
|
4845
5164
|
}).join("");
|
|
4846
5165
|
host.querySelectorAll("[data-character-restore]").forEach((button) => button.addEventListener("click", async () => {
|
|
@@ -4893,7 +5212,8 @@ async function showCharacterHistory() {
|
|
|
4893
5212
|
}
|
|
4894
5213
|
}
|
|
4895
5214
|
|
|
4896
|
-
async function openCharacterEditor(item = null) {
|
|
5215
|
+
async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
5216
|
+
entityEditorReadOnly = readOnly;
|
|
4897
5217
|
[state.races, state.organizations] = await Promise.all([
|
|
4898
5218
|
canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
|
|
4899
5219
|
canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
|
|
@@ -4912,7 +5232,7 @@ async function openCharacterEditor(item = null) {
|
|
|
4912
5232
|
$("#character-history-button").title = item ? "查看、比较和回滚历史版本" : "创建人物档案后即可查看版本历史";
|
|
4913
5233
|
const characterMergeButton = $("#character-merge-button");
|
|
4914
5234
|
const characterDeleteButton = $("#character-delete-button");
|
|
4915
|
-
const canManageCharacter = Boolean(item && canEditModule("characters"));
|
|
5235
|
+
const canManageCharacter = Boolean(item && !readOnly && canEditModule("characters"));
|
|
4916
5236
|
characterMergeButton.classList.toggle("hidden", !canManageCharacter || state.characters.length < 2);
|
|
4917
5237
|
characterDeleteButton.classList.toggle("hidden", !canManageCharacter);
|
|
4918
5238
|
characterMergeButton.onclick = async () => {
|
|
@@ -4940,14 +5260,18 @@ async function openCharacterEditor(item = null) {
|
|
|
4940
5260
|
};
|
|
4941
5261
|
setCharacterHistoryVisible(false);
|
|
4942
5262
|
renderCharacterEditorFields(item);
|
|
4943
|
-
const viewOnly = !canEditModule("characters");
|
|
5263
|
+
const viewOnly = readOnly || !canEditModule("characters");
|
|
4944
5264
|
if (viewOnly) {
|
|
4945
|
-
$("#character-editor-eyebrow").textContent = "人物档案";
|
|
5265
|
+
$("#character-editor-eyebrow").textContent = readOnly ? "阅读人物档案" : "人物档案";
|
|
4946
5266
|
$("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
|
|
4947
5267
|
$("#character-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
|
|
5268
|
+
$("#character-editor-fields").querySelectorAll("button").forEach((button) => { button.disabled = true; });
|
|
4948
5269
|
}
|
|
4949
5270
|
$("#character-change-note").readOnly = viewOnly;
|
|
4950
5271
|
$("#character-editor-submit").classList.toggle("hidden", viewOnly);
|
|
5272
|
+
const editButton = $("#character-editor-edit");
|
|
5273
|
+
editButton.classList.toggle("hidden", !readOnly || !canEditModule("characters"));
|
|
5274
|
+
editButton.onclick = () => void openCharacterEditor(item);
|
|
4951
5275
|
document.querySelectorAll("[data-character-editor-tab]").forEach((button) => {
|
|
4952
5276
|
button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
|
|
4953
5277
|
});
|
|
@@ -4957,7 +5281,7 @@ async function openCharacterEditor(item = null) {
|
|
|
4957
5281
|
const form = $("#character-editor-form");
|
|
4958
5282
|
form.onsubmit = async (event) => {
|
|
4959
5283
|
event.preventDefault();
|
|
4960
|
-
if (!canEditModule("characters")) return;
|
|
5284
|
+
if (readOnly || !canEditModule("characters")) return;
|
|
4961
5285
|
const submit = $("#character-editor-submit");
|
|
4962
5286
|
submit.disabled = true;
|
|
4963
5287
|
try {
|
|
@@ -4966,6 +5290,7 @@ async function openCharacterEditor(item = null) {
|
|
|
4966
5290
|
const wasEditing = Boolean(characterEditorItem);
|
|
4967
5291
|
const previousVersion = characterEditorItem?.versionNo;
|
|
4968
5292
|
if (!wasEditing) delete body.changeNote;
|
|
5293
|
+
if (!(await confirmConcurrentSave())) return;
|
|
4969
5294
|
const saved = await api(wasEditing ? `/api/characters/${characterEditorItem.id}` : `/api/works/${state.work.id}/characters`, { method: wasEditing ? "PATCH" : "POST", body });
|
|
4970
5295
|
entityEditorDirty = false;
|
|
4971
5296
|
await loadAiReferences();
|
|
@@ -4977,7 +5302,7 @@ async function openCharacterEditor(item = null) {
|
|
|
4977
5302
|
submit.disabled = false;
|
|
4978
5303
|
}
|
|
4979
5304
|
};
|
|
4980
|
-
showEntityEditorPage("character");
|
|
5305
|
+
showEntityEditorPage("character", { readOnly });
|
|
4981
5306
|
if (item) {
|
|
4982
5307
|
if (canReadModule("relationships")) void loadCharacterEditorRelationships(item.id);
|
|
4983
5308
|
void loadCharacterMarkdownSections(item.id);
|
|
@@ -5019,7 +5344,8 @@ function renderKnowledgeEditorFields(kind, item, memberOptions, parentOptions) {
|
|
|
5019
5344
|
activateKnowledgeEditorTab("basic");
|
|
5020
5345
|
}
|
|
5021
5346
|
|
|
5022
|
-
async function openKnowledgeEditor(kind, item) {
|
|
5347
|
+
async function openKnowledgeEditor(kind, item, { readOnly = false } = {}) {
|
|
5348
|
+
entityEditorReadOnly = readOnly;
|
|
5023
5349
|
await discardPendingMarkdownAttachments();
|
|
5024
5350
|
state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
|
|
5025
5351
|
const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
|
|
@@ -5049,8 +5375,8 @@ async function openKnowledgeEditor(kind, item) {
|
|
|
5049
5375
|
const candidates = isRace ? state.races : state.organizations;
|
|
5050
5376
|
const typeLabel = label;
|
|
5051
5377
|
historyButton.classList.toggle("hidden", !item);
|
|
5052
|
-
mergeButton.classList.toggle("hidden", !item || !canEditModule(module) || candidates.length < 2);
|
|
5053
|
-
deleteButton.classList.toggle("hidden", !item || !canEditModule(module));
|
|
5378
|
+
mergeButton.classList.toggle("hidden", !item || readOnly || !canEditModule(module) || candidates.length < 2);
|
|
5379
|
+
deleteButton.classList.toggle("hidden", !item || readOnly || !canEditModule(module));
|
|
5054
5380
|
historyButton.onclick = async () => {
|
|
5055
5381
|
if (!item) return;
|
|
5056
5382
|
if (!(await closeEntityEditor())) return;
|
|
@@ -5080,17 +5406,22 @@ async function openKnowledgeEditor(kind, item) {
|
|
|
5080
5406
|
});
|
|
5081
5407
|
};
|
|
5082
5408
|
renderKnowledgeEditorFields(kind, item, memberOptions, parentOptions);
|
|
5083
|
-
const viewOnly = !canEditModule(module);
|
|
5409
|
+
const viewOnly = readOnly || !canEditModule(module);
|
|
5084
5410
|
if (viewOnly) {
|
|
5085
|
-
$("#knowledge-editor-eyebrow").textContent = `${label}档案`;
|
|
5411
|
+
$("#knowledge-editor-eyebrow").textContent = readOnly ? `阅读${label}档案` : `${label}档案`;
|
|
5086
5412
|
$("#knowledge-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
|
|
5087
5413
|
$("#knowledge-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
|
|
5414
|
+
$("#knowledge-editor-fields").querySelectorAll("button").forEach((button) => { button.disabled = true; });
|
|
5088
5415
|
}
|
|
5089
5416
|
$("#knowledge-editor-submit").classList.toggle("hidden", viewOnly);
|
|
5417
|
+
const editButton = $("#knowledge-editor-edit");
|
|
5418
|
+
editButton.textContent = `编辑${label}`;
|
|
5419
|
+
editButton.classList.toggle("hidden", !readOnly || !canEditModule(module));
|
|
5420
|
+
editButton.onclick = () => void openKnowledgeEditor(kind, item);
|
|
5090
5421
|
const form = $("#knowledge-editor-form");
|
|
5091
5422
|
form.onsubmit = async (event) => {
|
|
5092
5423
|
event.preventDefault();
|
|
5093
|
-
if (!canEditModule(module)) return;
|
|
5424
|
+
if (readOnly || !canEditModule(module)) return;
|
|
5094
5425
|
const submit = $("#knowledge-editor-submit");
|
|
5095
5426
|
submit.disabled = true;
|
|
5096
5427
|
try {
|
|
@@ -5108,6 +5439,7 @@ async function openKnowledgeEditor(kind, item) {
|
|
|
5108
5439
|
: { name, description: data.get("description"), settingsMarkdown, settingsSections };
|
|
5109
5440
|
if (canReadModule("characters")) body.memberIds = data.getAll("memberIds").map(String);
|
|
5110
5441
|
const wasEditing = Boolean(knowledgeEditorItem);
|
|
5442
|
+
if (!(await confirmConcurrentSave())) return;
|
|
5111
5443
|
await api(wasEditing ? `/api/${module}/${knowledgeEditorItem.id}` : `/api/works/${state.work.id}/${module}`, { method: wasEditing ? "PATCH" : "POST", body });
|
|
5112
5444
|
await cleanupPendingMarkdownAttachments(settingsMarkdown);
|
|
5113
5445
|
entityEditorDirty = false;
|
|
@@ -5120,16 +5452,16 @@ async function openKnowledgeEditor(kind, item) {
|
|
|
5120
5452
|
submit.disabled = false;
|
|
5121
5453
|
}
|
|
5122
5454
|
};
|
|
5123
|
-
showEntityEditorPage(kind);
|
|
5124
|
-
$("#knowledge-editor-fields").querySelector("input:not([type='checkbox']), textarea")?.focus();
|
|
5455
|
+
showEntityEditorPage(kind, { readOnly });
|
|
5456
|
+
(readOnly ? $("#knowledge-editor-close") : $("#knowledge-editor-fields").querySelector("input:not([type='checkbox']), textarea"))?.focus();
|
|
5125
5457
|
}
|
|
5126
5458
|
|
|
5127
|
-
async function openRaceDialog(item) {
|
|
5128
|
-
await openKnowledgeEditor("race", item);
|
|
5459
|
+
async function openRaceDialog(item, options) {
|
|
5460
|
+
await openKnowledgeEditor("race", item, options);
|
|
5129
5461
|
}
|
|
5130
5462
|
|
|
5131
|
-
async function openOrganizationDialog(item) {
|
|
5132
|
-
await openKnowledgeEditor("organization", item);
|
|
5463
|
+
async function openOrganizationDialog(item, options) {
|
|
5464
|
+
await openKnowledgeEditor("organization", item, options);
|
|
5133
5465
|
}
|
|
5134
5466
|
|
|
5135
5467
|
function openTimelineTrackDialog(item) {
|
|
@@ -5259,7 +5591,7 @@ function openTaskDialog() {
|
|
|
5259
5591
|
}
|
|
5260
5592
|
|
|
5261
5593
|
function openProviderDialog(item) {
|
|
5262
|
-
openDialog(item ? "编辑 AI 供应商" : "新建 AI 供应商", field("name", "显示名称", "text", item?.name) + field("baseUrl", "
|
|
5594
|
+
openDialog(item ? "编辑 AI 供应商" : "新建 AI 供应商", field("name", "显示名称", "text", item?.name) + field("baseUrl", "OpenAI 兼容接口地址", "url", item?.baseUrl ?? "https://api.openai.com/v1") + field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password") + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10) + field("rpmLimit", "每分钟请求上限", "number", item?.rpmLimit ?? 10) + field("maxTokens", "最大输出令牌数", "number", item?.maxTokens ?? 32000) + field("note", "用途备注", "textarea", item?.note) + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true), async (form) => {
|
|
5263
5595
|
const body = { name: form.get("name"), baseUrl: form.get("baseUrl"), concurrencyLimit: Number(form.get("concurrencyLimit")), rpmLimit: Number(form.get("rpmLimit")), maxTokens: Number(form.get("maxTokens")), note: form.get("note"), status: form.get("enabled") === "on" ? "enabled" : "disabled" };
|
|
5264
5596
|
if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
|
|
5265
5597
|
await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
|
|
@@ -5271,7 +5603,7 @@ function openProviderDialog(item) {
|
|
|
5271
5603
|
function openModelDialog(providerId, item = null) {
|
|
5272
5604
|
const values = modelFormValues(item);
|
|
5273
5605
|
const temperatureField = `<div class="form-field model-temperature-field"><label for="model-temperature">默认温度<input id="model-temperature" name="temperature" type="number" value="${esc(values.temperature)}" step="any" aria-describedby="model-temperature-hint"></label><small id="model-temperature-hint" class="model-temperature-hint" hidden>Kimi 模型必须设置温度为 1。</small></div>`;
|
|
5274
|
-
openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + field("contextWindow", "
|
|
5606
|
+
openDialog(item ? "编辑模型" : "添加模型", field("displayName", "显示名称", "text", values.displayName) + field("modelId", "模型标识符", "text", values.modelId) + field("purposes", "支持用途(可多选)", "chips", values.purposes, MODEL_PURPOSE_OPTIONS) + field("contextWindow", "模型上下文令牌总量", "number", values.contextWindow) + temperatureField + field("maxTokens", "默认最大输出令牌数", "number", values.maxTokens) + field("thinkingEnabled", "开启思考模式(供应商需支持相应参数)", "checkbox", values.thinkingEnabled) + field("enabled", "启用模型", "checkbox", values.enabled), async (form) => {
|
|
5275
5607
|
const body = modelPayload({ displayName: form.get("displayName"), modelId: form.get("modelId"), purposes: form.getAll("purposes"), contextWindow: form.get("contextWindow"), temperature: form.get("temperature"), maxTokens: form.get("maxTokens"), thinkingEnabled: form.get("thinkingEnabled") === "on", enabled: form.get("enabled") === "on" }, item?.preset);
|
|
5276
5608
|
await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
|
|
5277
5609
|
await renderPlatformAiConfig();
|
|
@@ -5513,7 +5845,7 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
|
|
|
5513
5845
|
message.className = "assistant-message";
|
|
5514
5846
|
const applicable = suggestion.action !== "note";
|
|
5515
5847
|
const guard = suggestion.guard;
|
|
5516
|
-
const guardHtml = guard ? `<section class="guard-card ${esc(guard.status)}" data-testid="continuation-guard"><strong>${guard.status === "clear" ? "一致性守卫:未发现冲突" : guard.status === "warning" ? `一致性守卫:发现 ${guard.issues.length} 项风险` : "一致性守卫:检查失败"}</strong>${guard.status === "failed" ? `<p>${esc(guard.failure || "无法完成检查,请谨慎采纳")}</p>` : guard.issues.map((issue) => `<p><b>${esc(issue.severity)} · ${esc(issue.type)}</b> ${esc(issue.title)}${issue.description ? `:${esc(issue.description)}` : ""}</p>`).join("")}</section>` : "";
|
|
5848
|
+
const guardHtml = guard ? `<section class="guard-card ${esc(guard.status)}" data-testid="continuation-guard"><strong>${guard.status === "clear" ? "一致性守卫:未发现冲突" : guard.status === "warning" ? `一致性守卫:发现 ${guard.issues.length} 项风险` : "一致性守卫:检查失败"}</strong>${guard.status === "failed" ? `<p>${esc(guard.failure || "无法完成检查,请谨慎采纳")}</p>` : guard.issues.map((issue) => `<p><b>${esc(levelLabel(issue.severity))} · ${esc(reviewItemTypeLabel(issue.type))}</b> ${esc(issue.title)}${issue.description ? `:${esc(issue.description)}` : ""}</p>`).join("")}</section>` : "";
|
|
5517
5849
|
message.innerHTML = `<div class="message-body">${renderMarkdown(suggestion.content)}</div><div class="message-meta">${esc(formatAiMessageMeta(suggestion.model?.displayName, suggestion.outputTokens, `基于 v${suggestion.chapterVersion ?? "-"}`))}</div>${guardHtml}${applicable ? '<div class="message-actions"><button data-action="accept">采纳到正文</button><button data-action="reject">拒绝</button></div>' : ""}`;
|
|
5518
5850
|
attachMessageHeading(message, "助手建议", createdAt ?? undefined);
|
|
5519
5851
|
attachAssistantCopyAction(message, suggestion.content);
|
|
@@ -5546,7 +5878,7 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
|
|
|
5546
5878
|
async function showVersions() {
|
|
5547
5879
|
if (!state.chapter) return;
|
|
5548
5880
|
const versions = await api(`/api/chapters/${state.chapter.id}/versions`);
|
|
5549
|
-
$("#versions-list").innerHTML = versions.map((version) => `<div class="version-row"><div><b>v${version.versionNo}</b><small>${esc(version.source)} · ${esc(version.actor || "历史数据")}</small></div><p>${esc(version.content.slice(0, 300) || "空白章节")}</p>${canEditProse() ? `<button class="ghost-button" data-restore-version="${version.versionNo}">恢复</button>` : ""}</div>`).join("");
|
|
5881
|
+
$("#versions-list").innerHTML = versions.map((version) => `<div class="version-row"><div><b>v${version.versionNo}</b><small>${esc(chapterVersionSourceLabel(version.source))} · ${esc(version.actor || "历史数据")}</small></div><p>${esc(version.content.slice(0, 300) || "空白章节")}</p>${canEditProse() ? `<button class="ghost-button" data-restore-version="${version.versionNo}">恢复</button>` : ""}</div>`).join("");
|
|
5550
5882
|
$("#versions-list").querySelectorAll("[data-restore-version]").forEach((button) => button.addEventListener("click", async () => {
|
|
5551
5883
|
if (!(await confirmToast(
|
|
5552
5884
|
`将版本 v${button.dataset.restoreVersion} 恢复为一个新的保存版本?`,
|
|
@@ -5956,6 +6288,11 @@ $("#platform-ai-button").addEventListener("click", () => showPlatformAi().catch(
|
|
|
5956
6288
|
$("#user-management-button").addEventListener("click", openUsersDialog);
|
|
5957
6289
|
$("#platform-ui-settings-button").addEventListener("click", openPlatformUiSettingsDialog);
|
|
5958
6290
|
$("#collaboration-button").addEventListener("click", () => openMembersDialog());
|
|
6291
|
+
$("#presence-button").addEventListener("click", () => {
|
|
6292
|
+
const panel = $("#presence-panel");
|
|
6293
|
+
const open = panel.classList.toggle("hidden") === false;
|
|
6294
|
+
$("#presence-button").setAttribute("aria-expanded", String(open));
|
|
6295
|
+
});
|
|
5959
6296
|
$("#users-dialog-close").addEventListener("click", () => $("#users-dialog").close());
|
|
5960
6297
|
$("#platform-ui-settings-close").addEventListener("click", () => $("#platform-ui-settings-dialog").close());
|
|
5961
6298
|
$("#platform-ui-settings-cancel").addEventListener("click", () => $("#platform-ui-settings-dialog").close());
|
|
@@ -6271,6 +6608,10 @@ document.addEventListener("pointerdown", (event) => {
|
|
|
6271
6608
|
$("#account-menu").classList.add("hidden");
|
|
6272
6609
|
$("#account-button").setAttribute("aria-expanded", "false");
|
|
6273
6610
|
}
|
|
6611
|
+
if (!event.target.closest("#presence-control")) {
|
|
6612
|
+
$("#presence-panel").classList.add("hidden");
|
|
6613
|
+
$("#presence-button").setAttribute("aria-expanded", "false");
|
|
6614
|
+
}
|
|
6274
6615
|
});
|
|
6275
6616
|
document.addEventListener("keydown", (event) => {
|
|
6276
6617
|
if (event.key === "Escape") {
|