@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.
@@ -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=20260714-all-knowledge-history";
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, moduleLayoutLabel } from "/module-layout.js?v=20260723-module-layout-toggle";
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)) ?? 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)] ?? 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
- $("#left-panel-toggle").textContent = panelLayout.leftCollapsed ? "›" : "‹";
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", panelLayout.leftCollapsed ? "展开作品侧栏" : "收起作品侧栏");
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/")) showAuth(false);
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 ? records.find((record) => record.id === route.entityId) : null;
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
- if (route.entity === "setting") openSettingEditor(item);
2011
- else if (route.entity === "character") await openCharacterEditor(item);
2012
- else if (route.entity === "race") await openRaceDialog(item);
2013
- else if (route.entity === "organization") await openOrganizationDialog(item);
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(searchResultTypeLabels[item.type] ?? item.type)}</span><strong>${esc(item.title)}</strong></div>
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"}">卡片</button>
2844
- <button type="button" data-module-layout="rows" aria-pressed="${layout === "rows"}">列表</button>
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
- [state.characters, state.races, state.organizations] = await Promise.all([
2900
- apiPage(`/api/works/${state.work.id}/characters`).then((result) => result.items),
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 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" ${state.characters.length < 2 ? "disabled" : ""}>AI 角色查重</button></section>` : "";
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
- $("#module-content").querySelectorAll("[data-open-character]").forEach((card) => {
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({ setup: "埋设", reminder: "提醒", payoff: "回收" }[link.role] ?? link.role)} · ${esc(link.volumeTitle)} / ${esc(link.chapterTitle)}</span>`).join("") : '<span class="pill">尚未关联章节</span>'}</div>
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) => `${({ setup: "埋设", reminder: "提醒", payoff: "回收" }[link.role] ?? link.role)} · ${link.volumeTitle} / ${link.chapterTitle}`).join(";")
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))}<br><small>${esc(item.taskType)}</small></td>
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))}(${esc(task.taskType)})</p>
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} · RPM:${provider.rpmLimit} · max_tokens:${provider.maxTokens ?? 32000}${provider.lastError ? `\n错误:${esc(provider.lastError)}` : ""}</p>
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 ? "启用" : "停用"} · Thinking ${model.thinkingEnabled ? "开启" : "关闭"} · 上下文 ${Number(model.contextWindow ?? 128000).toLocaleString("zh-CN")} Token · max_tokens ${Number(model.preset?.max_tokens ?? 32000).toLocaleString("zh-CN")}</button>`).join("")}</div>
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 Chat Completions 兼容地址和密钥,测试成功后再添加模型。");
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>选择平台模型作为当前作品的默认模型;所有请求都会携带 max_tokens,默认值为 32000。</p></div></div>
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)}<br><small>${esc(taskType)}</small></td><td><select class="default-model-select" data-task-default="${esc(taskType)}">
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 management = $("#setting-editor-management");
4131
- management.classList.toggle("hidden", !item || viewOnly);
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: String(form.get("changeNote") ?? "").trim() } : {})
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 = relationshipCategoryLabels[relationship.category] ?? relationship.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 toolbar = `<div class="character-markdown-list-toolbar"><div><b>Markdown 档案章节</b><span>长篇内容独立保存、渲染、检索和版本管理。</span></div>${canEditModule("characters") ? '<button type="button" class="primary-button" data-character-section-create>新建章节</button>' : ""}</div>`;
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>${canEditModule("characters") ? `<button type="button" data-character-section-edit="${esc(section.id)}">编辑</button>` : ""}<button type="button" data-character-section-versions="${esc(section.id)}">版本</button>${canEditModule("characters") ? `<button type="button" data-character-section-delete="${esc(section.id)}">删除</button>` : ""}</div></header>
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", "Chat Completions 基础地址", "url", item?.baseUrl ?? "https://api.openai.com/v1") + field("apiKey", item ? "替换 API 密钥(留空则不变)" : "API 密钥", "password") + field("concurrencyLimit", "最大并发请求数", "number", item?.concurrencyLimit ?? 10) + field("rpmLimit", "每分钟请求上限(RPM)", "number", item?.rpmLimit ?? 10) + field("maxTokens", "最大输出 Token 数", "number", item?.maxTokens ?? 32000) + field("note", "用途备注", "textarea", item?.note) + field("enabled", item ? "启用供应商" : "立即启用", "checkbox", item ? item.status === "enabled" : true), async (form) => {
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", "模型上下文总量(Token)", "number", values.contextWindow) + temperatureField + field("maxTokens", "默认 max_tokens", "number", values.maxTokens) + field("thinkingEnabled", "开启 Thinking(供应商需支持 thinking 参数)", "checkbox", values.thinkingEnabled) + field("enabled", "启用模型", "checkbox", values.enabled), async (form) => {
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", () => { applyPanelLayout(); scheduleChapterLineNumbers(); });
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) showModule(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
  });