@musnows/scriverse 0.4.2 → 0.4.3

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