@musnows/scriverse 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +28 -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 +530 -126
- 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 +24 -10
- package/dist/public/page-route.js +3 -1
- package/dist/public/styles.css +327 -23
- 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) {
|
|
@@ -248,6 +287,10 @@ function hasCompletedOnboarding() {
|
|
|
248
287
|
return state.user?.onboardingCompleted === true;
|
|
249
288
|
}
|
|
250
289
|
|
|
290
|
+
function isMobileViewport() {
|
|
291
|
+
return window.matchMedia("(max-width: 850px)").matches;
|
|
292
|
+
}
|
|
293
|
+
|
|
251
294
|
function persistOnboardingCompletion() {
|
|
252
295
|
if (!state.user || state.user.onboardingCompleted) return;
|
|
253
296
|
state.user = { ...state.user, onboardingCompleted: true };
|
|
@@ -370,6 +413,7 @@ function renderOnboardingStep(step, focusTitle = false) {
|
|
|
370
413
|
}
|
|
371
414
|
|
|
372
415
|
function openOnboarding(force = false) {
|
|
416
|
+
if (isMobileViewport()) return;
|
|
373
417
|
const dialog = $("#onboarding-dialog");
|
|
374
418
|
if (!force && hasCompletedOnboarding()) return;
|
|
375
419
|
onboardingSteps = currentOnboardingSteps();
|
|
@@ -388,7 +432,7 @@ function completeOnboarding() {
|
|
|
388
432
|
}
|
|
389
433
|
|
|
390
434
|
function scheduleFirstUseOnboarding() {
|
|
391
|
-
if (onboardingAutoScheduled || hasCompletedOnboarding()) return;
|
|
435
|
+
if (isMobileViewport() || onboardingAutoScheduled || hasCompletedOnboarding()) return;
|
|
392
436
|
onboardingAutoScheduled = true;
|
|
393
437
|
window.requestAnimationFrame(() => {
|
|
394
438
|
onboardingAutoScheduled = false;
|
|
@@ -409,13 +453,134 @@ function replacePageRoute(route) {
|
|
|
409
453
|
if (restoringPageRoute) return;
|
|
410
454
|
const hash = serializePageRoute(route);
|
|
411
455
|
if (window.location.hash !== hash) window.history.replaceState(null, "", hash);
|
|
456
|
+
schedulePresenceHeartbeat();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function presencePageForRoute(route = currentPageRoute()) {
|
|
460
|
+
if (!state.work || route.view === "shelf" || route.view === "platform-ai") return null;
|
|
461
|
+
if (route.view === "editor") return { kind: "editor", resourceId: String(route.chapterId ?? "") || undefined };
|
|
462
|
+
if (route.view === "entity-editor") return { kind: "entity-editor", module: route.entity, resourceId: String(route.entityId ?? "") || undefined };
|
|
463
|
+
if (route.view === "module") return { kind: "module", module: route.module };
|
|
464
|
+
if (route.view === "settings" || route.view === "platform-ai") return { kind: "settings" };
|
|
465
|
+
return { kind: "welcome" };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function presencePageKey(page) {
|
|
469
|
+
if (!page) return "";
|
|
470
|
+
if (page.kind === "editor") return `editor:${page.resourceId ?? ""}`;
|
|
471
|
+
if (page.kind === "entity-editor") return `entity-editor:${page.module ?? ""}:${page.resourceId ?? ""}`;
|
|
472
|
+
if (page.kind === "module") return `module:${page.module ?? ""}`;
|
|
473
|
+
return page.kind;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function groupedPresenceParticipants() {
|
|
477
|
+
const groups = new Map();
|
|
478
|
+
for (const participant of presenceParticipants) {
|
|
479
|
+
const current = groups.get(participant.userId) ?? { ...participant, pages: [], clientIds: [] };
|
|
480
|
+
if (!current.pages.some((page) => page.key === participant.page.key)) current.pages.push(participant.page);
|
|
481
|
+
current.clientIds.push(participant.clientId);
|
|
482
|
+
groups.set(participant.userId, current);
|
|
483
|
+
}
|
|
484
|
+
return [...groups.values()];
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function hasOtherCollaborators() {
|
|
488
|
+
return presenceParticipants.some((participant) => participant.userId !== state.user?.userId);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function syncChapterAutoSaveWithPresence() {
|
|
492
|
+
const wasDisabled = collaborationAutoSaveDisabled;
|
|
493
|
+
collaborationAutoSaveDisabled = hasOtherCollaborators();
|
|
494
|
+
if (collaborationAutoSaveDisabled) {
|
|
495
|
+
cancelChapterAutoSave();
|
|
496
|
+
if (state.chapter && canEditProse()) {
|
|
497
|
+
setSaveState(state.dirty ? "多人协作,自动保存已关闭" : "自动保存已关闭", state.dirty);
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (wasDisabled && state.dirty && state.chapter && canEditProse()) scheduleChapterAutoSave(250);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function renderPresence() {
|
|
505
|
+
const control = $("#presence-control");
|
|
506
|
+
if (!state.work || !presenceParticipants.length) {
|
|
507
|
+
syncChapterAutoSaveWithPresence();
|
|
508
|
+
control.classList.add("hidden");
|
|
509
|
+
$("#presence-panel").classList.add("hidden");
|
|
510
|
+
$("#presence-button").setAttribute("aria-expanded", "false");
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const groups = groupedPresenceParticipants();
|
|
514
|
+
const localKey = presencePageKey(presencePageForRoute());
|
|
515
|
+
syncChapterAutoSaveWithPresence();
|
|
516
|
+
control.classList.remove("hidden");
|
|
517
|
+
$("#presence-count").textContent = `${groups.length} 人在线`;
|
|
518
|
+
$("#presence-list").innerHTML = groups.map((participant) => {
|
|
519
|
+
const isCurrent = participant.userId === state.user?.userId;
|
|
520
|
+
const samePage = !isCurrent && participant.pages.some((page) => page.key === localKey);
|
|
521
|
+
const pageLabels = participant.pages.map((page) => page.label).join("、");
|
|
522
|
+
const avatar = participant.avatarUrl
|
|
523
|
+
? `<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>`
|
|
524
|
+
: `<span class="user-avatar"><span class="user-avatar-fallback">${esc(Array.from(participant.displayName || participant.username)[0] ?? "作")}</span></span>`;
|
|
525
|
+
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>`;
|
|
526
|
+
}).join("");
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function refreshPresence() {
|
|
530
|
+
const requestId = ++presenceHeartbeatRequest;
|
|
531
|
+
if (presenceHeartbeatTimer !== null) clearTimeout(presenceHeartbeatTimer);
|
|
532
|
+
presenceHeartbeatTimer = null;
|
|
533
|
+
const workId = state.work?.id;
|
|
534
|
+
const page = presencePageForRoute();
|
|
535
|
+
if (!workId || !page || !state.user) {
|
|
536
|
+
presenceParticipants = [];
|
|
537
|
+
renderPresence();
|
|
538
|
+
return [];
|
|
539
|
+
}
|
|
540
|
+
try {
|
|
541
|
+
const participants = await api(`/api/works/${encodeURIComponent(workId)}/presence`, {
|
|
542
|
+
method: "POST",
|
|
543
|
+
body: { clientId: presenceClientId, page },
|
|
544
|
+
skipOptimisticVersion: true
|
|
545
|
+
});
|
|
546
|
+
if (state.work?.id === workId && presenceHeartbeatRequest === requestId) {
|
|
547
|
+
presenceParticipants = participants;
|
|
548
|
+
renderPresence();
|
|
549
|
+
}
|
|
550
|
+
} catch {
|
|
551
|
+
if (state.work?.id === workId) renderPresence();
|
|
552
|
+
} finally {
|
|
553
|
+
if (state.work?.id === workId && presenceHeartbeatRequest === requestId) presenceHeartbeatTimer = setTimeout(refreshPresence, presenceHeartbeatInterval);
|
|
554
|
+
}
|
|
555
|
+
return presenceParticipants;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function schedulePresenceHeartbeat() {
|
|
559
|
+
if (presenceHeartbeatQueued !== null) clearTimeout(presenceHeartbeatQueued);
|
|
560
|
+
presenceHeartbeatQueued = setTimeout(() => {
|
|
561
|
+
presenceHeartbeatQueued = null;
|
|
562
|
+
void refreshPresence();
|
|
563
|
+
}, 80);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function confirmConcurrentSave() {
|
|
567
|
+
await refreshPresence();
|
|
568
|
+
const localKey = presencePageKey(presencePageForRoute());
|
|
569
|
+
const peers = presenceParticipants.filter((participant) => participant.clientId !== presenceClientId && participant.page.key === localKey);
|
|
570
|
+
if (!peers.length) return true;
|
|
571
|
+
const names = [...new Set(peers.map((participant) => participant.displayName))];
|
|
572
|
+
return confirmToast(`${names.join("、")}也停留在这个编辑页面。当前项目尚未适配多人同时编辑,继续保存可能覆盖对方的修改。`, {
|
|
573
|
+
title: "检测到同页协作",
|
|
574
|
+
confirmLabel: "仍然保存",
|
|
575
|
+
cancelLabel: "暂不保存"
|
|
576
|
+
});
|
|
412
577
|
}
|
|
413
578
|
|
|
414
579
|
function currentPageRoute() {
|
|
415
580
|
const workId = state.work?.id ?? null;
|
|
416
581
|
if (!$("#entity-editor-view").classList.contains("hidden") && workId && entityEditorType) {
|
|
417
582
|
const entityId = entityEditorType === "setting" ? settingEditorItem?.id : entityEditorType === "character" ? characterEditorItem?.id : knowledgeEditorItem?.id;
|
|
418
|
-
return { view: "entity-editor", workId, entity: entityEditorType, entityId: entityId ?? null };
|
|
583
|
+
return { view: "entity-editor", workId, entity: entityEditorType, entityId: entityId ?? null, entityMode: entityEditorReadOnly ? "read" : "edit" };
|
|
419
584
|
}
|
|
420
585
|
if (!$("#settings-hub-view").classList.contains("hidden")) return { view: "settings", workId, ...settingsRouteContext() };
|
|
421
586
|
if (!$("#platform-ai-view").classList.contains("hidden")) return { view: "platform-ai", workId, ...settingsRouteContext() };
|
|
@@ -441,6 +606,11 @@ function loadPanelLayout() {
|
|
|
441
606
|
}
|
|
442
607
|
|
|
443
608
|
let panelLayout = loadPanelLayout();
|
|
609
|
+
if (window.matchMedia("(max-width: 850px)").matches) {
|
|
610
|
+
// 手机上默认收起创作助手,避免遮挡正文;用户可通过底部把手随时展开。
|
|
611
|
+
panelLayout.aiCollapsed = true;
|
|
612
|
+
panelLayout.leftCollapsed = true;
|
|
613
|
+
}
|
|
444
614
|
|
|
445
615
|
function constrainPanelLayout() {
|
|
446
616
|
const minimumMainWidth = 480;
|
|
@@ -461,9 +631,13 @@ function applyPanelLayout(persist = false) {
|
|
|
461
631
|
app.style.setProperty("--ai-panel-width", `${panelLayout.aiWidth}px`);
|
|
462
632
|
app.classList.toggle("left-panel-collapsed", panelLayout.leftCollapsed);
|
|
463
633
|
app.classList.toggle("ai-panel-collapsed", panelLayout.aiCollapsed);
|
|
464
|
-
|
|
634
|
+
const mobileViewport = isMobileViewport();
|
|
635
|
+
$("#left-panel-toggle").textContent = mobileViewport ? "›" : (panelLayout.leftCollapsed ? "›" : "‹");
|
|
465
636
|
$("#left-panel-toggle").setAttribute("aria-expanded", String(!panelLayout.leftCollapsed));
|
|
466
|
-
$("#left-panel-toggle").setAttribute("aria-label",
|
|
637
|
+
$("#left-panel-toggle").setAttribute("aria-label", mobileViewport
|
|
638
|
+
? (panelLayout.leftCollapsed ? "打开作品模块" : "关闭作品模块")
|
|
639
|
+
: (panelLayout.leftCollapsed ? "展开作品侧栏" : "收起作品侧栏"));
|
|
640
|
+
$("#mobile-module-tab").setAttribute("aria-expanded", String(!panelLayout.leftCollapsed));
|
|
467
641
|
$("#ai-panel-toggle").textContent = panelLayout.aiCollapsed ? "‹" : "›";
|
|
468
642
|
$("#ai-panel-toggle").setAttribute("aria-expanded", String(!panelLayout.aiCollapsed));
|
|
469
643
|
$("#ai-panel-toggle").setAttribute("aria-label", panelLayout.aiCollapsed ? "展开创作助手" : "收起创作助手");
|
|
@@ -520,6 +694,7 @@ let chapterLineDrag = null;
|
|
|
520
694
|
let chapterWhitespaceVisible = true;
|
|
521
695
|
let chapterAutoSaveTimer = null;
|
|
522
696
|
let chapterSaveInFlight = null;
|
|
697
|
+
let chapterSaveGuardInFlight = null;
|
|
523
698
|
let lastSavedChapterSnapshot = null;
|
|
524
699
|
let moduleNavExpanded = false;
|
|
525
700
|
const chapterAutoSaveDelay = 800;
|
|
@@ -528,6 +703,8 @@ let aiMentionRange = null;
|
|
|
528
703
|
let settingsReturnContext = null;
|
|
529
704
|
let entityEditorType = null;
|
|
530
705
|
let entityEditorDirty = false;
|
|
706
|
+
let entityEditorReadOnly = false;
|
|
707
|
+
let characterListPage = 1;
|
|
531
708
|
let settingEditorItem = null;
|
|
532
709
|
let characterEditorItem = null;
|
|
533
710
|
let knowledgeEditorItem = null;
|
|
@@ -547,9 +724,10 @@ let knowledgeSectionVditor = null;
|
|
|
547
724
|
let characterSectionVditor = null;
|
|
548
725
|
let entityHistoryContext = null;
|
|
549
726
|
|
|
550
|
-
function showEntityEditorPage(type) {
|
|
727
|
+
function showEntityEditorPage(type, { readOnly = false } = {}) {
|
|
551
728
|
entityEditorType = type;
|
|
552
729
|
entityEditorDirty = false;
|
|
730
|
+
entityEditorReadOnly = readOnly;
|
|
553
731
|
characterSectionEditorDirty = false;
|
|
554
732
|
knowledgeSectionEditorDirty = false;
|
|
555
733
|
$("#entity-editor-view").classList.remove("hidden");
|
|
@@ -565,7 +743,7 @@ function showEntityEditorPage(type) {
|
|
|
565
743
|
|
|
566
744
|
function markEntityEditorDirty() {
|
|
567
745
|
const module = entityEditorType === "setting" ? "settings" : entityEditorType === "character" ? "characters" : entityEditorType === "race" ? "races" : "organizations";
|
|
568
|
-
if (entityEditorType && canEditModule(module)) entityEditorDirty = true;
|
|
746
|
+
if (entityEditorType && !entityEditorReadOnly && canEditModule(module)) entityEditorDirty = true;
|
|
569
747
|
}
|
|
570
748
|
|
|
571
749
|
async function confirmEntityEditorDiscard(message) {
|
|
@@ -588,6 +766,7 @@ async function closeEntityEditor({ force = false } = {}) {
|
|
|
588
766
|
const module = entityEditorType === "setting" ? "settings" : entityEditorType === "character" ? "characters" : entityEditorType === "race" ? "races" : "organizations";
|
|
589
767
|
entityEditorType = null;
|
|
590
768
|
entityEditorDirty = false;
|
|
769
|
+
entityEditorReadOnly = false;
|
|
591
770
|
settingEditorItem = null;
|
|
592
771
|
characterEditorItem = null;
|
|
593
772
|
knowledgeEditorItem = null;
|
|
@@ -1587,7 +1766,7 @@ function attachOptimisticVersion(path, method, body) {
|
|
|
1587
1766
|
|
|
1588
1767
|
async function api(path, options = {}) {
|
|
1589
1768
|
const method = String(options.method ?? "GET").toUpperCase();
|
|
1590
|
-
const body = attachOptimisticVersion(path, method, options.body);
|
|
1769
|
+
const body = options.skipOptimisticVersion ? options.body : attachOptimisticVersion(path, method, options.body);
|
|
1591
1770
|
const headers = { ...(options.headers ?? {}) };
|
|
1592
1771
|
if (state.csrfToken && !["GET", "HEAD", "OPTIONS"].includes(method)) headers["X-CSRF-Token"] = state.csrfToken;
|
|
1593
1772
|
if (!(body instanceof FormData)) headers["Content-Type"] = "application/json";
|
|
@@ -1598,7 +1777,11 @@ async function api(path, options = {}) {
|
|
|
1598
1777
|
});
|
|
1599
1778
|
if (!response.ok) {
|
|
1600
1779
|
const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
|
|
1601
|
-
if (response.status === 401 && !path.startsWith("/api/auth/"))
|
|
1780
|
+
if (response.status === 401 && !path.startsWith("/api/auth/")) {
|
|
1781
|
+
state.user = null;
|
|
1782
|
+
state.csrfToken = null;
|
|
1783
|
+
showAuth(false);
|
|
1784
|
+
}
|
|
1602
1785
|
throw new Error(payload.error?.message ?? `请求失败:${response.status}`);
|
|
1603
1786
|
}
|
|
1604
1787
|
if (response.status === 204) return null;
|
|
@@ -1679,6 +1862,7 @@ async function refreshAuthCaptcha(target = "login") {
|
|
|
1679
1862
|
}
|
|
1680
1863
|
|
|
1681
1864
|
function showAuth(setupRequired, registrationOpen = false) {
|
|
1865
|
+
if (state.user) return;
|
|
1682
1866
|
document.body.classList.add("auth-pending");
|
|
1683
1867
|
$("#auth-view").classList.remove("hidden");
|
|
1684
1868
|
const canRegister = registrationOpen === true;
|
|
@@ -1707,6 +1891,7 @@ function applyAuthenticatedUser(session) {
|
|
|
1707
1891
|
$("#account-menu-role").textContent = session.user.role === "admin" ? "系统管理员" : "普通用户";
|
|
1708
1892
|
$("#auth-view").classList.add("hidden");
|
|
1709
1893
|
document.documentElement.classList.remove("login-route");
|
|
1894
|
+
if (!session.csrfToken) document.body.classList.remove("auth-pending");
|
|
1710
1895
|
// 注意:auth-pending 由 initializePage 路由完成后才移除,
|
|
1711
1896
|
// 避免会话确认后、目标视图渲染前露出无内容的编辑器外壳
|
|
1712
1897
|
}
|
|
@@ -1805,6 +1990,60 @@ function confirmToast(message, { title = "请再次确认", confirmLabel = "确
|
|
|
1805
1990
|
});
|
|
1806
1991
|
}
|
|
1807
1992
|
|
|
1993
|
+
function inputToast(message, { title = "请输入", inputLabel = title, placeholder = "", confirmLabel = "确认", cancelLabel = "取消", maxLength = 500 } = {}) {
|
|
1994
|
+
const region = $("#toast-region");
|
|
1995
|
+
const element = document.createElement("section");
|
|
1996
|
+
element.className = "toast toast-confirmation toast-input-dialog";
|
|
1997
|
+
element.setAttribute("role", "alertdialog");
|
|
1998
|
+
element.setAttribute("aria-label", title);
|
|
1999
|
+
const heading = document.createElement("strong");
|
|
2000
|
+
heading.textContent = title;
|
|
2001
|
+
const description = document.createElement("p");
|
|
2002
|
+
description.textContent = message;
|
|
2003
|
+
const input = document.createElement("input");
|
|
2004
|
+
input.className = "toast-input";
|
|
2005
|
+
input.type = "text";
|
|
2006
|
+
input.maxLength = maxLength;
|
|
2007
|
+
input.placeholder = placeholder;
|
|
2008
|
+
input.setAttribute("aria-label", inputLabel);
|
|
2009
|
+
const actions = document.createElement("div");
|
|
2010
|
+
actions.className = "toast-confirmation-actions";
|
|
2011
|
+
const cancel = document.createElement("button");
|
|
2012
|
+
cancel.className = "ghost-button";
|
|
2013
|
+
cancel.type = "button";
|
|
2014
|
+
cancel.textContent = cancelLabel;
|
|
2015
|
+
const confirm = document.createElement("button");
|
|
2016
|
+
confirm.className = "primary-button";
|
|
2017
|
+
confirm.type = "button";
|
|
2018
|
+
confirm.textContent = confirmLabel;
|
|
2019
|
+
actions.append(cancel, confirm);
|
|
2020
|
+
element.append(heading, description, input, actions);
|
|
2021
|
+
region.append(element);
|
|
2022
|
+
raiseToastRegion();
|
|
2023
|
+
input.focus();
|
|
2024
|
+
return new Promise((resolve) => {
|
|
2025
|
+
let settled = false;
|
|
2026
|
+
const finish = (value) => {
|
|
2027
|
+
if (settled) return;
|
|
2028
|
+
settled = true;
|
|
2029
|
+
element.remove();
|
|
2030
|
+
if (!region.childElementCount && typeof region.hidePopover === "function" && region.matches(":popover-open")) region.hidePopover();
|
|
2031
|
+
resolve(value);
|
|
2032
|
+
};
|
|
2033
|
+
cancel.addEventListener("click", () => finish(null), { once: true });
|
|
2034
|
+
confirm.addEventListener("click", () => finish(input.value.trim()), { once: true });
|
|
2035
|
+
element.addEventListener("keydown", (event) => {
|
|
2036
|
+
if (event.key === "Escape") {
|
|
2037
|
+
event.preventDefault();
|
|
2038
|
+
finish(null);
|
|
2039
|
+
} else if (event.key === "Enter") {
|
|
2040
|
+
event.preventDefault();
|
|
2041
|
+
finish(input.value.trim());
|
|
2042
|
+
}
|
|
2043
|
+
});
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
|
|
1808
2047
|
document.addEventListener("toggle", (event) => {
|
|
1809
2048
|
const target = event.target;
|
|
1810
2049
|
if (target instanceof HTMLDialogElement && target.open && $("#toast-region").childElementCount) {
|
|
@@ -1839,6 +2078,10 @@ function cancelChapterAutoSave() {
|
|
|
1839
2078
|
function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
|
|
1840
2079
|
if (!state.chapter || !canEditProse()) return;
|
|
1841
2080
|
cancelChapterAutoSave();
|
|
2081
|
+
if (collaborationAutoSaveDisabled) {
|
|
2082
|
+
setSaveState("多人协作,自动保存已关闭", true);
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
1842
2085
|
setSaveState("等待自动保存", true);
|
|
1843
2086
|
chapterAutoSaveTimer = setTimeout(() => {
|
|
1844
2087
|
chapterAutoSaveTimer = null;
|
|
@@ -1853,12 +2096,23 @@ async function persistChapter({ automatic = false } = {}) {
|
|
|
1853
2096
|
return null;
|
|
1854
2097
|
}
|
|
1855
2098
|
cancelChapterAutoSave();
|
|
2099
|
+
if (automatic) {
|
|
2100
|
+
await refreshPresence();
|
|
2101
|
+
if (collaborationAutoSaveDisabled) {
|
|
2102
|
+
setSaveState("多人协作,自动保存已关闭", true);
|
|
2103
|
+
return null;
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
1856
2106
|
if (chapterSaveInFlight) {
|
|
1857
2107
|
await chapterSaveInFlight;
|
|
1858
2108
|
const pendingDraft = chapterDraftSnapshot();
|
|
1859
2109
|
if (!sameChapterSnapshot(pendingDraft, lastSavedChapterSnapshot)) return persistChapter({ automatic });
|
|
1860
2110
|
return state.chapter;
|
|
1861
2111
|
}
|
|
2112
|
+
if (chapterSaveGuardInFlight) {
|
|
2113
|
+
await chapterSaveGuardInFlight;
|
|
2114
|
+
return persistChapter({ automatic });
|
|
2115
|
+
}
|
|
1862
2116
|
const draft = chapterDraftSnapshot();
|
|
1863
2117
|
if (!draft?.title) {
|
|
1864
2118
|
setSaveState("标题不能为空", true);
|
|
@@ -1871,9 +2125,17 @@ async function persistChapter({ automatic = false } = {}) {
|
|
|
1871
2125
|
scheduleChapterLineNumbers();
|
|
1872
2126
|
}
|
|
1873
2127
|
if (sameChapterSnapshot(draft, lastSavedChapterSnapshot)) {
|
|
1874
|
-
setSaveState(automatic ? "已自动保存" : "已保存");
|
|
2128
|
+
setSaveState(automatic ? "已自动保存" : collaborationAutoSaveDisabled ? "已保存 · 自动保存已关闭" : "已保存");
|
|
1875
2129
|
return state.chapter;
|
|
1876
2130
|
}
|
|
2131
|
+
const saveGuard = confirmConcurrentSave();
|
|
2132
|
+
chapterSaveGuardInFlight = saveGuard;
|
|
2133
|
+
const confirmed = await saveGuard;
|
|
2134
|
+
if (chapterSaveGuardInFlight === saveGuard) chapterSaveGuardInFlight = null;
|
|
2135
|
+
if (!confirmed) {
|
|
2136
|
+
setSaveState("检测到同页协作,未保存", true);
|
|
2137
|
+
return null;
|
|
2138
|
+
}
|
|
1877
2139
|
setSaveState(automatic ? "自动保存中" : "保存中", true);
|
|
1878
2140
|
const workId = state.work.id;
|
|
1879
2141
|
const request = (async () => {
|
|
@@ -1895,7 +2157,7 @@ async function persistChapter({ automatic = false } = {}) {
|
|
|
1895
2157
|
updateChapterStats();
|
|
1896
2158
|
const currentDraft = chapterDraftSnapshot();
|
|
1897
2159
|
if (sameChapterSnapshot(currentDraft, draft)) {
|
|
1898
|
-
setSaveState(automatic ? "已自动保存" : "已保存");
|
|
2160
|
+
setSaveState(automatic ? "已自动保存" : collaborationAutoSaveDisabled ? "已保存 · 自动保存已关闭" : "已保存");
|
|
1899
2161
|
if (!automatic) toast(`正文已保存为 v${state.chapter.versionNo}`);
|
|
1900
2162
|
} else {
|
|
1901
2163
|
scheduleChapterAutoSave(250);
|
|
@@ -1997,20 +2259,26 @@ async function initializePage() {
|
|
|
1997
2259
|
|
|
1998
2260
|
if (route.view === "editor") {
|
|
1999
2261
|
if (route.chapterId && state.chapter?.id !== route.chapterId) await selectChapter(route.chapterId);
|
|
2262
|
+
if (state.chapter?.id === route.chapterId && $("#editor-view").classList.contains("hidden")) await selectChapter(state.chapter.id);
|
|
2000
2263
|
return;
|
|
2001
2264
|
}
|
|
2002
2265
|
if (route.view === "module") return;
|
|
2003
2266
|
if (route.view === "entity-editor") {
|
|
2004
2267
|
const records = route.entity === "setting" ? state.settings : route.entity === "character" ? state.characters : route.entity === "race" ? state.races : state.organizations;
|
|
2005
|
-
const item = route.entityId
|
|
2268
|
+
const item = route.entityId
|
|
2269
|
+
? route.entity === "character"
|
|
2270
|
+
? await api(`/api/characters/${encodeURIComponent(route.entityId)}`)
|
|
2271
|
+
: records.find((record) => record.id === route.entityId)
|
|
2272
|
+
: null;
|
|
2006
2273
|
if (route.entityId && !item) {
|
|
2007
2274
|
toast(({ setting: "未找到要编辑的设定", character: "未找到要编辑的角色", race: "未找到要编辑的种族", organization: "未找到要编辑的组织" }[route.entity] ?? "未找到要编辑的档案"), "error");
|
|
2008
2275
|
return;
|
|
2009
2276
|
}
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
else if (route.entity === "
|
|
2013
|
-
else if (route.entity === "
|
|
2277
|
+
const options = { readOnly: route.entityMode === "read" };
|
|
2278
|
+
if (route.entity === "setting") openSettingEditor(item, options);
|
|
2279
|
+
else if (route.entity === "character") await openCharacterEditor(item, options);
|
|
2280
|
+
else if (route.entity === "race") await openRaceDialog(item, options);
|
|
2281
|
+
else if (route.entity === "organization") await openOrganizationDialog(item, options);
|
|
2014
2282
|
return;
|
|
2015
2283
|
}
|
|
2016
2284
|
if (route.view === "welcome") {
|
|
@@ -2230,14 +2498,6 @@ async function openMembersDialog(targetWork = state.work) {
|
|
|
2230
2498
|
} catch (error) { $("#members-dialog").close(); toast(error.message, "error"); }
|
|
2231
2499
|
}
|
|
2232
2500
|
|
|
2233
|
-
const searchResultTypeLabels = {
|
|
2234
|
-
chapter: "章节",
|
|
2235
|
-
setting: "设定",
|
|
2236
|
-
character: "角色",
|
|
2237
|
-
race: "种族",
|
|
2238
|
-
organization: "组织"
|
|
2239
|
-
};
|
|
2240
|
-
|
|
2241
2501
|
async function openSearchDialog() {
|
|
2242
2502
|
if (!state.work) {
|
|
2243
2503
|
toast("请先打开一部作品", "error");
|
|
@@ -2262,7 +2522,7 @@ function renderSearchResults(results) {
|
|
|
2262
2522
|
}
|
|
2263
2523
|
$("#search-results").innerHTML = results.map((item) => `
|
|
2264
2524
|
<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(
|
|
2525
|
+
<div class="search-result-meta"><span>${esc(searchResultTypeLabel(item.type))}</span><strong>${esc(item.title)}</strong></div>
|
|
2266
2526
|
<p>${esc(item.snippet || "无摘要")}</p>
|
|
2267
2527
|
</button>`).join("");
|
|
2268
2528
|
$("#search-results").querySelectorAll(".search-result").forEach((button) => {
|
|
@@ -2412,6 +2672,7 @@ function resetWorkScopedUiCaches() {
|
|
|
2412
2672
|
state.models = [];
|
|
2413
2673
|
state.characters = [];
|
|
2414
2674
|
state.settings = [];
|
|
2675
|
+
characterListPage = 1;
|
|
2415
2676
|
state.collapsedVolumeIds.clear();
|
|
2416
2677
|
lastSavedChapterSnapshot = null;
|
|
2417
2678
|
if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
|
|
@@ -2644,7 +2905,7 @@ async function showModule(module) {
|
|
|
2644
2905
|
$("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
|
|
2645
2906
|
try {
|
|
2646
2907
|
if (module === "settings") await renderSettings();
|
|
2647
|
-
if (module === "characters") await renderCharacters();
|
|
2908
|
+
if (module === "characters") await renderCharacters(characterListPage);
|
|
2648
2909
|
if (module === "races") await renderRaces();
|
|
2649
2910
|
if (module === "organizations") await renderOrganizations();
|
|
2650
2911
|
if (module === "timeline") await renderTimeline();
|
|
@@ -2837,13 +3098,19 @@ function moduleRowPreview(text, max = 180) {
|
|
|
2837
3098
|
return preview.length > max ? `${preview.slice(0, max)}…` : preview;
|
|
2838
3099
|
}
|
|
2839
3100
|
|
|
3101
|
+
function moduleLayoutIconMarkup(layout) {
|
|
3102
|
+
if (layout === "rows") {
|
|
3103
|
+
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>';
|
|
3104
|
+
}
|
|
3105
|
+
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>';
|
|
3106
|
+
}
|
|
3107
|
+
|
|
2840
3108
|
function renderModuleLayoutToggle(layout, ariaLabel = "列表样式") {
|
|
2841
3109
|
return `<div class="module-layout-toolbar" data-module-header-action="layout-toggle">
|
|
2842
3110
|
<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"}"
|
|
3111
|
+
<button type="button" data-module-layout="cards" aria-label="卡片视图" title="卡片视图" aria-pressed="${layout === "cards"}">${moduleLayoutIconMarkup("cards")}</button>
|
|
3112
|
+
<button type="button" data-module-layout="rows" aria-label="列表视图" title="列表视图" aria-pressed="${layout === "rows"}">${moduleLayoutIconMarkup("rows")}</button>
|
|
2845
3113
|
</div>
|
|
2846
|
-
<span class="module-layout-hint">当前:${esc(moduleLayoutLabel(layout))}</span>
|
|
2847
3114
|
</div>`;
|
|
2848
3115
|
}
|
|
2849
3116
|
|
|
@@ -2859,6 +3126,56 @@ function bindModuleLayoutToggle(refresh) {
|
|
|
2859
3126
|
}));
|
|
2860
3127
|
}
|
|
2861
3128
|
|
|
3129
|
+
function bindRecordPreview(selector, open) {
|
|
3130
|
+
$("#module-content").querySelectorAll(selector).forEach((card) => {
|
|
3131
|
+
const id = card.dataset.openSetting ?? card.dataset.openCharacter ?? card.dataset.openRace ?? card.dataset.openReview;
|
|
3132
|
+
card.addEventListener("click", (event) => {
|
|
3133
|
+
if (!event.target.closest("button, a, summary")) void open(id);
|
|
3134
|
+
});
|
|
3135
|
+
card.addEventListener("keydown", (event) => {
|
|
3136
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
3137
|
+
if (event.target.closest("button, a, summary")) return;
|
|
3138
|
+
event.preventDefault();
|
|
3139
|
+
void open(id);
|
|
3140
|
+
});
|
|
3141
|
+
});
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
function openReviewDetailDialog(item) {
|
|
3145
|
+
if (!item) return;
|
|
3146
|
+
const evidence = Array.isArray(item.evidence) ? item.evidence : [];
|
|
3147
|
+
const entityRefs = Array.isArray(item.entityRefs) ? item.entityRefs : [];
|
|
3148
|
+
const evidenceHtml = evidence.length
|
|
3149
|
+
? `<ul>${evidence.map((entry) => {
|
|
3150
|
+
if (!entry || typeof entry !== "object") return `<li>${esc(String(entry))}</li>`;
|
|
3151
|
+
const source = entry.chapterTitle || entry.chapterId || "相关证据";
|
|
3152
|
+
const quote = entry.quote ? `<blockquote>${esc(entry.quote)}</blockquote>` : "";
|
|
3153
|
+
const supports = entry.supports ? `<small>${esc(entry.supports)}</small>` : "";
|
|
3154
|
+
return `<li><strong>${esc(source)}</strong>${quote}${supports}</li>`;
|
|
3155
|
+
}).join("")}</ul>`
|
|
3156
|
+
: "<p>暂无证据</p>";
|
|
3157
|
+
const entityRefsHtml = entityRefs.length
|
|
3158
|
+
? `<ul>${entityRefs.map((reference) => `<li><code>${esc(JSON.stringify(reference))}</code></li>`).join("")}</ul>`
|
|
3159
|
+
: "<p>未关联资料</p>";
|
|
3160
|
+
openDialog("审核详情",
|
|
3161
|
+
`<div class="task-detail review-detail">
|
|
3162
|
+
<p><strong>问题类型</strong> ${esc(reviewItemTypeLabel(item.itemType))} · ${esc(reviewSeverityLabel(item.severity))} · ${esc(reviewStatusLabel(item.status))}</p>
|
|
3163
|
+
<div><strong>问题说明</strong><pre class="task-detail-result">${esc(item.description || "暂无说明")}</pre></div>
|
|
3164
|
+
<div><strong>处理建议</strong><pre class="task-detail-result">${esc(item.suggestion || "暂无建议")}</pre></div>
|
|
3165
|
+
<div><strong>相关证据</strong>${evidenceHtml}</div>
|
|
3166
|
+
<div><strong>关联资料</strong>${entityRefsHtml}</div>
|
|
3167
|
+
${item.resolutionNote ? `<div><strong>处理结果</strong><pre class="task-detail-result">${esc(item.resolutionNote)}</pre></div>` : ""}
|
|
3168
|
+
</div>`,
|
|
3169
|
+
async () => undefined,
|
|
3170
|
+
"审核建议",
|
|
3171
|
+
{
|
|
3172
|
+
submitLabel: "关闭",
|
|
3173
|
+
wide: true,
|
|
3174
|
+
hideCancel: true,
|
|
3175
|
+
meta: `创建于 ${formatDateTime(item.createdAt)} · 更新于 ${formatDateTime(item.updatedAt)}`
|
|
3176
|
+
});
|
|
3177
|
+
}
|
|
3178
|
+
|
|
2862
3179
|
function settingRecordActions(item) {
|
|
2863
3180
|
return canEditModule("settings")
|
|
2864
3181
|
? recordCardEditButton("edit-setting", item.id, `设定“${item.title}”`)
|
|
@@ -2867,7 +3184,7 @@ function settingRecordActions(item) {
|
|
|
2867
3184
|
|
|
2868
3185
|
function renderSettingCards(records) {
|
|
2869
3186
|
return `<div class="card-grid">${records.map((item) => `
|
|
2870
|
-
<article class="record-card"><small>${esc(item.category)} · ${item.locked ? "已锁定" : esc(item.status)}</small>
|
|
3187
|
+
<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
3188
|
<h3>${esc(item.title)}</h3><div class="record-markdown-preview message-body">${renderMarkdown(item.content) || '<p class="markdown-editor-empty">暂无正文</p>'}</div>
|
|
2872
3189
|
<div class="card-actions">${settingRecordActions(item)}</div></article>`).join("")}</div>`;
|
|
2873
3190
|
}
|
|
@@ -2876,7 +3193,7 @@ function renderSettingRows(records) {
|
|
|
2876
3193
|
return `<div class="module-row-list">${records.map((item) => {
|
|
2877
3194
|
const preview = moduleRowPreview(item.content);
|
|
2878
3195
|
return `
|
|
2879
|
-
<article class="record-card module-row"><small>${esc(item.category)} · ${item.locked ? "已锁定" : esc(item.status)}</small>
|
|
3196
|
+
<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
3197
|
<h3>${esc(item.title)}</h3><p class="module-row-preview" title="${esc(preview)}">${esc(preview)}</p>
|
|
2881
3198
|
<div class="card-actions">${settingRecordActions(item)}</div></article>`;
|
|
2882
3199
|
}).join("")}</div>`;
|
|
@@ -2891,22 +3208,26 @@ async function renderSettings() {
|
|
|
2891
3208
|
? `${layout === "rows" ? renderSettingRows(records) : renderSettingCards(records)}`
|
|
2892
3209
|
: emptyModule("还没有世界观设定", "新建规则、地点、组织、科技或创作约束。AI 提取的候选也会进入这里。");
|
|
2893
3210
|
bindModuleLayoutToggle(renderSettings);
|
|
3211
|
+
bindRecordPreview("[data-open-setting]", (id) => openSettingEditor(records.find((item) => item.id === id), { readOnly: true }));
|
|
2894
3212
|
$("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => openSettingEditor(records.find((item) => item.id === button.dataset.editSetting))));
|
|
2895
3213
|
bindEntityHistoryButtons(async () => { await renderSettings(); await loadAiReferences(); });
|
|
2896
3214
|
}
|
|
2897
3215
|
|
|
2898
|
-
async function renderCharacters() {
|
|
2899
|
-
[
|
|
2900
|
-
apiPage(`/api/works/${state.work.id}/characters
|
|
3216
|
+
async function renderCharacters(page = characterListPage) {
|
|
3217
|
+
const [characterPage, races, organizations] = await Promise.all([
|
|
3218
|
+
apiPage(`/api/works/${state.work.id}/characters`, page),
|
|
2901
3219
|
canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
|
|
2902
3220
|
canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
|
|
2903
3221
|
]);
|
|
3222
|
+
if (!characterPage.items.length && page > 1) return renderCharacters(page - 1);
|
|
3223
|
+
characterListPage = characterPage.page;
|
|
3224
|
+
[state.characters, state.races, state.organizations] = [characterPage.items, races, organizations];
|
|
2904
3225
|
const layout = readModuleLayout();
|
|
2905
3226
|
const characterActions = (item) => recordCardEditButton("edit-character", item.id, `角色“${item.name}”`);
|
|
2906
3227
|
const characterCards = () => `<div class="card-grid">${state.characters.map((item) => {
|
|
2907
3228
|
const details = normalizeCharacterDetails(item.attributes?.details);
|
|
2908
3229
|
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>
|
|
3230
|
+
<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
3231
|
<h3>${esc(item.name)}</h3>
|
|
2911
3232
|
${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
|
|
2912
3233
|
${item.aliases.length ? `<div class="character-aliases">${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
|
|
@@ -2928,19 +3249,32 @@ async function renderCharacters() {
|
|
|
2928
3249
|
].filter(Boolean).join(" · ");
|
|
2929
3250
|
const line = meta ? `${meta} · ${preview}` : preview;
|
|
2930
3251
|
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>
|
|
3252
|
+
<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)}">
|
|
3253
|
+
<small>${item.lockedFields.length ? `锁定 ${item.lockedFields.length} 项` : esc(characterVisibilityLabel(item.visibility))}</small>
|
|
2933
3254
|
<h3>${esc(item.name)}</h3>
|
|
2934
3255
|
<p class="module-row-preview" title="${esc(line)}">${esc(line)}</p>
|
|
2935
3256
|
<div class="card-actions">${characterActions(item)}</div>
|
|
2936
3257
|
</article>`;
|
|
2937
3258
|
}).join("")}</div>`;
|
|
2938
|
-
const
|
|
3259
|
+
const hasMultipleCharacters = characterPage.page > 1 || characterPage.hasMore || state.characters.length > 1;
|
|
3260
|
+
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>` : "";
|
|
3261
|
+
const pagination = state.characters.length && (characterPage.page > 1 || characterPage.hasMore)
|
|
3262
|
+
? `<nav class="module-pagination" aria-label="角色列表分页">
|
|
3263
|
+
<button type="button" data-character-page="${characterPage.page - 1}" ${characterPage.page <= 1 ? "disabled" : ""}>上一页</button>
|
|
3264
|
+
<span>第 ${characterPage.page} 页 · 本页 ${state.characters.length} 个角色</span>
|
|
3265
|
+
<button type="button" data-character-page="${characterPage.nextPage ?? characterPage.page + 1}" ${characterPage.hasMore ? "" : "disabled"}>下一页</button>
|
|
3266
|
+
</nav>`
|
|
3267
|
+
: "";
|
|
2939
3268
|
if (state.characters.length) mountModuleLayoutToggle(layout, "角色列表样式");
|
|
2940
3269
|
$("#module-content").innerHTML = auditPanel + (state.characters.length
|
|
2941
|
-
? `${layout === "rows" ? characterRows() : characterCards()}`
|
|
3270
|
+
? `${layout === "rows" ? characterRows() : characterCards()}${pagination}`
|
|
2942
3271
|
: emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
|
|
2943
3272
|
bindModuleLayoutToggle(renderCharacters);
|
|
3273
|
+
$("#module-content").querySelectorAll("[data-character-page]").forEach((button) => button.addEventListener("click", async () => {
|
|
3274
|
+
if (button.disabled) return;
|
|
3275
|
+
$("#module-content").querySelectorAll("[data-character-page]").forEach((control) => { control.disabled = true; });
|
|
3276
|
+
await renderCharacters(Number(button.dataset.characterPage));
|
|
3277
|
+
}));
|
|
2944
3278
|
$("#create-character-audit-task")?.addEventListener("click", async () => {
|
|
2945
3279
|
const button = $("#create-character-audit-task");
|
|
2946
3280
|
button.disabled = true;
|
|
@@ -2953,15 +3287,7 @@ async function renderCharacters() {
|
|
|
2953
3287
|
button.disabled = false;
|
|
2954
3288
|
}
|
|
2955
3289
|
});
|
|
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
|
-
});
|
|
3290
|
+
bindRecordPreview("[data-open-character]", (id) => openCharacterEditor(state.characters.find((item) => item.id === id), { readOnly: true }));
|
|
2965
3291
|
$("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterEditor(state.characters.find((item) => item.id === button.dataset.editCharacter))));
|
|
2966
3292
|
}
|
|
2967
3293
|
|
|
@@ -2981,7 +3307,7 @@ async function renderRaces() {
|
|
|
2981
3307
|
const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
|
|
2982
3308
|
<summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
|
|
2983
3309
|
<div class="race-tree-branch">
|
|
2984
|
-
<article class="record-card race-card${canEditRaces ? " has-card-edit" : ""}"><small>${item.memberIds.length} 位直接角色 · ${item.settings.length} 条自身设定</small>
|
|
3310
|
+
<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
3311
|
<div class="race-path" aria-label="种族路径">${esc(racePathLabel(item))}</div>
|
|
2986
3312
|
<p>${esc(item.description || "尚未填写种族简介")}</p>
|
|
2987
3313
|
<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 +3321,7 @@ async function renderRaces() {
|
|
|
2995
3321
|
const preview = moduleRowPreview(item.description || "尚未填写种族简介");
|
|
2996
3322
|
const meta = `${item.memberIds.length} 位直接角色 · ${item.settings.length ? "已填写共同设定" : "暂无共同设定"}`;
|
|
2997
3323
|
return `
|
|
2998
|
-
<article class="record-card module-row race-card">
|
|
3324
|
+
<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
3325
|
<small>${esc(meta)}</small>
|
|
3000
3326
|
<h3>${esc(item.name)}<span class="module-row-path">${esc(racePathLabel(item))}</span></h3>
|
|
3001
3327
|
<p class="module-row-preview" title="${esc(preview)}">${esc(preview)}${item.members.length ? ` · ${esc(item.members.map((member) => member.name).join("、"))}` : ""}</p>
|
|
@@ -3007,6 +3333,7 @@ async function renderRaces() {
|
|
|
3007
3333
|
? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>`}`
|
|
3008
3334
|
: emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
|
|
3009
3335
|
bindModuleLayoutToggle(renderRaces);
|
|
3336
|
+
bindRecordPreview("[data-open-race]", (id) => openRaceDialog(state.races.find((item) => item.id === id), { readOnly: true }));
|
|
3010
3337
|
$("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => openRaceDialog(state.races.find((item) => item.id === button.dataset.editRace))));
|
|
3011
3338
|
bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
|
|
3012
3339
|
}
|
|
@@ -3083,7 +3410,7 @@ async function renderTimeline() {
|
|
|
3083
3410
|
$("#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
3411
|
state.timelineTracks = tracks;
|
|
3085
3412
|
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>`;
|
|
3413
|
+
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
3414
|
$("#module-content").innerHTML = `<div class="timeline-kanban" data-testid="timeline-kanban">${lanes.map((track) => {
|
|
3088
3415
|
const laneEvents = events.filter((item) => (item.trackId ?? "") === track.id);
|
|
3089
3416
|
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 +3451,19 @@ async function renderOutlines() {
|
|
|
3124
3451
|
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
3452
|
const foreshadowCards = () => `<div class="card-grid foreshadow-grid">${foreshadows.map((item) => `
|
|
3126
3453
|
<article class="record-card foreshadow-card ${item.overdue ? "is-overdue" : ""}">
|
|
3127
|
-
<small>${esc(item.importance)} · ${esc(item.status)}${item.overdue ? " · 已逾期" : ""}</small>
|
|
3454
|
+
<small>${esc(levelLabel(item.importance))} · ${esc(foreshadowStatusLabel(item.status))}${item.overdue ? " · 已逾期" : ""}</small>
|
|
3128
3455
|
<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(
|
|
3456
|
+
<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
3457
|
<div class="card-actions">${foreshadowActions(item)}</div>
|
|
3131
3458
|
</article>`).join("")}</div>`;
|
|
3132
3459
|
const foreshadowRows = () => `<div class="module-row-list">${foreshadows.map((item) => {
|
|
3133
3460
|
const preview = moduleRowPreview(item.description || "暂无说明");
|
|
3134
3461
|
const links = item.occurrences.length
|
|
3135
|
-
? item.occurrences.map((link) => `${(
|
|
3462
|
+
? item.occurrences.map((link) => `${occurrenceRoleLabel(link.role)} · ${link.volumeTitle} / ${link.chapterTitle}`).join(";")
|
|
3136
3463
|
: "尚未关联章节";
|
|
3137
3464
|
return `
|
|
3138
3465
|
<article class="record-card module-row foreshadow-card ${item.overdue ? "is-overdue" : ""}">
|
|
3139
|
-
<small>${esc(item.importance)} · ${esc(item.status)}${item.overdue ? " · 已逾期" : ""}</small>
|
|
3466
|
+
<small>${esc(levelLabel(item.importance))} · ${esc(foreshadowStatusLabel(item.status))}${item.overdue ? " · 已逾期" : ""}</small>
|
|
3140
3467
|
<h3>${esc(item.title)}</h3>
|
|
3141
3468
|
<p class="module-row-preview" title="${esc(`${preview} · ${links}`)}">${esc(preview)} · ${esc(links)}</p>
|
|
3142
3469
|
<div class="card-actions">${foreshadowActions(item)}</div>
|
|
@@ -3148,7 +3475,7 @@ async function renderOutlines() {
|
|
|
3148
3475
|
if (foreshadows.length) mountModuleLayoutToggle(layout, "伏笔列表样式");
|
|
3149
3476
|
const outlineHtml = outlines.length ? `<div class="outline-list">${outlines.map((item) => `
|
|
3150
3477
|
<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>
|
|
3478
|
+
<div><small>${esc(item.volumeTitle)} · ${esc(outlineStatusLabel(item.status))}</small><h3>${esc(item.chapterTitle)}</h3></div>
|
|
3152
3479
|
<div><b>目标</b><p>${esc(item.goal || "未填写")}</p></div>
|
|
3153
3480
|
<div><b>冲突</b><p>${esc(item.conflict || "未填写")}</p></div>
|
|
3154
3481
|
<div><b>转折</b><p>${esc(item.turningPoint || "未填写")}</p></div>
|
|
@@ -3172,7 +3499,7 @@ async function renderRelationships() {
|
|
|
3172
3499
|
state.relationshipGraph = graph;
|
|
3173
3500
|
$("#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
3501
|
<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>'}`;
|
|
3502
|
+
<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
3503
|
const openGalaxy = () => {
|
|
3177
3504
|
state.galaxy?.destroy();
|
|
3178
3505
|
state.galaxy = createGalaxyRenderer($("#relationship-galaxy-dialog"), graph, { workId: state.work.id });
|
|
@@ -3216,11 +3543,11 @@ async function renderReviews() {
|
|
|
3216
3543
|
const actions = mergeActions || keepSeparateAction
|
|
3217
3544
|
? `<div class="card-actions character-duplicate-actions">${mergeActions}${keepSeparateAction}</div>`
|
|
3218
3545
|
: "";
|
|
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>`;
|
|
3546
|
+
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
3547
|
};
|
|
3221
3548
|
const layout = readModuleLayout();
|
|
3222
3549
|
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>
|
|
3550
|
+
<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
3551
|
<p>${esc(item.description)}${item.suggestion ? `\n建议:${esc(item.suggestion)}` : ""}</p>
|
|
3225
3552
|
${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
3553
|
const reviewRow = (item) => {
|
|
@@ -3229,8 +3556,8 @@ async function renderReviews() {
|
|
|
3229
3556
|
}
|
|
3230
3557
|
const preview = moduleRowPreview(`${item.description || ""}${item.suggestion ? ` 建议:${item.suggestion}` : ""}`);
|
|
3231
3558
|
return `
|
|
3232
|
-
<article class="record-card module-row">
|
|
3233
|
-
<small>${esc(item.itemType)} · ${esc(item.severity)} · ${esc(item.status)}</small>
|
|
3559
|
+
<article class="record-card module-row preview-record-card" data-open-review="${esc(item.id)}" role="button" tabindex="0" aria-label="查看审核建议 ${esc(item.title)}">
|
|
3560
|
+
<small>${esc(reviewItemTypeLabel(item.itemType))} · ${esc(reviewSeverityLabel(item.severity))} · ${esc(reviewStatusLabel(item.status))}</small>
|
|
3234
3561
|
<h3>${esc(item.title)}</h3>
|
|
3235
3562
|
<p class="module-row-preview" title="${esc(preview)}">${esc(preview)}</p>
|
|
3236
3563
|
<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 +3568,7 @@ async function renderReviews() {
|
|
|
3241
3568
|
? `${layout === "rows" ? `<div class="module-row-list">${reviews.map(reviewRow).join("")}</div>` : `<div class="card-grid">${reviews.map(reviewCard).join("")}</div>`}`
|
|
3242
3569
|
: emptyModule("没有待审核事项", "候选设定、冲突与低置信度结论会集中显示在这里。");
|
|
3243
3570
|
bindModuleLayoutToggle(renderReviews);
|
|
3571
|
+
bindRecordPreview("[data-open-review]", (id) => openReviewDetailDialog(reviews.find((item) => item.id === id)));
|
|
3244
3572
|
$("#module-content").querySelectorAll("[data-review-id]").forEach((button) => button.addEventListener("click", async () => {
|
|
3245
3573
|
await api(`/api/reviews/${button.dataset.reviewId}`, { method: "PATCH", body: { status: button.dataset.reviewStatus } });
|
|
3246
3574
|
await renderReviews();
|
|
@@ -3310,8 +3638,8 @@ async function renderTasks() {
|
|
|
3310
3638
|
</section>
|
|
3311
3639
|
${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
3640
|
<tr>
|
|
3313
|
-
<td>${esc(analysisTaskTypeLabel(item.taskType))}
|
|
3314
|
-
<td>${esc(item.scopeSummary || item.scope?.type || "book")}</td>
|
|
3641
|
+
<td>${esc(analysisTaskTypeLabel(item.taskType))}</td>
|
|
3642
|
+
<td>${esc(item.scopeSummary || taskScopeLabel(item.scope?.type || "book"))}</td>
|
|
3315
3643
|
<td>${esc(analysisTaskStatusLabel(item.status))}</td>
|
|
3316
3644
|
<td>${Number(item.progress ?? 0)}%</td>
|
|
3317
3645
|
<td class="task-row-actions">
|
|
@@ -3419,7 +3747,7 @@ function openTaskDetailDialog(task) {
|
|
|
3419
3747
|
openDialog("任务详情",
|
|
3420
3748
|
`<div class="task-detail">
|
|
3421
3749
|
<p><strong>任务 ID</strong><br><code>${esc(task.id)}</code></p>
|
|
3422
|
-
<p><strong>类型</strong> ${esc(analysisTaskTypeLabel(task.taskType))}
|
|
3750
|
+
<p><strong>类型</strong> ${esc(analysisTaskTypeLabel(task.taskType))}</p>
|
|
3423
3751
|
<p><strong>状态</strong> ${esc(analysisTaskStatusLabel(task.status))} · 进度 ${Number(task.progress ?? 0)}%</p>
|
|
3424
3752
|
<p><strong>范围摘要</strong> ${esc(task.scopeSummary || "未指定")}</p>
|
|
3425
3753
|
<div><strong>范围详情</strong><ul>${detailHtml}</ul></div>
|
|
@@ -3434,11 +3762,11 @@ function openTaskDetailDialog(task) {
|
|
|
3434
3762
|
|
|
3435
3763
|
function renderProviderCards(providers, models) {
|
|
3436
3764
|
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 ? "启用" : "停用"} ·
|
|
3765
|
+
<article class="record-card provider-card"><small>平台级 · ${esc(providerStatusLabel(provider.status))} · ${esc(providerConnectionLabel(provider.connectionStatus))}</small><h3>${esc(provider.name)}</h3>
|
|
3766
|
+
<p>${esc(provider.baseUrl)}\n密钥:${esc(provider.apiKey)}\n并发:${provider.concurrencyLimit} · 每分钟请求:${provider.rpmLimit} · 最大输出:${provider.maxTokens ?? 32000}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
|
|
3767
|
+
<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
3768
|
<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
|
|
3769
|
+
: emptyModule("尚未配置 AI 供应商", "添加 OpenAI 兼容接口地址和密钥,测试成功后再添加模型。");
|
|
3442
3770
|
}
|
|
3443
3771
|
|
|
3444
3772
|
function bindPlatformProviderActions(host, providers, models) {
|
|
@@ -3459,10 +3787,10 @@ function renderTaskDefaults(models, providers, taskDefaults) {
|
|
|
3459
3787
|
const providerById = new Map(providers.map((provider) => [provider.id, provider]));
|
|
3460
3788
|
const defaultModelByTask = new Map(taskDefaults.map((item) => [item.taskType, item.model.id]));
|
|
3461
3789
|
return models.length ? `<section class="config-section">
|
|
3462
|
-
<div class="config-section-header"><div><h2>本书任务默认模型</h2><p
|
|
3790
|
+
<div class="config-section-header"><div><h2>本书任务默认模型</h2><p>选择平台模型作为当前作品的默认模型;所有请求都会携带最大输出令牌数,默认值为 32000。</p></div></div>
|
|
3463
3791
|
<table class="table-list"><thead><tr><th>任务能力</th><th>默认模型</th></tr></thead><tbody>${taskTypeLabels.map(([taskType, label]) => {
|
|
3464
3792
|
const currentModelId = defaultModelByTask.get(taskType) ?? "";
|
|
3465
|
-
return `<tr><td>${esc(label)}
|
|
3793
|
+
return `<tr><td>${esc(label)}</td><td><select class="default-model-select" data-task-default="${esc(taskType)}">
|
|
3466
3794
|
<option value="" disabled ${currentModelId ? "" : "selected"}>请选择模型</option>
|
|
3467
3795
|
${models.map((model) => {
|
|
3468
3796
|
const provider = providerById.get(model.providerId);
|
|
@@ -3830,7 +4158,7 @@ function renderKnowledgeMarkdownSections() {
|
|
|
3830
4158
|
const host = $("#knowledge-markdown-sections");
|
|
3831
4159
|
if (!host) return;
|
|
3832
4160
|
const label = knowledgeEditorKind === "race" ? "种族" : "组织";
|
|
3833
|
-
const canEdit = canEditModule(knowledgeEditorKind === "race" ? "races" : "organizations");
|
|
4161
|
+
const canEdit = !entityEditorReadOnly && canEditModule(knowledgeEditorKind === "race" ? "races" : "organizations");
|
|
3834
4162
|
const sections = Array.isArray(knowledgeEditorSections) ? knowledgeEditorSections : [];
|
|
3835
4163
|
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
4164
|
host.querySelector("[data-knowledge-section-create]")?.addEventListener("click", () => void openKnowledgeSectionEditor());
|
|
@@ -3934,8 +4262,11 @@ function openDialog(title, fields, onSubmit, eyebrow = "新增", options = {}) {
|
|
|
3934
4262
|
void discardPendingMarkdownAttachments();
|
|
3935
4263
|
$("#dialog-title").textContent = title;
|
|
3936
4264
|
$("#dialog-eyebrow").textContent = eyebrow;
|
|
4265
|
+
$("#dialog-meta").textContent = options.meta ?? "";
|
|
4266
|
+
$("#dialog-meta").classList.toggle("hidden", !options.meta);
|
|
3937
4267
|
$("#dialog-fields").innerHTML = fields;
|
|
3938
4268
|
$("#dialog-submit").textContent = options.submitLabel ?? "保存";
|
|
4269
|
+
$("#dynamic-form .dialog-actions [value='cancel']").classList.toggle("hidden", Boolean(options.hideCancel));
|
|
3939
4270
|
$("#form-dialog").classList.toggle("wide-dialog", Boolean(options.wide));
|
|
3940
4271
|
bindDynamicListControls($("#dialog-fields"));
|
|
3941
4272
|
bindRelationshipKeywordControls($("#dialog-fields"));
|
|
@@ -4111,27 +4442,32 @@ function openVolumeDialog(item) {
|
|
|
4111
4442
|
}, "分卷设置");
|
|
4112
4443
|
}
|
|
4113
4444
|
|
|
4114
|
-
function openSettingEditor(item = null) {
|
|
4445
|
+
function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
4446
|
+
entityEditorReadOnly = readOnly;
|
|
4115
4447
|
destroyVditorEditor(settingEditorVditor);
|
|
4116
4448
|
settingEditorVditor = null;
|
|
4117
4449
|
settingEditorItem = item;
|
|
4118
|
-
$("#setting-editor-eyebrow").textContent = item ? "
|
|
4450
|
+
$("#setting-editor-eyebrow").textContent = item ? "编辑设定" : "新建设定";
|
|
4119
4451
|
$("#setting-editor-name").value = item?.title ?? "";
|
|
4120
4452
|
$("#setting-editor-category").value = item?.category ?? "世界规则";
|
|
4121
4453
|
$("#setting-editor-locked").checked = Boolean(item?.locked);
|
|
4122
4454
|
$("#setting-editor-body").value = item?.content ?? "";
|
|
4123
|
-
$("#setting-change-note").value = "";
|
|
4124
|
-
$("#setting-change-note-field").classList.toggle("hidden", !item);
|
|
4125
4455
|
$("#setting-editor-submit").textContent = item ? "保存新版本" : "创建设定";
|
|
4126
|
-
const viewOnly = !canEditModule("settings");
|
|
4456
|
+
const viewOnly = readOnly || !canEditModule("settings");
|
|
4457
|
+
$("#setting-editor-form").classList.toggle("is-read-only", viewOnly);
|
|
4458
|
+
$("#setting-editor-eyebrow").textContent = readOnly ? "阅读设定" : item ? "编辑设定" : "新建设定";
|
|
4127
4459
|
$("#setting-editor-form").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = viewOnly; });
|
|
4128
4460
|
$("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
|
|
4129
4461
|
$("#setting-editor-submit").classList.toggle("hidden", viewOnly);
|
|
4130
|
-
const
|
|
4131
|
-
|
|
4462
|
+
const editButton = $("#setting-editor-edit");
|
|
4463
|
+
editButton.classList.toggle("hidden", !readOnly || !canEditModule("settings"));
|
|
4464
|
+
editButton.onclick = () => openSettingEditor(item);
|
|
4465
|
+
const showManagementActions = Boolean(item && !viewOnly);
|
|
4132
4466
|
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");
|
|
4467
|
+
$("#setting-editor-confirm").classList.toggle("hidden", !showManagementActions || item?.status !== "pending");
|
|
4468
|
+
$("#setting-editor-deprecate").classList.toggle("hidden", !showManagementActions || item?.status !== "pending");
|
|
4469
|
+
$("#setting-editor-history").classList.toggle("hidden", !showManagementActions);
|
|
4470
|
+
$("#setting-editor-delete").classList.toggle("hidden", !showManagementActions);
|
|
4135
4471
|
$("#setting-editor-history").onclick = async () => {
|
|
4136
4472
|
if (!item) return;
|
|
4137
4473
|
if (!(await closeEntityEditor())) return;
|
|
@@ -4166,7 +4502,7 @@ function openSettingEditor(item = null) {
|
|
|
4166
4502
|
});
|
|
4167
4503
|
$("#setting-editor-form").onsubmit = async (event) => {
|
|
4168
4504
|
event.preventDefault();
|
|
4169
|
-
if (!canEditModule("settings")) return;
|
|
4505
|
+
if (readOnly || !canEditModule("settings")) return;
|
|
4170
4506
|
const form = new FormData(event.currentTarget);
|
|
4171
4507
|
const submit = $("#setting-editor-submit");
|
|
4172
4508
|
submit.disabled = true;
|
|
@@ -4183,6 +4519,13 @@ function openSettingEditor(item = null) {
|
|
|
4183
4519
|
$("#setting-editor-body").focus();
|
|
4184
4520
|
return;
|
|
4185
4521
|
}
|
|
4522
|
+
const changeNote = item ? await inputToast("简要说明这次修改,便于以后查看版本历史。可以留空。", {
|
|
4523
|
+
title: "填写版本说明",
|
|
4524
|
+
inputLabel: "版本说明",
|
|
4525
|
+
placeholder: "例如:补充纪元历法限制",
|
|
4526
|
+
confirmLabel: "保存新版本"
|
|
4527
|
+
}) : "";
|
|
4528
|
+
if (changeNote === null) return;
|
|
4186
4529
|
const locked = form.get("locked") === "on";
|
|
4187
4530
|
const body = {
|
|
4188
4531
|
title,
|
|
@@ -4190,8 +4533,9 @@ function openSettingEditor(item = null) {
|
|
|
4190
4533
|
content,
|
|
4191
4534
|
locked,
|
|
4192
4535
|
status: locked ? "confirmed" : (item?.status ?? "draft"),
|
|
4193
|
-
...(item ? { changeNote
|
|
4536
|
+
...(item ? { changeNote } : {})
|
|
4194
4537
|
};
|
|
4538
|
+
if (!(await confirmConcurrentSave())) return;
|
|
4195
4539
|
await api(item ? `/api/settings/${item.id}` : `/api/works/${state.work.id}/settings`, { method: item ? "PATCH" : "POST", body });
|
|
4196
4540
|
await cleanupPendingMarkdownAttachments(body.content);
|
|
4197
4541
|
entityEditorDirty = false;
|
|
@@ -4204,12 +4548,12 @@ function openSettingEditor(item = null) {
|
|
|
4204
4548
|
submit.disabled = false;
|
|
4205
4549
|
}
|
|
4206
4550
|
};
|
|
4207
|
-
showEntityEditorPage("setting");
|
|
4551
|
+
showEntityEditorPage("setting", { readOnly });
|
|
4208
4552
|
settingEditorVditor = createVditorEditor($("#setting-editor-markdown"), item?.content ?? "", {
|
|
4209
4553
|
onInput: (markdown) => { $("#setting-editor-body").value = markdown; markEntityEditorDirty(); },
|
|
4210
4554
|
readOnly: viewOnly
|
|
4211
4555
|
});
|
|
4212
|
-
$("#setting-editor-name").focus();
|
|
4556
|
+
(readOnly ? $("#setting-editor-back") : $("#setting-editor-name")).focus();
|
|
4213
4557
|
}
|
|
4214
4558
|
|
|
4215
4559
|
function characterEditorSection(key, title, description, content) {
|
|
@@ -4236,14 +4580,6 @@ function setCharacterHistoryVisible(visible) {
|
|
|
4236
4580
|
$("#character-history-button").setAttribute("aria-expanded", String(visible));
|
|
4237
4581
|
}
|
|
4238
4582
|
|
|
4239
|
-
const relationshipCategoryLabels = {
|
|
4240
|
-
family: "亲属",
|
|
4241
|
-
social: "社交",
|
|
4242
|
-
emotional: "情感",
|
|
4243
|
-
conflict: "冲突",
|
|
4244
|
-
uncertain: "未确定"
|
|
4245
|
-
};
|
|
4246
|
-
|
|
4247
4583
|
function renderCharacterEditorRelationships() {
|
|
4248
4584
|
const host = $("#character-editor-relationships");
|
|
4249
4585
|
if (!host) return;
|
|
@@ -4261,15 +4597,15 @@ function renderCharacterEditorRelationships() {
|
|
|
4261
4597
|
const isSource = relationship.fromCharacterId === characterId;
|
|
4262
4598
|
const otherCharacterId = isSource ? relationship.toCharacterId : relationship.fromCharacterId;
|
|
4263
4599
|
const direction = relationship.directed ? (isSource ? "→" : "←") : "↔";
|
|
4264
|
-
const category =
|
|
4600
|
+
const category = relationshipCategoryLabel(relationship.category);
|
|
4265
4601
|
const relationLabel = [category, relationship.subtype].filter(Boolean).join(" · ") || "未细分";
|
|
4266
4602
|
const keywords = Array.isArray(relationship.keywords) ? relationship.keywords : [];
|
|
4267
4603
|
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>
|
|
4604
|
+
<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
4605
|
<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
4606
|
</article>`;
|
|
4271
4607
|
}).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>'}`;
|
|
4608
|
+
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
4609
|
host.querySelectorAll("[data-character-relationship-edit]").forEach((button) => button.addEventListener("click", () => {
|
|
4274
4610
|
const relationship = characterEditorRelationships.find((item) => item.id === button.dataset.characterRelationshipEdit);
|
|
4275
4611
|
if (relationship) void openRelationshipDialog(relationship, { characterId });
|
|
@@ -4413,10 +4749,12 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
|
|
|
4413
4749
|
},
|
|
4414
4750
|
input: (markdown) => {
|
|
4415
4751
|
normalizeVditorAttachmentImages(editor);
|
|
4752
|
+
updateVditorWordCount(editor, markdown);
|
|
4416
4753
|
onInput(markdown);
|
|
4417
4754
|
},
|
|
4418
4755
|
after: () => {
|
|
4419
4756
|
normalizeVditorAttachmentImages(editor);
|
|
4757
|
+
updateVditorWordCount(editor, value);
|
|
4420
4758
|
if (readOnly) editor?.disabled();
|
|
4421
4759
|
}
|
|
4422
4760
|
});
|
|
@@ -4424,10 +4762,26 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
|
|
|
4424
4762
|
attachmentObserver.observe(host, { subtree: true, childList: true, attributes: true, attributeFilter: ["src"] });
|
|
4425
4763
|
editor.__attachmentObserver = attachmentObserver;
|
|
4426
4764
|
host.__vditor = editor;
|
|
4427
|
-
if (readOnly) editor.disabled();
|
|
4428
4765
|
return editor;
|
|
4429
4766
|
}
|
|
4430
4767
|
|
|
4768
|
+
function updateVditorWordCount(editor, markdown) {
|
|
4769
|
+
const toolbar = editor?.vditor?.toolbar?.element;
|
|
4770
|
+
if (!toolbar) return;
|
|
4771
|
+
let counter = toolbar.querySelector("[data-markdown-word-count]");
|
|
4772
|
+
if (!counter) {
|
|
4773
|
+
counter = document.createElement("span");
|
|
4774
|
+
counter.className = "markdown-word-count";
|
|
4775
|
+
counter.dataset.markdownWordCount = "true";
|
|
4776
|
+
counter.setAttribute("role", "status");
|
|
4777
|
+
counter.setAttribute("aria-live", "polite");
|
|
4778
|
+
counter.title = "当前 Markdown 正文的字数,不计空白字符";
|
|
4779
|
+
toolbar.append(counter);
|
|
4780
|
+
}
|
|
4781
|
+
const count = Array.from(String(markdown ?? "").replace(/\s/gu, "")).length;
|
|
4782
|
+
counter.textContent = `${count.toLocaleString("zh-CN")} 字`;
|
|
4783
|
+
}
|
|
4784
|
+
|
|
4431
4785
|
function transformVditorPreview(html) {
|
|
4432
4786
|
return String(html ?? "").replace(/(<img\b[^>]*\bsrc\s*=\s*)(["'])attachment:\/\/([A-Za-z0-9_-]{1,300})\2/giu, (_match, prefix, quote, attachmentId) => `${prefix}${quote}/api/attachments/${encodeURIComponent(attachmentId)}/content${quote}`);
|
|
4433
4787
|
}
|
|
@@ -4632,6 +4986,10 @@ async function openCharacterSectionEditor(section = null) {
|
|
|
4632
4986
|
button.disabled = true;
|
|
4633
4987
|
const contentMarkdown = characterSectionVditor?.getValue() ?? "";
|
|
4634
4988
|
try {
|
|
4989
|
+
if (!(await confirmConcurrentSave())) {
|
|
4990
|
+
button.disabled = false;
|
|
4991
|
+
return;
|
|
4992
|
+
}
|
|
4635
4993
|
const saved = await api(section ? `/api/character-sections/${section.id}` : `/api/characters/${characterEditorItem.id}/sections`, {
|
|
4636
4994
|
method: section ? "PATCH" : "POST",
|
|
4637
4995
|
body: {
|
|
@@ -4691,9 +5049,10 @@ function renderCharacterMarkdownSections() {
|
|
|
4691
5049
|
host.innerHTML = '<div class="character-editor-empty-field" role="note" aria-label="保存角色提示"><b>请先保存当前角色</b><span>完成基础资料后,请点击页面底部的“创建人物档案”。保存成功后即可新建 Markdown 档案章节。</span></div>';
|
|
4692
5050
|
return;
|
|
4693
5051
|
}
|
|
4694
|
-
const
|
|
5052
|
+
const canEdit = !entityEditorReadOnly && canEditModule("characters");
|
|
5053
|
+
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
5054
|
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>${
|
|
5055
|
+
<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
5056
|
<div class="character-markdown-document message-body">${renderMarkdown(section.contentMarkdown) || '<p class="character-markdown-empty">本章节暂无正文。</p>'}</div>
|
|
4698
5057
|
<div data-character-section-versions-host="${esc(section.id)}"></div>
|
|
4699
5058
|
</article>`).join("");
|
|
@@ -4840,7 +5199,7 @@ function renderCharacterHistory() {
|
|
|
4840
5199
|
<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
5200
|
<p>${esc(version.changeNote || "未填写版本说明")}</p>
|
|
4842
5201
|
<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>`}
|
|
5202
|
+
${isCurrent ? '<button type="button" disabled>当前版本</button>' : entityEditorReadOnly ? '<button type="button" disabled>历史版本</button>' : `<button type="button" data-character-restore="${version.versionNo}">回滚到此版本</button>`}
|
|
4844
5203
|
</article>`;
|
|
4845
5204
|
}).join("");
|
|
4846
5205
|
host.querySelectorAll("[data-character-restore]").forEach((button) => button.addEventListener("click", async () => {
|
|
@@ -4893,7 +5252,8 @@ async function showCharacterHistory() {
|
|
|
4893
5252
|
}
|
|
4894
5253
|
}
|
|
4895
5254
|
|
|
4896
|
-
async function openCharacterEditor(item = null) {
|
|
5255
|
+
async function openCharacterEditor(item = null, { readOnly = false } = {}) {
|
|
5256
|
+
entityEditorReadOnly = readOnly;
|
|
4897
5257
|
[state.races, state.organizations] = await Promise.all([
|
|
4898
5258
|
canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
|
|
4899
5259
|
canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
|
|
@@ -4912,7 +5272,7 @@ async function openCharacterEditor(item = null) {
|
|
|
4912
5272
|
$("#character-history-button").title = item ? "查看、比较和回滚历史版本" : "创建人物档案后即可查看版本历史";
|
|
4913
5273
|
const characterMergeButton = $("#character-merge-button");
|
|
4914
5274
|
const characterDeleteButton = $("#character-delete-button");
|
|
4915
|
-
const canManageCharacter = Boolean(item && canEditModule("characters"));
|
|
5275
|
+
const canManageCharacter = Boolean(item && !readOnly && canEditModule("characters"));
|
|
4916
5276
|
characterMergeButton.classList.toggle("hidden", !canManageCharacter || state.characters.length < 2);
|
|
4917
5277
|
characterDeleteButton.classList.toggle("hidden", !canManageCharacter);
|
|
4918
5278
|
characterMergeButton.onclick = async () => {
|
|
@@ -4940,14 +5300,18 @@ async function openCharacterEditor(item = null) {
|
|
|
4940
5300
|
};
|
|
4941
5301
|
setCharacterHistoryVisible(false);
|
|
4942
5302
|
renderCharacterEditorFields(item);
|
|
4943
|
-
const viewOnly = !canEditModule("characters");
|
|
5303
|
+
const viewOnly = readOnly || !canEditModule("characters");
|
|
4944
5304
|
if (viewOnly) {
|
|
4945
|
-
$("#character-editor-eyebrow").textContent = "人物档案";
|
|
5305
|
+
$("#character-editor-eyebrow").textContent = readOnly ? "阅读人物档案" : "人物档案";
|
|
4946
5306
|
$("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
|
|
4947
5307
|
$("#character-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
|
|
5308
|
+
$("#character-editor-fields").querySelectorAll("button").forEach((button) => { button.disabled = true; });
|
|
4948
5309
|
}
|
|
4949
5310
|
$("#character-change-note").readOnly = viewOnly;
|
|
4950
5311
|
$("#character-editor-submit").classList.toggle("hidden", viewOnly);
|
|
5312
|
+
const editButton = $("#character-editor-edit");
|
|
5313
|
+
editButton.classList.toggle("hidden", !readOnly || !canEditModule("characters"));
|
|
5314
|
+
editButton.onclick = () => void openCharacterEditor(item);
|
|
4951
5315
|
document.querySelectorAll("[data-character-editor-tab]").forEach((button) => {
|
|
4952
5316
|
button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
|
|
4953
5317
|
});
|
|
@@ -4957,7 +5321,7 @@ async function openCharacterEditor(item = null) {
|
|
|
4957
5321
|
const form = $("#character-editor-form");
|
|
4958
5322
|
form.onsubmit = async (event) => {
|
|
4959
5323
|
event.preventDefault();
|
|
4960
|
-
if (!canEditModule("characters")) return;
|
|
5324
|
+
if (readOnly || !canEditModule("characters")) return;
|
|
4961
5325
|
const submit = $("#character-editor-submit");
|
|
4962
5326
|
submit.disabled = true;
|
|
4963
5327
|
try {
|
|
@@ -4966,6 +5330,7 @@ async function openCharacterEditor(item = null) {
|
|
|
4966
5330
|
const wasEditing = Boolean(characterEditorItem);
|
|
4967
5331
|
const previousVersion = characterEditorItem?.versionNo;
|
|
4968
5332
|
if (!wasEditing) delete body.changeNote;
|
|
5333
|
+
if (!(await confirmConcurrentSave())) return;
|
|
4969
5334
|
const saved = await api(wasEditing ? `/api/characters/${characterEditorItem.id}` : `/api/works/${state.work.id}/characters`, { method: wasEditing ? "PATCH" : "POST", body });
|
|
4970
5335
|
entityEditorDirty = false;
|
|
4971
5336
|
await loadAiReferences();
|
|
@@ -4977,7 +5342,7 @@ async function openCharacterEditor(item = null) {
|
|
|
4977
5342
|
submit.disabled = false;
|
|
4978
5343
|
}
|
|
4979
5344
|
};
|
|
4980
|
-
showEntityEditorPage("character");
|
|
5345
|
+
showEntityEditorPage("character", { readOnly });
|
|
4981
5346
|
if (item) {
|
|
4982
5347
|
if (canReadModule("relationships")) void loadCharacterEditorRelationships(item.id);
|
|
4983
5348
|
void loadCharacterMarkdownSections(item.id);
|
|
@@ -5019,7 +5384,8 @@ function renderKnowledgeEditorFields(kind, item, memberOptions, parentOptions) {
|
|
|
5019
5384
|
activateKnowledgeEditorTab("basic");
|
|
5020
5385
|
}
|
|
5021
5386
|
|
|
5022
|
-
async function openKnowledgeEditor(kind, item) {
|
|
5387
|
+
async function openKnowledgeEditor(kind, item, { readOnly = false } = {}) {
|
|
5388
|
+
entityEditorReadOnly = readOnly;
|
|
5023
5389
|
await discardPendingMarkdownAttachments();
|
|
5024
5390
|
state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
|
|
5025
5391
|
const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
|
|
@@ -5049,8 +5415,8 @@ async function openKnowledgeEditor(kind, item) {
|
|
|
5049
5415
|
const candidates = isRace ? state.races : state.organizations;
|
|
5050
5416
|
const typeLabel = label;
|
|
5051
5417
|
historyButton.classList.toggle("hidden", !item);
|
|
5052
|
-
mergeButton.classList.toggle("hidden", !item || !canEditModule(module) || candidates.length < 2);
|
|
5053
|
-
deleteButton.classList.toggle("hidden", !item || !canEditModule(module));
|
|
5418
|
+
mergeButton.classList.toggle("hidden", !item || readOnly || !canEditModule(module) || candidates.length < 2);
|
|
5419
|
+
deleteButton.classList.toggle("hidden", !item || readOnly || !canEditModule(module));
|
|
5054
5420
|
historyButton.onclick = async () => {
|
|
5055
5421
|
if (!item) return;
|
|
5056
5422
|
if (!(await closeEntityEditor())) return;
|
|
@@ -5080,17 +5446,22 @@ async function openKnowledgeEditor(kind, item) {
|
|
|
5080
5446
|
});
|
|
5081
5447
|
};
|
|
5082
5448
|
renderKnowledgeEditorFields(kind, item, memberOptions, parentOptions);
|
|
5083
|
-
const viewOnly = !canEditModule(module);
|
|
5449
|
+
const viewOnly = readOnly || !canEditModule(module);
|
|
5084
5450
|
if (viewOnly) {
|
|
5085
|
-
$("#knowledge-editor-eyebrow").textContent = `${label}档案`;
|
|
5451
|
+
$("#knowledge-editor-eyebrow").textContent = readOnly ? `阅读${label}档案` : `${label}档案`;
|
|
5086
5452
|
$("#knowledge-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
|
|
5087
5453
|
$("#knowledge-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
|
|
5454
|
+
$("#knowledge-editor-fields").querySelectorAll("button").forEach((button) => { button.disabled = true; });
|
|
5088
5455
|
}
|
|
5089
5456
|
$("#knowledge-editor-submit").classList.toggle("hidden", viewOnly);
|
|
5457
|
+
const editButton = $("#knowledge-editor-edit");
|
|
5458
|
+
editButton.textContent = `编辑${label}`;
|
|
5459
|
+
editButton.classList.toggle("hidden", !readOnly || !canEditModule(module));
|
|
5460
|
+
editButton.onclick = () => void openKnowledgeEditor(kind, item);
|
|
5090
5461
|
const form = $("#knowledge-editor-form");
|
|
5091
5462
|
form.onsubmit = async (event) => {
|
|
5092
5463
|
event.preventDefault();
|
|
5093
|
-
if (!canEditModule(module)) return;
|
|
5464
|
+
if (readOnly || !canEditModule(module)) return;
|
|
5094
5465
|
const submit = $("#knowledge-editor-submit");
|
|
5095
5466
|
submit.disabled = true;
|
|
5096
5467
|
try {
|
|
@@ -5108,6 +5479,7 @@ async function openKnowledgeEditor(kind, item) {
|
|
|
5108
5479
|
: { name, description: data.get("description"), settingsMarkdown, settingsSections };
|
|
5109
5480
|
if (canReadModule("characters")) body.memberIds = data.getAll("memberIds").map(String);
|
|
5110
5481
|
const wasEditing = Boolean(knowledgeEditorItem);
|
|
5482
|
+
if (!(await confirmConcurrentSave())) return;
|
|
5111
5483
|
await api(wasEditing ? `/api/${module}/${knowledgeEditorItem.id}` : `/api/works/${state.work.id}/${module}`, { method: wasEditing ? "PATCH" : "POST", body });
|
|
5112
5484
|
await cleanupPendingMarkdownAttachments(settingsMarkdown);
|
|
5113
5485
|
entityEditorDirty = false;
|
|
@@ -5120,16 +5492,16 @@ async function openKnowledgeEditor(kind, item) {
|
|
|
5120
5492
|
submit.disabled = false;
|
|
5121
5493
|
}
|
|
5122
5494
|
};
|
|
5123
|
-
showEntityEditorPage(kind);
|
|
5124
|
-
$("#knowledge-editor-fields").querySelector("input:not([type='checkbox']), textarea")?.focus();
|
|
5495
|
+
showEntityEditorPage(kind, { readOnly });
|
|
5496
|
+
(readOnly ? $("#knowledge-editor-close") : $("#knowledge-editor-fields").querySelector("input:not([type='checkbox']), textarea"))?.focus();
|
|
5125
5497
|
}
|
|
5126
5498
|
|
|
5127
|
-
async function openRaceDialog(item) {
|
|
5128
|
-
await openKnowledgeEditor("race", item);
|
|
5499
|
+
async function openRaceDialog(item, options) {
|
|
5500
|
+
await openKnowledgeEditor("race", item, options);
|
|
5129
5501
|
}
|
|
5130
5502
|
|
|
5131
|
-
async function openOrganizationDialog(item) {
|
|
5132
|
-
await openKnowledgeEditor("organization", item);
|
|
5503
|
+
async function openOrganizationDialog(item, options) {
|
|
5504
|
+
await openKnowledgeEditor("organization", item, options);
|
|
5133
5505
|
}
|
|
5134
5506
|
|
|
5135
5507
|
function openTimelineTrackDialog(item) {
|
|
@@ -5259,7 +5631,7 @@ function openTaskDialog() {
|
|
|
5259
5631
|
}
|
|
5260
5632
|
|
|
5261
5633
|
function openProviderDialog(item) {
|
|
5262
|
-
openDialog(item ? "编辑 AI 供应商" : "新建 AI 供应商", field("name", "显示名称", "text", item?.name) + field("baseUrl", "
|
|
5634
|
+
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
5635
|
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
5636
|
if (!item || String(form.get("apiKey") ?? "").trim()) body.apiKey = form.get("apiKey");
|
|
5265
5637
|
await api(item ? `/api/providers/${item.id}` : "/api/platform/ai/providers", { method: item ? "PATCH" : "POST", body });
|
|
@@ -5271,7 +5643,7 @@ function openProviderDialog(item) {
|
|
|
5271
5643
|
function openModelDialog(providerId, item = null) {
|
|
5272
5644
|
const values = modelFormValues(item);
|
|
5273
5645
|
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", "
|
|
5646
|
+
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
5647
|
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
5648
|
await api(item ? `/api/models/${item.id}` : `/api/providers/${providerId}/models`, { method: item ? "PATCH" : "POST", body });
|
|
5277
5649
|
await renderPlatformAiConfig();
|
|
@@ -5513,7 +5885,7 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
|
|
|
5513
5885
|
message.className = "assistant-message";
|
|
5514
5886
|
const applicable = suggestion.action !== "note";
|
|
5515
5887
|
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>` : "";
|
|
5888
|
+
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
5889
|
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
5890
|
attachMessageHeading(message, "助手建议", createdAt ?? undefined);
|
|
5519
5891
|
attachAssistantCopyAction(message, suggestion.content);
|
|
@@ -5546,7 +5918,7 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
|
|
|
5546
5918
|
async function showVersions() {
|
|
5547
5919
|
if (!state.chapter) return;
|
|
5548
5920
|
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("");
|
|
5921
|
+
$("#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
5922
|
$("#versions-list").querySelectorAll("[data-restore-version]").forEach((button) => button.addEventListener("click", async () => {
|
|
5551
5923
|
if (!(await confirmToast(
|
|
5552
5924
|
`将版本 v${button.dataset.restoreVersion} 恢复为一个新的保存版本?`,
|
|
@@ -5956,6 +6328,11 @@ $("#platform-ai-button").addEventListener("click", () => showPlatformAi().catch(
|
|
|
5956
6328
|
$("#user-management-button").addEventListener("click", openUsersDialog);
|
|
5957
6329
|
$("#platform-ui-settings-button").addEventListener("click", openPlatformUiSettingsDialog);
|
|
5958
6330
|
$("#collaboration-button").addEventListener("click", () => openMembersDialog());
|
|
6331
|
+
$("#presence-button").addEventListener("click", () => {
|
|
6332
|
+
const panel = $("#presence-panel");
|
|
6333
|
+
const open = panel.classList.toggle("hidden") === false;
|
|
6334
|
+
$("#presence-button").setAttribute("aria-expanded", String(open));
|
|
6335
|
+
});
|
|
5959
6336
|
$("#users-dialog-close").addEventListener("click", () => $("#users-dialog").close());
|
|
5960
6337
|
$("#platform-ui-settings-close").addEventListener("click", () => $("#platform-ui-settings-dialog").close());
|
|
5961
6338
|
$("#platform-ui-settings-cancel").addEventListener("click", () => $("#platform-ui-settings-dialog").close());
|
|
@@ -6110,6 +6487,14 @@ $("#left-panel-toggle").addEventListener("click", () => {
|
|
|
6110
6487
|
panelLayout.leftCollapsed = !panelLayout.leftCollapsed;
|
|
6111
6488
|
applyPanelLayout(true);
|
|
6112
6489
|
});
|
|
6490
|
+
$("#mobile-module-tab").addEventListener("click", () => {
|
|
6491
|
+
panelLayout.leftCollapsed = !panelLayout.leftCollapsed;
|
|
6492
|
+
applyPanelLayout(true);
|
|
6493
|
+
});
|
|
6494
|
+
$("#mobile-panel-backdrop").addEventListener("click", () => {
|
|
6495
|
+
panelLayout.leftCollapsed = true;
|
|
6496
|
+
applyPanelLayout(true);
|
|
6497
|
+
});
|
|
6113
6498
|
$("#ai-panel-toggle").addEventListener("click", () => {
|
|
6114
6499
|
panelLayout.aiCollapsed = !panelLayout.aiCollapsed;
|
|
6115
6500
|
applyPanelLayout(true);
|
|
@@ -6117,7 +6502,11 @@ $("#ai-panel-toggle").addEventListener("click", () => {
|
|
|
6117
6502
|
setupPanelResize($("#left-panel-resize"), "left");
|
|
6118
6503
|
setupPanelResize($("#ai-panel-resize"), "ai");
|
|
6119
6504
|
if (typeof ResizeObserver !== "undefined") new ResizeObserver(scheduleChapterLineNumbers).observe($("#chapter-content"));
|
|
6120
|
-
window.addEventListener("resize", () => {
|
|
6505
|
+
window.addEventListener("resize", () => {
|
|
6506
|
+
if (isMobileViewport() && $("#onboarding-dialog").open) completeOnboarding();
|
|
6507
|
+
applyPanelLayout();
|
|
6508
|
+
scheduleChapterLineNumbers();
|
|
6509
|
+
});
|
|
6121
6510
|
$("#module-nav").addEventListener("click", (event) => {
|
|
6122
6511
|
const button = event.target.closest("button");
|
|
6123
6512
|
if (!button || button.id === "module-more-button") return;
|
|
@@ -6126,7 +6515,14 @@ $("#module-nav").addEventListener("click", (event) => {
|
|
|
6126
6515
|
openWorkSettingsDialog(work);
|
|
6127
6516
|
return;
|
|
6128
6517
|
}
|
|
6129
|
-
if (button.dataset.module)
|
|
6518
|
+
if (button.dataset.module) {
|
|
6519
|
+
void showModule(button.dataset.module).finally(() => {
|
|
6520
|
+
if (window.matchMedia("(max-width: 850px)").matches) {
|
|
6521
|
+
panelLayout.leftCollapsed = true;
|
|
6522
|
+
applyPanelLayout(true);
|
|
6523
|
+
}
|
|
6524
|
+
});
|
|
6525
|
+
}
|
|
6130
6526
|
});
|
|
6131
6527
|
$("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!moduleNavExpanded));
|
|
6132
6528
|
$("#module-create-button").addEventListener("click", () => ({ settings: openSettingEditor, characters: openCharacterEditor, races: openRaceDialog, organizations: openOrganizationDialog, timeline: openTimelineDialog, outlines: openForeshadowDialog, relationships: openRelationshipDialog, reviews: openReviewDialog, tasks: openTaskDialog })[state.module]?.());
|
|
@@ -6271,6 +6667,10 @@ document.addEventListener("pointerdown", (event) => {
|
|
|
6271
6667
|
$("#account-menu").classList.add("hidden");
|
|
6272
6668
|
$("#account-button").setAttribute("aria-expanded", "false");
|
|
6273
6669
|
}
|
|
6670
|
+
if (!event.target.closest("#presence-control")) {
|
|
6671
|
+
$("#presence-panel").classList.add("hidden");
|
|
6672
|
+
$("#presence-button").setAttribute("aria-expanded", "false");
|
|
6673
|
+
}
|
|
6274
6674
|
});
|
|
6275
6675
|
document.addEventListener("keydown", (event) => {
|
|
6276
6676
|
if (event.key === "Escape") {
|
|
@@ -6378,6 +6778,10 @@ window.addEventListener("beforeunload", (event) => { if (state.dirty || entityEd
|
|
|
6378
6778
|
|
|
6379
6779
|
initializePage().catch((error) => {
|
|
6380
6780
|
restoringPageRoute = false;
|
|
6781
|
+
if (state.user) {
|
|
6782
|
+
document.body.classList.remove("auth-pending");
|
|
6783
|
+
$("#auth-view").classList.add("hidden");
|
|
6784
|
+
}
|
|
6381
6785
|
showShelf();
|
|
6382
6786
|
toast(`系统初始化失败:${error.message}`, "error");
|
|
6383
6787
|
});
|