@musnows/scriverse 0.3.9 → 0.3.11

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.
@@ -19,6 +19,7 @@ import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelatio
19
19
  import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
20
20
  import { buildRaceForest, eligibleRaceParents, racePathLabel } from "/race-hierarchy.js?v=20260721-race-hierarchy";
21
21
  import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
22
+ import { WORK_PERMISSION_MODULES, canReadUiModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260722-module-permissions";
22
23
 
23
24
  const state = {
24
25
  user: null,
@@ -81,11 +82,17 @@ function analysisTaskStatusLabel(status) {
81
82
  }
82
83
 
83
84
  function canEditWork(work = state.work) {
84
- return ["admin", "owner", "editor", "settings-editor"].includes(String(work?.accessRole));
85
+ return WORK_PERMISSION_MODULES.some((item) => canWriteUiModule(work, item.uiModule));
85
86
  }
86
87
 
87
88
  function canEditProse(work = state.work) {
88
- return ["admin", "owner", "editor"].includes(String(work?.accessRole));
89
+ return canWriteUiModule(work, "editor");
90
+ }
91
+
92
+ function canReplaceProse(work = state.work) {
93
+ return WORK_PERMISSION_MODULES
94
+ .filter((item) => item.id !== "ai-settings")
95
+ .every((item) => canWriteUiModule(work, item.uiModule));
89
96
  }
90
97
 
91
98
  function canManageWork(work = state.work) {
@@ -93,23 +100,47 @@ function canManageWork(work = state.work) {
93
100
  }
94
101
 
95
102
  function canEditModule(module, work = state.work) {
96
- if (canEditProse(work)) return true;
97
- return canEditWork(work) && ["settings", "characters", "races", "organizations", "timeline", "outlines", "relationships"].includes(module);
103
+ return canWriteUiModule(work, module);
104
+ }
105
+
106
+ function canReadModule(module, work = state.work) {
107
+ return canReadUiModule(work, module);
108
+ }
109
+
110
+ function canReadAggregateContent(work = state.work) {
111
+ return ["editor", "settings", "characters", "races", "organizations", "timeline", "relationships", "outlines"]
112
+ .every((module) => canReadModule(module, work));
98
113
  }
99
114
 
100
115
  function applyWorkAccessMode() {
101
116
  const viewOnly = Boolean(state.work) && !canEditWork();
102
- const settingsOnly = String(state.work?.accessRole) === "settings-editor";
103
117
  const proseReadOnly = Boolean(state.work) && !canEditProse();
118
+ const proseHidden = Boolean(state.work) && !canReadModule("editor");
119
+ const aiHidden = Boolean(state.work) && !canReadModule("tasks");
120
+ const aiReadOnly = Boolean(state.work) && !canEditModule("tasks");
121
+ const moduleReadOnly = Boolean(state.work) && !canEditModule(state.module);
104
122
  $("#app").classList.toggle("view-only-mode", viewOnly);
105
- $("#app").classList.toggle("settings-only-mode", settingsOnly);
106
123
  $("#app").classList.toggle("prose-read-only-mode", proseReadOnly);
107
- document.body.classList.toggle("work-viewer-mode", viewOnly);
108
- document.body.classList.toggle("work-settings-editor-mode", settingsOnly);
124
+ $("#app").classList.toggle("prose-hidden-mode", proseHidden);
125
+ $("#app").classList.toggle("ai-hidden-mode", aiHidden);
126
+ document.body.classList.toggle("work-viewer-mode", moduleReadOnly);
127
+ for (const item of WORK_PERMISSION_MODULES) {
128
+ const button = $(`#module-nav [data-module="${item.uiModule}"]`);
129
+ if (button) button.classList.toggle("permission-hidden", !canReadModule(item.uiModule));
130
+ }
131
+ $("#module-nav [data-work-settings]").classList.toggle("permission-hidden", Boolean(state.work) && !canManageWork());
132
+ $("#import-file-button").classList.toggle("permission-hidden", proseReadOnly);
133
+ $("#import-file-button").setAttribute("aria-hidden", String(proseReadOnly));
134
+ $("#import-file").disabled = proseReadOnly;
135
+ $("#import-history-button").classList.toggle("permission-hidden", Boolean(state.work) && !canReplaceProse());
136
+ $(".ai-panel").classList.toggle("permission-hidden", aiHidden);
109
137
  $("#chapter-title").readOnly = proseReadOnly;
110
138
  $("#chapter-content").readOnly = proseReadOnly;
111
139
  $("#chapter-title").setAttribute("aria-readonly", String(proseReadOnly));
112
140
  $("#chapter-content").setAttribute("aria-readonly", String(proseReadOnly));
141
+ $("#ai-prompt").readOnly = aiReadOnly;
142
+ $("#ai-prompt").setAttribute("aria-readonly", String(aiReadOnly));
143
+ $("#ai-send").classList.toggle("permission-hidden", aiReadOnly);
113
144
  if (proseReadOnly) {
114
145
  cancelChapterAutoSave();
115
146
  state.dirty = false;
@@ -162,6 +193,8 @@ const panelLayoutStorageKey = "ai-novel-panel-layout-v1";
162
193
  const panelLayoutDefaults = Object.freeze({ leftWidth: 280, aiWidth: 360, leftCollapsed: false, aiCollapsed: false });
163
194
  let restoringPageRoute = true;
164
195
  let memberDialogWork = null;
196
+ let memberDialogMembers = [];
197
+ let memberDialogDirectory = [];
165
198
  let onboardingStep = 0;
166
199
  let onboardingAutoScheduled = false;
167
200
  let onboardingPositionFrame = null;
@@ -175,6 +208,10 @@ let aiReferencesLoadPromise = null;
175
208
  let aiReferencesLoadWorkId = null;
176
209
  let aiConversationsLoadPromise = null;
177
210
  let aiConversationsLoadWorkId = null;
211
+ let workScopedUiGeneration = 0;
212
+ let importHistoryRecords = [];
213
+ let importHistoryNextPage = null;
214
+ let importHistoryRequestId = 0;
178
215
 
179
216
  const shelfOnboardingSteps = [
180
217
  { selector: "#home-button", eyebrow: "作品入口", title: "这里是你的创作书架", description: "点击左上角的叙界标志,可以随时回到书架,在不同作品之间切换。", placement: "bottom" },
@@ -510,7 +547,8 @@ function showEntityEditorPage(type) {
510
547
  }
511
548
 
512
549
  function markEntityEditorDirty() {
513
- if (entityEditorType && canEditWork()) entityEditorDirty = true;
550
+ const module = entityEditorType === "setting" ? "settings" : "characters";
551
+ if (entityEditorType && canEditModule(module)) entityEditorDirty = true;
514
552
  }
515
553
 
516
554
  function confirmEntityEditorDiscard(message) {
@@ -1082,8 +1120,9 @@ function renderAiConversationHistory() {
1082
1120
  async function loadAiConversations(openLatest = true) {
1083
1121
  const workId = state.work?.id;
1084
1122
  if (!workId) return;
1123
+ const generation = workScopedUiGeneration;
1085
1124
  const conversations = (await apiPage(`/api/works/${workId}/ai-conversations`)).items;
1086
- if (state.work?.id !== workId) return;
1125
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
1087
1126
  state.aiConversations = conversations;
1088
1127
  loadedAiConversationsWorkId = workId;
1089
1128
  renderAiConversationHistory();
@@ -1774,8 +1813,11 @@ function confirmDiscardChanges(message = "当前章节有未保存修改,继
1774
1813
 
1775
1814
  function chooseExistingWorkImportMode(file) {
1776
1815
  const dialog = $("#import-mode-dialog");
1816
+ const canOverwrite = canReplaceProse();
1777
1817
  $("#import-mode-file-summary").textContent = `文件:${file.name};当前作品:《${state.work.title}》`;
1778
1818
  $("#import-mode-unsaved-warning").classList.toggle("hidden", !state.dirty);
1819
+ $("#import-mode-overwrite").disabled = !canOverwrite;
1820
+ $("#import-mode-overwrite-permission").classList.toggle("hidden", canOverwrite);
1779
1821
  dialog.returnValue = "cancel";
1780
1822
  dialog.showModal();
1781
1823
  return new Promise((resolve) => {
@@ -1905,13 +1947,14 @@ function captureSettingsReturnContext() {
1905
1947
  function renderSettingsHub() {
1906
1948
  const hasWork = Boolean(state.work);
1907
1949
  const canManageWork = hasWork && ["admin", "owner"].includes(String(state.work.accessRole));
1950
+ const canReadAggregate = hasWork && canReadAggregateContent();
1908
1951
  const isAdmin = state.user?.role === "admin";
1909
1952
  $("#platform-ai-button").classList.toggle("hidden", !isAdmin);
1910
1953
  $("#user-management-button").classList.toggle("hidden", !isAdmin);
1911
1954
  $("#platform-ui-settings-button").classList.toggle("hidden", !isAdmin);
1912
1955
  $("#collaboration-button").disabled = !canManageWork;
1913
- $("#top-search-button").disabled = !hasWork;
1914
- $("#export-button").disabled = !hasWork;
1956
+ $("#top-search-button").disabled = !canReadAggregate;
1957
+ $("#export-button").disabled = !canReadAggregate;
1915
1958
  $("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
1916
1959
  $("#settings-work-note").textContent = hasWork
1917
1960
  ? `当前作品:《${state.work.title}》。导出将作用于这部作品。`
@@ -1973,61 +2016,99 @@ async function openPlatformUiSettingsDialog() {
1973
2016
  }
1974
2017
  }
1975
2018
 
2019
+ function renderMemberPermissionGrid(value) {
2020
+ const permissions = normalizeModulePermissions(value, "custom");
2021
+ $("#member-permission-grid").innerHTML = WORK_PERMISSION_MODULES.map((item) => `<label class="member-permission-row">
2022
+ <span>${esc(item.label)}</span>
2023
+ <select data-member-permission="${esc(item.id)}" aria-label="${esc(item.label)}权限">
2024
+ <option value="none" ${permissions[item.id] === "none" ? "selected" : ""}>无权限</option>
2025
+ <option value="read" ${permissions[item.id] === "read" ? "selected" : ""}>只读</option>
2026
+ <option value="write" ${permissions[item.id] === "write" ? "selected" : ""}>可编辑</option>
2027
+ </select>
2028
+ </label>`).join("");
2029
+ }
2030
+
2031
+ function selectedMemberPermissions() {
2032
+ const permissions = emptyModulePermissions();
2033
+ $("#member-permission-grid").querySelectorAll("[data-member-permission]").forEach((select) => {
2034
+ permissions[select.dataset.memberPermission] = select.value;
2035
+ });
2036
+ return permissions;
2037
+ }
2038
+
2039
+ function selectMemberForConfiguration(userId) {
2040
+ const fieldset = $("#member-permission-fieldset");
2041
+ const member = memberDialogMembers.find((item) => item.userId === userId);
2042
+ fieldset.disabled = !userId;
2043
+ renderMemberPermissionGrid(member?.permissions ?? emptyModulePermissions());
2044
+ $("#member-permission-submit").textContent = member ? "更新模块权限" : "添加成员并保存";
2045
+ }
2046
+
2047
+ function renderMemberSelector(selectedUserId = "") {
2048
+ const ownerIds = new Set(memberDialogMembers.filter((member) => member.role === "owner").map((member) => member.userId));
2049
+ const people = new Map(memberDialogDirectory.map((user) => [user.userId, user]));
2050
+ for (const member of memberDialogMembers) if (member.role !== "owner") people.set(member.userId, member);
2051
+ const options = [...people.values()].filter((user) => !ownerIds.has(user.userId));
2052
+ $("#member-user-select").innerHTML = options.length
2053
+ ? `<option value="">选择用户</option>${options.map((user) => {
2054
+ const existing = memberDialogMembers.some((member) => member.userId === user.userId && member.role !== "owner");
2055
+ return `<option value="${esc(user.userId)}" ${selectedUserId === user.userId ? "selected" : ""}>${esc(user.displayName)} · @${esc(user.username)}${existing ? " · 已加入" : ""}</option>`;
2056
+ }).join("")}`
2057
+ : '<option value="">没有可配置的用户</option>';
2058
+ $("#member-user-select").disabled = !options.length;
2059
+ selectMemberForConfiguration(selectedUserId);
2060
+ }
2061
+
1976
2062
  function renderMembers(members) {
2063
+ memberDialogMembers = members;
1977
2064
  const work = memberDialogWork ?? state.work;
1978
2065
  const canManage = ["admin", "owner"].includes(String(work?.accessRole));
1979
- $("#members-list").innerHTML = members.map((member) => `<article class="access-row">
1980
- <div class="access-person">${userAvatarHtml(member, "access-avatar")}<div class="access-person-copy"><strong>${esc(member.displayName)} · @${esc(member.username)}</strong><small>${member.role === "owner" ? "作品创建者" : member.role === "viewer" ? "查看者" : member.role === "settings-editor" ? "设定编辑" : "完整协作者"}${member.status === "disabled" ? " · 已停用" : ""}</small></div></div>
1981
- ${member.role === "owner" ? "<span>所有者</span>" : `<select data-member-role="${esc(member.userId)}" aria-label="${esc(member.displayName)}的作品权限" ${canManage ? "" : "disabled"}><option value="viewer" ${member.role === "viewer" ? "selected" : ""}>仅查看</option><option value="settings-editor" ${member.role === "settings-editor" ? "selected" : ""}>仅编辑设定</option><option value="editor" ${member.role === "editor" ? "selected" : ""}>编辑正文与设定</option></select>`}
1982
- ${member.role === "owner" || !canManage ? "<span></span>" : `<button type="button" data-remove-member="${esc(member.userId)}">移除</button>`}
1983
- </article>`).join("");
2066
+ $("#members-list").innerHTML = members.map((member) => {
2067
+ const descriptionId = `member-role-summary-${member.userId}`;
2068
+ return `<article class="access-row">
2069
+ <div class="access-person">${userAvatarHtml(member, "access-avatar")}<div class="access-person-copy"><strong>${esc(member.displayName)} · @${esc(member.username)}</strong><small id="${esc(descriptionId)}">${member.role === "owner" ? "作品创建者 · 拥有全部模块管理权限" : esc(permissionSummary(member.permissions))}${member.status === "disabled" ? " · 已停用" : ""}</small></div></div>
2070
+ ${member.role === "owner" || !canManage ? "<span></span>" : `<button type="button" data-configure-member="${esc(member.userId)}" aria-describedby="${esc(descriptionId)}">配置权限</button>`}
2071
+ ${member.role === "owner" || !canManage ? "<span></span>" : `<button type="button" data-remove-member="${esc(member.userId)}">移除</button>`}
2072
+ </article>`;
2073
+ }).join("");
1984
2074
  bindUserAvatarFallbacks($("#members-list"));
1985
- $("#members-list").querySelectorAll("[data-member-role]").forEach((select) => select.addEventListener("change", async () => {
1986
- if (!work) return;
1987
- const previousRole = members.find((member) => member.userId === select.dataset.memberRole)?.role ?? "viewer";
1988
- try {
1989
- const updated = await api(`/api/works/${encodeURIComponent(work.id)}/members/${encodeURIComponent(select.dataset.memberRole)}`, { method: "PATCH", body: { role: select.value } });
1990
- renderMembers(updated);
1991
- toast("成员权限已更新");
1992
- } catch (error) {
1993
- select.value = previousRole;
1994
- toast(error.message, "error");
1995
- }
2075
+ $("#members-list").querySelectorAll("[data-configure-member]").forEach((button) => button.addEventListener("click", () => {
2076
+ $("#member-user-select").value = button.dataset.configureMember;
2077
+ selectMemberForConfiguration(button.dataset.configureMember);
2078
+ $("#member-user-select").focus();
1996
2079
  }));
1997
2080
  $("#members-list").querySelectorAll("[data-remove-member]").forEach((button) => button.addEventListener("click", async () => {
1998
2081
  if (!work) return;
1999
2082
  try {
2000
2083
  const updated = await api(`/api/works/${encodeURIComponent(work.id)}/members/${encodeURIComponent(button.dataset.removeMember)}`, { method: "DELETE" });
2001
2084
  renderMembers(updated);
2002
- await fillMemberCandidates(updated);
2085
+ renderMemberSelector();
2003
2086
  toast("协作者已移除");
2004
2087
  } catch (error) { toast(error.message, "error"); }
2005
2088
  }));
2006
2089
  }
2007
2090
 
2008
- async function fillMemberCandidates(members) {
2009
- const directory = await api("/api/users/directory");
2010
- const memberIds = new Set(members.map((member) => member.userId));
2011
- const candidates = directory.filter((user) => !memberIds.has(user.userId));
2012
- $("#member-user-select").innerHTML = candidates.length
2013
- ? `<option value="">选择用户</option>${candidates.map((user) => `<option value="${esc(user.userId)}">${esc(user.displayName)} · @${esc(user.username)}</option>`).join("")}`
2014
- : '<option value="">没有可邀请的用户</option>';
2015
- $("#member-user-select").disabled = !candidates.length;
2016
- }
2017
-
2018
2091
  async function openMembersDialog(targetWork = state.work) {
2019
2092
  if (!targetWork) return;
2020
2093
  memberDialogWork = targetWork;
2021
2094
  const canManage = ["admin", "owner"].includes(String(targetWork.accessRole));
2022
2095
  $("#members-dialog-eyebrow").textContent = `作品权限 · 《${targetWork.title}》`;
2023
- $("#members-dialog-title").textContent = "可访问人";
2096
+ $("#members-dialog-title").textContent = "成员模块权限";
2024
2097
  $("#members-list").innerHTML = '<p class="empty-state">正在读取成员……</p>';
2025
- $("#member-invite-form").classList.toggle("hidden", !canManage);
2098
+ $("#member-permission-form").classList.toggle("hidden", !canManage);
2099
+ memberDialogMembers = [];
2100
+ memberDialogDirectory = [];
2101
+ renderMemberPermissionGrid(emptyModulePermissions());
2102
+ $("#member-permission-fieldset").disabled = true;
2026
2103
  $("#members-dialog").showModal();
2027
2104
  try {
2028
- const members = await api(`/api/works/${encodeURIComponent(targetWork.id)}/members`);
2105
+ const [members, directory] = await Promise.all([
2106
+ api(`/api/works/${encodeURIComponent(targetWork.id)}/members`),
2107
+ canManage ? api("/api/users/directory") : Promise.resolve([])
2108
+ ]);
2109
+ memberDialogDirectory = directory;
2029
2110
  renderMembers(members);
2030
- if (canManage) await fillMemberCandidates(members);
2111
+ if (canManage) renderMemberSelector();
2031
2112
  } catch (error) { $("#members-dialog").close(); toast(error.message, "error"); }
2032
2113
  }
2033
2114
 
@@ -2181,7 +2262,7 @@ function renderShelf() {
2181
2262
  <span class="book-cover-fallback">${esc(Array.from(work.title)[0] ?? "书")}</span>
2182
2263
  ${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面">` : ""}
2183
2264
  </span>
2184
- <span class="book-info"><strong>${esc(work.title)}</strong><small>${esc(work.author || "未署名")} · ${work.chapterCount} 章 · ${work.wordCount} 字</small><span>${esc(work.description || "尚未填写作品简介")}</span><em class="book-access-badge">${work.accessRole === "viewer" ? "仅查看" : work.accessRole === "settings-editor" ? "设定协作" : work.accessRole === "editor" ? "完整协作" : work.accessRole === "admin" ? "管理员访问" : "我的作品"}</em></span>
2265
+ <span class="book-info"><strong>${esc(work.title)}</strong><small>${esc(work.author || "未署名")} · ${work.chapterCount} 章 · ${work.wordCount} 字</small><span>${esc(work.description || "尚未填写作品简介")}</span><em class="book-access-badge">${work.accessRole === "viewer" ? "全部只读" : work.accessRole === "settings-editor" ? "设定协作" : work.accessRole === "editor" ? "全部可编辑" : work.accessRole === "custom" ? "自定义权限" : work.accessRole === "admin" ? "管理员访问" : "我的作品"}</em></span>
2185
2266
  </button>
2186
2267
  ${canManageWork(work) ? `<button class="book-card-settings" type="button" data-edit-work="${esc(work.id)}" aria-label="作品设置" title="作品设置">设置</button>` : ""}
2187
2268
  </article>`).join("")}
@@ -2194,34 +2275,45 @@ function renderShelf() {
2194
2275
  $("#book-add-card").addEventListener("click", openWorkDialog);
2195
2276
  }
2196
2277
 
2278
+ function resetWorkScopedUiCaches() {
2279
+ workScopedUiGeneration += 1;
2280
+ loadedAiModelsWorkId = null;
2281
+ loadedAiReferencesWorkId = null;
2282
+ loadedAiConversationsWorkId = null;
2283
+ aiModelsLoadPromise = null;
2284
+ aiModelsLoadWorkId = null;
2285
+ aiReferencesLoadPromise = null;
2286
+ aiReferencesLoadWorkId = null;
2287
+ aiConversationsLoadPromise = null;
2288
+ aiConversationsLoadWorkId = null;
2289
+ state.models = [];
2290
+ state.characters = [];
2291
+ state.settings = [];
2292
+ state.collapsedVolumeIds.clear();
2293
+ lastSavedChapterSnapshot = null;
2294
+ if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
2295
+ aiContextUsageTimer = null;
2296
+ aiContextUsageRequest += 1;
2297
+ state.aiCitations = [];
2298
+ state.aiReferences = [];
2299
+ state.aiPromptSent = false;
2300
+ state.aiConversationId = null;
2301
+ state.aiConversations = [];
2302
+ renderAiCitations();
2303
+ renderAiReferences();
2304
+ renderAiQuickActions();
2305
+ resetAiFeed();
2306
+ $("#ai-conversation-title").textContent = "新对话";
2307
+ $("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
2308
+ setAiContextMeter(null);
2309
+ renderAiConversationHistory();
2310
+ }
2311
+
2197
2312
  async function selectWork(workId, preferredChapterId = null) {
2198
2313
  const discarding = state.work?.id !== workId && state.dirty;
2199
2314
  if (discarding && !confirmDiscardChanges()) return false;
2200
2315
  const nextWork = await api(`/api/works/${workId}?page=1&limit=100`);
2201
- if (state.work?.id !== nextWork.id) {
2202
- loadedAiModelsWorkId = null;
2203
- loadedAiReferencesWorkId = null;
2204
- loadedAiConversationsWorkId = null;
2205
- state.models = [];
2206
- state.characters = [];
2207
- state.settings = [];
2208
- if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
2209
- aiContextUsageTimer = null;
2210
- aiContextUsageRequest += 1;
2211
- state.aiCitations = [];
2212
- state.aiReferences = [];
2213
- state.aiPromptSent = false;
2214
- state.aiConversationId = null;
2215
- state.aiConversations = [];
2216
- renderAiCitations();
2217
- renderAiReferences();
2218
- renderAiQuickActions();
2219
- resetAiFeed();
2220
- $("#ai-conversation-title").textContent = "新对话";
2221
- $("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
2222
- setAiContextMeter(null);
2223
- renderAiConversationHistory();
2224
- }
2316
+ if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
2225
2317
  if (discarding) setSaveState("就绪");
2226
2318
  $("#app").classList.remove("shelf-mode");
2227
2319
  $("#shelf-view").classList.add("hidden");
@@ -2231,16 +2323,18 @@ async function selectWork(workId, preferredChapterId = null) {
2231
2323
  settingsReturnContext = null;
2232
2324
  state.work = nextWork;
2233
2325
  state.chapter = null;
2326
+ if (!canReadModule(state.module)) state.module = firstReadableUiModule(state.work) ?? "editor";
2234
2327
  applyWorkAccessMode();
2235
2328
  updateDocumentTitle(state.work);
2236
2329
  $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
2237
- $("#top-search-button").disabled = false;
2330
+ $("#top-search-button").disabled = !canReadAggregateContent();
2238
2331
  renderTree();
2239
2332
  const chapters = state.work.volumes.flatMap((volume) => volume.chapters);
2240
2333
  const targetChapter = chapters.find((chapter) => chapter.id === preferredChapterId) ?? chapters[0];
2241
2334
  if (state.module === "editor" && preferredChapterId) await selectChapter(preferredChapterId);
2242
2335
  else if (state.module === "editor" && targetChapter) await selectChapter(targetChapter.id);
2243
- else if (state.module === "editor") showWelcome(true);
2336
+ else if (state.module === "editor" && canReadModule("editor")) showWelcome(true);
2337
+ else if (!canReadModule(state.module)) showWelcome(true);
2244
2338
  else await showModule(state.module);
2245
2339
  return true;
2246
2340
  }
@@ -2310,6 +2404,7 @@ async function selectChapter(chapterId) {
2310
2404
  state.chapter = await api(`/api/chapters/${chapterId}`);
2311
2405
  lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
2312
2406
  state.module = "editor";
2407
+ applyWorkAccessMode();
2313
2408
  markActiveModule("editor");
2314
2409
  $("#welcome-view").classList.add("hidden");
2315
2410
  $("#module-view").classList.add("hidden");
@@ -2326,7 +2421,7 @@ async function selectChapter(chapterId) {
2326
2421
  scheduleChapterLineNumbers();
2327
2422
  $("#chapter-insight").classList.add("hidden");
2328
2423
  updateChapterStats();
2329
- if (!canEditProse()) setSaveState(canEditWork() ? "正文只读" : "仅查看");
2424
+ if (!canEditProse()) setSaveState("正文只读");
2330
2425
  else if (spacingChanged) scheduleChapterAutoSave(120);
2331
2426
  else setSaveState("已保存");
2332
2427
  renderTree();
@@ -2384,10 +2479,18 @@ const moduleMeta = {
2384
2479
 
2385
2480
  async function showModule(module) {
2386
2481
  if (!state.work) return showWelcome();
2387
- if (!canEditProse() && ["tasks", "ai-settings"].includes(module)) module = "editor";
2482
+ if (!canReadModule(module)) {
2483
+ const fallback = firstReadableUiModule(state.work);
2484
+ if (!fallback) {
2485
+ showWelcome(true);
2486
+ return toast("当前账户尚未获授权访问任何作品模块", "error");
2487
+ }
2488
+ module = fallback;
2489
+ }
2388
2490
  if (module !== "editor" && state.module === "editor" && !confirmDiscardChanges()) return;
2389
2491
  if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
2390
2492
  state.module = module;
2493
+ applyWorkAccessMode();
2391
2494
  markActiveModule(module);
2392
2495
  if (module === "editor") {
2393
2496
  if (state.chapter) await selectChapter(state.chapter.id);
@@ -2545,10 +2648,10 @@ async function renderSettings() {
2545
2648
  async function renderCharacters() {
2546
2649
  [state.characters, state.races, state.organizations] = await Promise.all([
2547
2650
  apiPage(`/api/works/${state.work.id}/characters`).then((result) => result.items),
2548
- apiAllPages(`/api/works/${state.work.id}/races`),
2549
- apiAllPages(`/api/works/${state.work.id}/organizations`)
2651
+ canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
2652
+ canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
2550
2653
  ]);
2551
- const auditPanel = `<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>`;
2654
+ 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>` : "";
2552
2655
  $("#module-content").innerHTML = auditPanel + (state.characters.length ? `<div class="card-grid">${state.characters.map((item) => {
2553
2656
  const details = normalizeCharacterDetails(item.attributes?.details);
2554
2657
  return `
@@ -2560,7 +2663,7 @@ async function renderCharacters() {
2560
2663
  <div class="organization-links"><b>所属组织</b>${(item.organizations ?? []).length ? item.organizations.map((organization) => `<span class="pill organization-pill">${esc(organization.name)}</span>`).join("") : '<span class="organization-empty">未加入组织</span>'}</div>
2561
2664
  ${item.profile?.summary ? `<p class="character-summary">${esc(item.profile.summary)}</p>` : `<p>${esc(Object.entries(item.currentState).map(([key, value]) => `${key}:${value}`).join("\n") || "尚未记录当前状态")}</p>`}
2562
2665
  ${item.profileSectionCount ? `<small class="character-section-count">${item.profileSectionCount} 个设定章节</small>` : ""}
2563
- <div class="card-actions"><button data-edit-character="${esc(item.id)}">编辑</button>${canEditWork() && state.characters.length > 1 ? `<button data-merge-character="${esc(item.id)}">合并</button>` : ""}${canEditWork() ? `<button class="danger-button" data-delete-character="${esc(item.id)}">删除</button>` : ""}</div></article>`;
2666
+ <div class="card-actions"><button data-edit-character="${esc(item.id)}">编辑</button>${canEditModule("characters") && state.characters.length > 1 ? `<button data-merge-character="${esc(item.id)}">合并</button>` : ""}${canEditModule("characters") ? `<button class="danger-button" data-delete-character="${esc(item.id)}">删除</button>` : ""}</div></article>`;
2564
2667
  }).join("")}</div>`
2565
2668
  : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
2566
2669
  $("#create-character-audit-task")?.addEventListener("click", async () => {
@@ -2618,7 +2721,7 @@ async function renderCharacters() {
2618
2721
  async function renderRaces() {
2619
2722
  [state.races, state.characters] = await Promise.all([
2620
2723
  apiAllPages(`/api/works/${state.work.id}/races`),
2621
- apiAllPages(`/api/works/${state.work.id}/characters`)
2724
+ canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
2622
2725
  ]);
2623
2726
  const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
2624
2727
  <summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
@@ -2628,7 +2731,7 @@ async function renderRaces() {
2628
2731
  <p>${esc(item.description || "尚未填写种族简介")}</p>
2629
2732
  <div class="race-settings">${item.effectiveSettings.length ? item.effectiveSettings.map((setting) => `<span class="pill${setting.inherited ? " inherited" : ""}" title="${esc(setting.inherited ? `继承自 ${setting.sourceRaceName}` : `定义于 ${setting.sourceRaceName}`)}">${esc(setting.value)}<small>${esc(setting.sourceRaceName)}</small></span>`).join("") : '<span class="pill">暂无共同设定</span>'}</div>
2630
2733
  <p class="race-members">直接角色:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
2631
- <div class="card-actions"><button data-edit-race="${esc(item.id)}">编辑</button><button data-entity-history="race" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button>${canEditWork() && state.races.length > 1 ? `<button data-merge-race="${esc(item.id)}">合并</button>` : ""}${canEditWork() ? `<button class="danger-button" data-delete-race="${esc(item.id)}">删除</button>` : ""}</div>
2734
+ <div class="card-actions"><button data-edit-race="${esc(item.id)}">编辑</button><button data-entity-history="race" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button>${canEditModule("races") && state.races.length > 1 ? `<button data-merge-race="${esc(item.id)}">合并</button>` : ""}${canEditModule("races") ? `<button class="danger-button" data-delete-race="${esc(item.id)}">删除</button>` : ""}</div>
2632
2735
  </article>
2633
2736
  ${item.children.length ? `<div class="race-tree-children">${item.children.map(renderRaceNode).join("")}</div>` : ""}
2634
2737
  </div>
@@ -2665,14 +2768,14 @@ async function renderRaces() {
2665
2768
  async function renderOrganizations() {
2666
2769
  [state.organizations, state.characters] = await Promise.all([
2667
2770
  apiAllPages(`/api/works/${state.work.id}/organizations`),
2668
- apiAllPages(`/api/works/${state.work.id}/characters`)
2771
+ canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
2669
2772
  ]);
2670
2773
  $("#module-content").innerHTML = state.organizations.length ? `<div class="card-grid organization-grid">${state.organizations.map((item) => `
2671
2774
  <article class="record-card organization-card"><small>${item.memberIds.length} 位成员 · ${item.settings.length} 条设定</small>
2672
2775
  <h3>${esc(item.name)}</h3><p>${esc(item.description || "尚未填写组织简介")}</p>
2673
2776
  <div class="organization-settings">${item.settings.map((setting) => `<span class="pill">${esc(setting)}</span>`).join("") || '<span class="pill">暂无组织设定</span>'}</div>
2674
2777
  <p class="organization-members">成员:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
2675
- <div class="card-actions"><button data-edit-organization="${esc(item.id)}">编辑</button><button data-entity-history="organization" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button>${canEditWork() && state.organizations.length > 1 ? `<button data-merge-organization="${esc(item.id)}">合并</button>` : ""}${canEditWork() ? `<button class="danger-button" data-delete-organization="${esc(item.id)}">删除</button>` : ""}</div>
2778
+ <div class="card-actions"><button data-edit-organization="${esc(item.id)}">编辑</button><button data-entity-history="organization" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button>${canEditModule("organizations") && state.organizations.length > 1 ? `<button data-merge-organization="${esc(item.id)}">合并</button>` : ""}${canEditModule("organizations") ? `<button class="danger-button" data-delete-organization="${esc(item.id)}">删除</button>` : ""}</div>
2676
2779
  </article>`).join("")}</div>` : emptyModule("还没有组织", "创建国家、机构、阵营或团队,并维护组织设定与成员。");
2677
2780
  $("#module-content").querySelectorAll("[data-edit-organization]").forEach((button) => button.addEventListener("click", () => openOrganizationDialog(state.organizations.find((item) => item.id === button.dataset.editOrganization))));
2678
2781
  $("#module-content").querySelectorAll("[data-merge-organization]").forEach((button) => button.addEventListener("click", () => {
@@ -2767,7 +2870,7 @@ async function renderOutlines() {
2767
2870
  }
2768
2871
 
2769
2872
  async function renderRelationships() {
2770
- state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
2873
+ state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
2771
2874
  const relationships = (await apiPage(`/api/works/${state.work.id}/relationships`)).items;
2772
2875
  const nameOf = (id) => state.characters.find((item) => item.id === id)?.name ?? "未知角色";
2773
2876
  state.galaxy?.destroy();
@@ -2798,9 +2901,13 @@ async function renderRelationships() {
2798
2901
  }
2799
2902
 
2800
2903
  async function renderReviews() {
2904
+ const canReadCharacters = canReadModule("characters");
2905
+ const canResolveReview = canEditModule("reviews");
2906
+ const canMergeCharacters = canResolveReview
2907
+ && ["characters", "races", "organizations", "timeline", "relationships"].every((module) => canEditModule(module));
2801
2908
  const [reviews, characters] = await Promise.all([
2802
2909
  apiPage(`/api/works/${state.work.id}/reviews`).then((result) => result.items),
2803
- apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`)
2910
+ canReadCharacters ? apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`) : Promise.resolve([])
2804
2911
  ]);
2805
2912
  const characterById = new Map(characters.map((character) => [character.id, character]));
2806
2913
  const duplicateCard = (item) => {
@@ -2808,17 +2915,21 @@ async function renderReviews() {
2808
2915
  const sides = refs.map((reference) => ({ reference, character: characterById.get(reference.id) }));
2809
2916
  const sideHtml = sides.map(({ character }) => `<section><strong>${esc(character.name)}</strong><small>v${esc(String(character.versionNo))} · ${esc(character.species || "种族未知")}</small><div>${character.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("") || '<span class="organization-empty">无别名</span>'}</div><p>${esc(character.attributes?.identity || character.profile?.summary || "尚未记录身份说明")}</p></section>`).join("");
2810
2917
  const evidenceHtml = (item.evidence ?? []).map((evidence) => `<li><strong>${esc(evidence.chapterTitle || evidence.chapterId || "原文")}</strong><q>${esc(evidence.quote || "")}</q>${evidence.supports ? `<small>${esc(evidence.supports)}</small>` : ""}</li>`).join("");
2811
- const actions = item.status === "pending" && sides.length === 2 ? `<div class="card-actions character-duplicate-actions">
2918
+ const mergeActions = item.status === "pending" && sides.length === 2 && canMergeCharacters ? `
2812
2919
  <button data-merge-review="${esc(item.id)}" data-merge-target="${esc(sides[0].character.id)}" data-merge-source="${esc(sides[1].character.id)}" data-target-version="${esc(String(sides[0].reference.versionNo))}" data-source-version="${esc(String(sides[1].reference.versionNo))}">合并为 ${esc(sides[0].character.name)}</button>
2813
- <button data-merge-review="${esc(item.id)}" data-merge-target="${esc(sides[1].character.id)}" data-merge-source="${esc(sides[0].character.id)}" data-target-version="${esc(String(sides[1].reference.versionNo))}" data-source-version="${esc(String(sides[0].reference.versionNo))}">合并为 ${esc(sides[1].character.name)}</button>
2814
- <button data-keep-characters-separate="${esc(item.id)}">确认是不同角色</button>
2815
- </div>` : "";
2920
+ <button data-merge-review="${esc(item.id)}" data-merge-target="${esc(sides[1].character.id)}" data-merge-source="${esc(sides[0].character.id)}" data-target-version="${esc(String(sides[1].reference.versionNo))}" data-source-version="${esc(String(sides[0].reference.versionNo))}">合并为 ${esc(sides[1].character.name)}</button>` : "";
2921
+ const keepSeparateAction = item.status === "pending" && canResolveReview
2922
+ ? `<button data-keep-characters-separate="${esc(item.id)}">确认是不同角色</button>`
2923
+ : "";
2924
+ const actions = mergeActions || keepSeparateAction
2925
+ ? `<div class="card-actions character-duplicate-actions">${mergeActions}${keepSeparateAction}</div>`
2926
+ : "";
2816
2927
  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>`;
2817
2928
  };
2818
2929
  $("#module-content").innerHTML = reviews.length ? `<div class="card-grid">${reviews.map((item) => item.itemType === "character-duplicate" ? duplicateCard(item) : `
2819
2930
  <article class="record-card"><small>${esc(item.itemType)} · ${esc(item.severity)} · ${esc(item.status)}</small><h3>${esc(item.title)}</h3>
2820
2931
  <p>${esc(item.description)}${item.suggestion ? `\n建议:${esc(item.suggestion)}` : ""}</p>
2821
- ${item.status === "pending" ? `<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>`).join("")}</div>`
2932
+ ${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>`).join("")}</div>`
2822
2933
  : emptyModule("没有待审核事项", "候选设定、冲突与低置信度结论会集中显示在这里。");
2823
2934
  $("#module-content").querySelectorAll("[data-review-id]").forEach((button) => button.addEventListener("click", async () => {
2824
2935
  await api(`/api/reviews/${button.dataset.reviewId}`, { method: "PATCH", body: { status: button.dataset.reviewStatus } });
@@ -2861,12 +2972,15 @@ async function renderReviews() {
2861
2972
  async function renderTasks() {
2862
2973
  const [tasks, settings] = await Promise.all([
2863
2974
  apiPage(`/api/works/${state.work.id}/tasks`).then((result) => result.items),
2864
- api(`/api/works/${state.work.id}/ai-settings`)
2975
+ canReadModule("ai-settings")
2976
+ ? api(`/api/works/${state.work.id}/ai-settings`)
2977
+ : Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunBatchLimit: 20 })
2865
2978
  ]);
2979
+ const canConfigureAutoRun = canEditModule("tasks") && canEditModule("ai-settings");
2866
2980
  const pendingCount = tasks.filter((item) => item.status === "pending").length;
2867
2981
  const runningCount = tasks.filter((item) => item.status === "running").length;
2868
2982
  $("#module-content").innerHTML = `
2869
- <section class="task-auto-run-panel" aria-labelledby="task-auto-run-title">
2983
+ <section class="task-auto-run-panel ${canConfigureAutoRun ? "" : "hidden"}" aria-labelledby="task-auto-run-title">
2870
2984
  <div class="task-auto-run-copy">
2871
2985
  <strong id="task-auto-run-title">自动执行待分析任务</strong>
2872
2986
  <small>只执行已经进入“待执行”队列的任务,不会自动创建人物关系、世界观或其他分析。</small>
@@ -3084,6 +3198,10 @@ async function renderBookAiSettings() {
3084
3198
  "afterend",
3085
3199
  `<label><input name="agent-tool" type="checkbox" value="read_character_sections" ${agentTools.has("read_character_sections") ? "checked" : ""}><span><strong>读取人物 Markdown 章节</strong><small>根据知识查询返回的章节 ID 精读人物背景、能力与经历原文。</small></span></label>`
3086
3200
  );
3201
+ if (!canEditModule("ai-settings")) {
3202
+ host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
3203
+ host.querySelectorAll(".primary-button").forEach((button) => button.classList.add("permission-hidden"));
3204
+ }
3087
3205
  $("#save-work-system-prompt").addEventListener("click", async () => {
3088
3206
  const button = $("#save-work-system-prompt");
3089
3207
  button.disabled = true;
@@ -3152,8 +3270,9 @@ async function renderBookAiSettings() {
3152
3270
  async function loadModels() {
3153
3271
  const workId = state.work?.id;
3154
3272
  if (!workId) return;
3273
+ const generation = workScopedUiGeneration;
3155
3274
  const models = await api(`/api/works/${workId}/models`);
3156
- if (state.work?.id !== workId) return;
3275
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
3157
3276
  state.models = models;
3158
3277
  loadedAiModelsWorkId = workId;
3159
3278
  const select = $("#ai-model");
@@ -3294,11 +3413,12 @@ async function refreshAiContextUsage() {
3294
3413
  async function loadAiReferences() {
3295
3414
  const workId = state.work?.id;
3296
3415
  if (!workId) return;
3416
+ const generation = workScopedUiGeneration;
3297
3417
  const [characters, settings] = await Promise.all([
3298
- apiAllPages(`/api/works/${workId}/characters`),
3299
- apiAllPages(`/api/works/${workId}/settings`)
3418
+ canReadModule("characters") ? apiAllPages(`/api/works/${workId}/characters`) : Promise.resolve([]),
3419
+ canReadModule("settings") ? apiAllPages(`/api/works/${workId}/settings`) : Promise.resolve([])
3300
3420
  ]);
3301
- if (state.work?.id !== workId) return;
3421
+ if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
3302
3422
  state.characters = characters;
3303
3423
  state.settings = settings;
3304
3424
  loadedAiReferencesWorkId = workId;
@@ -3533,8 +3653,8 @@ function openWorkSettingsDialog(work) {
3533
3653
  if (!work) return;
3534
3654
  const canManageAccess = ["admin", "owner"].includes(String(work.accessRole));
3535
3655
  const accessField = `<section class="work-access-field" aria-labelledby="work-access-title">
3536
- <div><strong id="work-access-title">可访问人</strong><small>可以分别授予成员仅查看或共同编辑权限。</small></div>
3537
- ${canManageAccess ? '<button id="work-access-manage" class="ghost-button" type="button">添加或管理可访问人</button>' : '<small>仅作品创建者或系统管理员可以调整访问权限。</small>'}
3656
+ <div><strong id="work-access-title">成员权限</strong><small>选择成员后,可为每个作品模块单独设置无权限、只读或可编辑。</small><div class="work-access-options" aria-label="成员权限配置方式"><span>按成员配置</span><span>按模块授权</span><span>读写分离</span></div></div>
3657
+ ${canManageAccess ? '<button id="work-access-manage" class="ghost-button" type="button">配置成员权限</button>' : '<small>仅作品创建者或系统管理员可以调整访问权限。</small>'}
3538
3658
  </section>`;
3539
3659
  openDialog("作品信息",
3540
3660
  workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + accessField,
@@ -3610,13 +3730,13 @@ function openSettingEditor(item = null) {
3610
3730
  $("#setting-change-note").value = "";
3611
3731
  $("#setting-change-note-field").classList.toggle("hidden", !item);
3612
3732
  $("#setting-editor-submit").textContent = item ? "保存新版本" : "创建设定";
3613
- const viewOnly = !canEditWork();
3733
+ const viewOnly = !canEditModule("settings");
3614
3734
  $("#setting-editor-form").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = viewOnly; });
3615
3735
  $("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
3616
3736
  $("#setting-editor-submit").classList.toggle("hidden", viewOnly);
3617
3737
  $("#setting-editor-form").onsubmit = async (event) => {
3618
3738
  event.preventDefault();
3619
- if (!canEditWork()) return;
3739
+ if (!canEditModule("settings")) return;
3620
3740
  const form = new FormData(event.currentTarget);
3621
3741
  const submit = $("#setting-editor-submit");
3622
3742
  submit.disabled = true;
@@ -3698,11 +3818,11 @@ function renderCharacterEditorRelationships() {
3698
3818
  const relationLabel = [category, relationship.subtype].filter(Boolean).join(" · ") || "未细分";
3699
3819
  const keywords = Array.isArray(relationship.keywords) ? relationship.keywords : [];
3700
3820
  return `<article class="character-relationship-row">
3701
- <div class="character-relationship-heading"><div><strong>${esc(nameOf(otherCharacterId))}</strong><span>${direction} ${esc(relationLabel)}</span></div>${canEditWork() ? `<button type="button" data-character-relationship-edit="${esc(relationship.id)}">编辑关系</button>` : ""}</div>
3821
+ <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>
3702
3822
  <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>
3703
3823
  </article>`;
3704
3824
  }).join("");
3705
- host.innerHTML = `<div class="character-relationship-toolbar"><p>与 ${esc(characterEditorItem.name)} 有关的其他人物及关系关键词。</p>${canEditWork() ? '<button type="button" class="ghost-button" data-character-relationship-create>新建关系</button>' : ""}</div>${rows || '<p class="character-relationship-status">暂未记录与其他人物的关系。</p>'}`;
3825
+ 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>'}`;
3706
3826
  host.querySelectorAll("[data-character-relationship-edit]").forEach((button) => button.addEventListener("click", () => {
3707
3827
  const relationship = characterEditorRelationships.find((item) => item.id === button.dataset.characterRelationshipEdit);
3708
3828
  if (relationship) void openRelationshipDialog(relationship, { characterId });
@@ -3932,9 +4052,9 @@ function renderCharacterMarkdownSections() {
3932
4052
  host.innerHTML = '<div class="character-editor-empty-field"><b>Markdown 档案章节</b><span>创建人物档案后即可添加背景故事、能力、经历和研究记录。</span></div>';
3933
4053
  return;
3934
4054
  }
3935
- const toolbar = `<div class="character-markdown-list-toolbar"><div><b>Markdown 档案章节</b><span>长篇内容独立保存、渲染、检索和版本管理。</span></div>${canEditWork() ? '<button type="button" class="primary-button" data-character-section-create>新建章节</button>' : ""}</div>`;
4055
+ 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>`;
3936
4056
  const sections = characterEditorSections.map((section) => `<article class="character-markdown-section">
3937
- <header><div><span>${esc(characterSectionTypeLabels[section.sectionType] ?? section.sectionType)}</span><h4>${esc(section.title)}</h4>${section.summary ? `<p>${esc(section.summary)}</p>` : ""}</div><div>${canEditWork() ? `<button type="button" data-character-section-edit="${esc(section.id)}">编辑</button>` : ""}<button type="button" data-character-section-versions="${esc(section.id)}">版本</button>${canEditWork() ? `<button type="button" data-character-section-delete="${esc(section.id)}">删除</button>` : ""}</div></header>
4057
+ <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>
3938
4058
  <div class="character-markdown-document message-body">${renderMarkdown(section.contentMarkdown) || '<p class="character-markdown-empty">本章节暂无正文。</p>'}</div>
3939
4059
  <div data-character-section-versions-host="${esc(section.id)}"></div>
3940
4060
  </article>`).join("");
@@ -3992,14 +4112,20 @@ function renderCharacterEditorFields(item) {
3992
4112
  characterEditorSection("basic", "基础资料", "用于检索、去重和建立人物在作品中的基本归属。",
3993
4113
  field("name", "标准名", "text", item?.name) +
3994
4114
  field("aliases", "别名", "item-list", item?.aliases ?? []) +
3995
- (state.races.length
4115
+ (!canReadModule("races")
4116
+ ? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
4117
+ : state.races.length
3996
4118
  ? field("raceId", "种族", "select", item?.raceId ?? "", raceOptions)
3997
4119
  : '<div class="character-editor-empty-field"><b>种族</b><span>尚未创建种族,请先在“种族”模块建立档案。</span></div>') +
3998
- (organizationOptions.length
4120
+ (!canReadModule("organizations")
4121
+ ? '<div class="character-editor-empty-field"><b>所属组织</b><span>当前账户没有组织模块读取权限,原有绑定不会被修改。</span></div>'
4122
+ : organizationOptions.length
3999
4123
  ? field("organizationIds", "所属组织(可多选)", "chips", item?.organizationIds ?? [], organizationOptions)
4000
4124
  : '<div class="character-editor-empty-field"><b>所属组织</b><span>尚未创建组织,可稍后在“组织”模块中补充。</span></div>') +
4001
4125
  field("visibility", "可见范围", "select", item?.visibility ?? "author", [["author", "仅作者"], ["collaborators", "协作者"], ["public", "公开"]]) +
4002
- field("firstChapterId", "首次登场章节", "select", item?.firstChapterId ?? "", chapterOptions)),
4126
+ (canReadModule("editor")
4127
+ ? field("firstChapterId", "首次登场章节", "select", item?.firstChapterId ?? "", chapterOptions)
4128
+ : '<div class="character-editor-empty-field"><b>首次登场章节</b><span>当前账户没有正文读取权限,原有绑定不会被修改。</span></div>')),
4003
4129
  characterEditorSection("profile", "人物档案", "记录人物定位、行为动力和便于创作时快速理解的简介。",
4004
4130
  field("identity", "身份与定位", "text", item?.attributes?.identity) +
4005
4131
  field("motivation", "核心动机", "textarea", item?.profile?.motivation) +
@@ -4035,11 +4161,9 @@ function collectCharacterBody(form) {
4035
4161
  const item = characterEditorItem;
4036
4162
  const profile = { ...(item?.profile ?? {}) };
4037
4163
  delete profile.sections;
4038
- return {
4164
+ const body = {
4039
4165
  name: String(form.get("name") ?? "").trim(),
4040
4166
  aliases: form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean),
4041
- raceId: form.get("raceId") || null,
4042
- organizationIds: form.getAll("organizationIds").map(String),
4043
4167
  attributes: {
4044
4168
  ...(item?.attributes ?? {}),
4045
4169
  identity: String(form.get("identity") ?? "").trim(),
@@ -4053,9 +4177,12 @@ function collectCharacterBody(form) {
4053
4177
  currentState: buildCharacterState(form.getAll("stateKey"), form.getAll("stateValue"), item?.currentState ?? {}),
4054
4178
  lockedFields: form.getAll("lockedFields").map((value) => String(value).trim()).filter(Boolean),
4055
4179
  visibility: String(form.get("visibility") ?? "author"),
4056
- firstChapterId: form.get("firstChapterId") || null,
4057
4180
  changeNote: String(form.get("changeNote") ?? "").trim()
4058
4181
  };
4182
+ if (canReadModule("races")) body.raceId = form.get("raceId") || null;
4183
+ if (canReadModule("organizations")) body.organizationIds = form.getAll("organizationIds").map(String);
4184
+ if (canReadModule("editor")) body.firstChapterId = form.get("firstChapterId") || null;
4185
+ return body;
4059
4186
  }
4060
4187
 
4061
4188
  function renderCharacterHistory() {
@@ -4127,8 +4254,8 @@ async function showCharacterHistory() {
4127
4254
 
4128
4255
  async function openCharacterEditor(item = null) {
4129
4256
  [state.races, state.organizations] = await Promise.all([
4130
- apiAllPages(`/api/works/${state.work.id}/races`),
4131
- apiAllPages(`/api/works/${state.work.id}/organizations`)
4257
+ canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
4258
+ canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
4132
4259
  ]);
4133
4260
  characterEditorItem = item ?? null;
4134
4261
  characterEditorVersions = [];
@@ -4144,7 +4271,7 @@ async function openCharacterEditor(item = null) {
4144
4271
  $("#character-history-button").title = item ? "查看、比较和回滚历史版本" : "创建人物档案后即可查看版本历史";
4145
4272
  setCharacterHistoryVisible(false);
4146
4273
  renderCharacterEditorFields(item);
4147
- const viewOnly = !canEditWork();
4274
+ const viewOnly = !canEditModule("characters");
4148
4275
  if (viewOnly) {
4149
4276
  $("#character-editor-eyebrow").textContent = "人物档案";
4150
4277
  $("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
@@ -4156,12 +4283,12 @@ async function openCharacterEditor(item = null) {
4156
4283
  button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
4157
4284
  });
4158
4285
  const relationshipTab = document.querySelector("[data-character-editor-tab='relationships']");
4159
- relationshipTab.disabled = !item;
4160
- relationshipTab.title = item ? "查看和编辑人物关系" : "创建人物档案后即可维护人物关系";
4286
+ relationshipTab.disabled = !item || !canReadModule("relationships");
4287
+ relationshipTab.title = !canReadModule("relationships") ? "当前账户没有关系模块读取权限" : item ? "查看和编辑人物关系" : "创建人物档案后即可维护人物关系";
4161
4288
  const form = $("#character-editor-form");
4162
4289
  form.onsubmit = async (event) => {
4163
4290
  event.preventDefault();
4164
- if (!canEditWork()) return;
4291
+ if (!canEditModule("characters")) return;
4165
4292
  const submit = $("#character-editor-submit");
4166
4293
  submit.disabled = true;
4167
4294
  try {
@@ -4183,13 +4310,13 @@ async function openCharacterEditor(item = null) {
4183
4310
  };
4184
4311
  showEntityEditorPage("character");
4185
4312
  if (item) {
4186
- void loadCharacterEditorRelationships(item.id);
4313
+ if (canReadModule("relationships")) void loadCharacterEditorRelationships(item.id);
4187
4314
  void loadCharacterMarkdownSections(item.id);
4188
4315
  }
4189
4316
  }
4190
4317
 
4191
4318
  async function openRaceDialog(item) {
4192
- state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
4319
+ state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
4193
4320
  const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
4194
4321
  const parentOptions = [["", "无(根种族)"], ...eligibleRaceParents(state.races, item?.id)
4195
4322
  .sort((left, right) => racePathLabel(left).localeCompare(racePathLabel(right), "zh-CN"))
@@ -4202,7 +4329,8 @@ async function openRaceDialog(item) {
4202
4329
  (memberOptions.length ? field("memberIds", "属于该种族的角色(可多选)", "chips", item?.memberIds ?? [], memberOptions) : ""),
4203
4330
  async (form) => {
4204
4331
  const settings = form.getAll("settings").map((value) => String(value).trim()).filter(Boolean);
4205
- const body = { name: form.get("name"), parentRaceId: form.get("parentRaceId") || null, description: form.get("description"), settings, memberIds: form.getAll("memberIds").map(String) };
4332
+ const body = { name: form.get("name"), parentRaceId: form.get("parentRaceId") || null, description: form.get("description"), settings };
4333
+ if (canReadModule("characters")) body.memberIds = form.getAll("memberIds").map(String);
4206
4334
  await api(item ? `/api/races/${item.id}` : `/api/works/${state.work.id}/races`, { method: item ? "PATCH" : "POST", body });
4207
4335
  await renderRaces();
4208
4336
  await loadAiReferences();
@@ -4210,7 +4338,7 @@ async function openRaceDialog(item) {
4210
4338
  }
4211
4339
 
4212
4340
  async function openOrganizationDialog(item) {
4213
- state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
4341
+ state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
4214
4342
  const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
4215
4343
  openDialog(item ? "编辑组织" : "新建组织",
4216
4344
  field("name", "组织名称", "text", item?.name) +
@@ -4219,7 +4347,8 @@ async function openOrganizationDialog(item) {
4219
4347
  (memberOptions.length ? field("memberIds", "组织成员(可多选)", "chips", item?.memberIds ?? [], memberOptions) : ""),
4220
4348
  async (form) => {
4221
4349
  const settings = form.getAll("settings").map((value) => String(value).trim()).filter(Boolean);
4222
- const body = { name: form.get("name"), description: form.get("description"), settings, memberIds: form.getAll("memberIds").map(String) };
4350
+ const body = { name: form.get("name"), description: form.get("description"), settings };
4351
+ if (canReadModule("characters")) body.memberIds = form.getAll("memberIds").map(String);
4223
4352
  await api(item ? `/api/organizations/${item.id}` : `/api/works/${state.work.id}/organizations`, { method: item ? "PATCH" : "POST", body });
4224
4353
  await renderOrganizations();
4225
4354
  await loadAiReferences();
@@ -4316,6 +4445,7 @@ function openTimelineSplitDialog(item) {
4316
4445
  }
4317
4446
 
4318
4447
  async function openRelationshipDialog(item, options = {}) {
4448
+ if (!canReadModule("characters")) return toast("配置人物关系前需要角色模块读取权限", "error");
4319
4449
  state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
4320
4450
  if (state.characters.length < 2) return toast("至少需要两个角色才能创建关系", "error");
4321
4451
  const characterOptions = state.characters.map((item) => [item.id, item.name]);
@@ -4642,6 +4772,120 @@ async function showVersions() {
4642
4772
  $("#versions-dialog").showModal();
4643
4773
  }
4644
4774
 
4775
+ function importHistoryRecordLabel(version) {
4776
+ const restorePrefix = "before-restore:";
4777
+ if (version.fileType === "snapshot" && version.fileName.startsWith(restorePrefix)) {
4778
+ return {
4779
+ title: `恢复操作前备份:${version.fileName.slice(restorePrefix.length)}`,
4780
+ kind: "自动备份",
4781
+ action: "恢复此操作前备份"
4782
+ };
4783
+ }
4784
+ return { title: version.fileName, kind: String(version.fileType).toUpperCase(), action: "恢复到此次导入前" };
4785
+ }
4786
+
4787
+ function renderImportHistory(versions, nextPage = null) {
4788
+ const host = $("#import-history-list");
4789
+ if (!versions.length) {
4790
+ host.innerHTML = '<p class="entity-history-empty">还没有正文导入记录。首次导入后会自动保存导入前快照。</p>';
4791
+ return;
4792
+ }
4793
+ host.innerHTML = versions.map((version) => {
4794
+ const label = importHistoryRecordLabel(version);
4795
+ return `<article class="entity-version-card import-history-card" data-file-version="${esc(version.id)}">
4796
+ <header><strong title="${esc(label.title)}">${esc(label.title)}</strong><span class="import-history-kind">${esc(label.kind)}</span></header>
4797
+ <time>${esc(formatDateTime(version.createdAt))} · ${esc(version.actor || "历史数据")}</time>
4798
+ <p>${version.fileType === "snapshot" ? "恢复操作执行前自动保存的完整正文。" : "保存的是这次文件导入开始前的完整正文。"}</p>
4799
+ <small>自动备份仅包含正文,不能撤销章节关联信息的变化。</small>
4800
+ <button type="button" data-file-version-restore="${esc(version.id)}" data-default-label="${esc(label.action)}">${esc(label.action)}</button>
4801
+ </article>`;
4802
+ }).join("") + (nextPage ? '<button class="import-history-load-more" type="button" data-import-history-load-more>加载更多记录</button>' : "");
4803
+ host.querySelectorAll("[data-file-version-restore]").forEach((button) => button.addEventListener("click", async () => {
4804
+ if (!state.work || !canReplaceProse()) return;
4805
+ const defaultLabel = button.dataset.defaultLabel;
4806
+ if (button.dataset.confirmed !== "true") {
4807
+ host.querySelectorAll("[data-file-version-restore]").forEach((other) => {
4808
+ other.dataset.confirmed = "false";
4809
+ other.classList.remove("is-confirming");
4810
+ other.textContent = other.dataset.defaultLabel;
4811
+ });
4812
+ button.dataset.confirmed = "true";
4813
+ button.classList.add("is-confirming");
4814
+ button.textContent = "再次点击确认恢复";
4815
+ window.setTimeout(() => {
4816
+ if (!button.isConnected || button.dataset.confirmed !== "true") return;
4817
+ button.dataset.confirmed = "false";
4818
+ button.classList.remove("is-confirming");
4819
+ button.textContent = defaultLabel;
4820
+ }, 5000);
4821
+ return;
4822
+ }
4823
+ if (!confirmDiscardChanges("当前章节有未保存修改,恢复正文会丢弃这些本地修改。是否继续?")) return;
4824
+ button.disabled = true;
4825
+ cancelChapterAutoSave();
4826
+ const workId = state.work.id;
4827
+ try {
4828
+ await api(`/api/works/${encodeURIComponent(workId)}/file-versions/${encodeURIComponent(button.dataset.fileVersionRestore)}/restore`, {
4829
+ method: "POST",
4830
+ body: { expectedVersionNo: state.work.versionNo }
4831
+ });
4832
+ state.dirty = false;
4833
+ resetWorkScopedUiCaches();
4834
+ $("#import-history-dialog").close();
4835
+ await loadWorks(workId);
4836
+ toast("正文已恢复;恢复前正文已自动备份");
4837
+ } catch (error) {
4838
+ button.disabled = false;
4839
+ button.dataset.confirmed = "false";
4840
+ button.classList.remove("is-confirming");
4841
+ button.textContent = defaultLabel;
4842
+ if (state.dirty) scheduleChapterAutoSave();
4843
+ toast(error.message, "error");
4844
+ }
4845
+ }));
4846
+ host.querySelector("[data-import-history-load-more]")?.addEventListener("click", async (event) => {
4847
+ const button = event.currentTarget;
4848
+ button.disabled = true;
4849
+ button.textContent = "正在加载…";
4850
+ try {
4851
+ await loadImportHistoryPage(nextPage);
4852
+ } catch (error) {
4853
+ button.disabled = false;
4854
+ button.textContent = "加载更多记录";
4855
+ toast(error.message, "error");
4856
+ }
4857
+ });
4858
+ }
4859
+
4860
+ async function loadImportHistoryPage(page) {
4861
+ const workId = state.work?.id;
4862
+ if (!workId || !page) return;
4863
+ const requestId = ++importHistoryRequestId;
4864
+ const result = await apiPage(`/api/works/${encodeURIComponent(workId)}/file-versions`, page, 25);
4865
+ if (requestId !== importHistoryRequestId || state.work?.id !== workId || !$("#import-history-dialog").open) return;
4866
+ importHistoryRecords = page === 1 ? result.items : [...importHistoryRecords, ...result.items];
4867
+ importHistoryNextPage = result.nextPage;
4868
+ renderImportHistory(importHistoryRecords, importHistoryNextPage);
4869
+ }
4870
+
4871
+ async function openImportHistory() {
4872
+ if (!state.work || !canReplaceProse()) {
4873
+ toast("恢复整本正文需要所有受影响模块的编辑权限", "error");
4874
+ return;
4875
+ }
4876
+ importHistoryRecords = [];
4877
+ importHistoryNextPage = null;
4878
+ importHistoryRequestId += 1;
4879
+ $("#import-history-list").innerHTML = '<p class="entity-history-empty">正在读取导入历史…</p>';
4880
+ $("#import-history-dialog").showModal();
4881
+ try {
4882
+ await loadImportHistoryPage(1);
4883
+ } catch (error) {
4884
+ $("#import-history-dialog").close();
4885
+ toast(error.message, "error");
4886
+ }
4887
+ }
4888
+
4645
4889
  async function showChapterInsight() {
4646
4890
  if (!state.chapter) return;
4647
4891
  const panel = $("#chapter-insight");
@@ -4918,18 +5162,34 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
4918
5162
  }
4919
5163
  });
4920
5164
  $("#members-dialog-close").addEventListener("click", () => $("#members-dialog").close());
4921
- $("#members-dialog").addEventListener("close", () => { memberDialogWork = null; });
4922
- $("#member-invite-form").addEventListener("submit", async (event) => {
5165
+ $("#members-dialog").addEventListener("close", () => {
5166
+ memberDialogWork = null;
5167
+ memberDialogMembers = [];
5168
+ memberDialogDirectory = [];
5169
+ });
5170
+ $("#member-user-select").addEventListener("change", () => selectMemberForConfiguration($("#member-user-select").value));
5171
+ $("#member-permission-form").querySelectorAll("[data-permission-preset]").forEach((button) => button.addEventListener("click", () => {
5172
+ $("#member-permission-grid").querySelectorAll("[data-member-permission]").forEach((select) => {
5173
+ select.value = button.dataset.permissionPreset;
5174
+ });
5175
+ }));
5176
+ $("#member-permission-form").addEventListener("submit", async (event) => {
4923
5177
  event.preventDefault();
4924
5178
  const userId = $("#member-user-select").value;
4925
5179
  const work = memberDialogWork ?? state.work;
4926
5180
  if (!work || !userId) return;
4927
5181
  try {
4928
- const role = $("#member-role-select").value;
4929
- const members = await api(`/api/works/${encodeURIComponent(work.id)}/members`, { method: "POST", body: { userId, role } });
5182
+ const existing = memberDialogMembers.some((member) => member.userId === userId && member.role !== "owner");
5183
+ const permissions = selectedMemberPermissions();
5184
+ const members = await api(existing
5185
+ ? `/api/works/${encodeURIComponent(work.id)}/members/${encodeURIComponent(userId)}`
5186
+ : `/api/works/${encodeURIComponent(work.id)}/members`, {
5187
+ method: existing ? "PATCH" : "POST",
5188
+ body: existing ? { permissions } : { userId, permissions }
5189
+ });
4930
5190
  renderMembers(members);
4931
- await fillMemberCandidates(members);
4932
- toast(role === "viewer" ? "仅查看成员已邀请" : role === "settings-editor" ? "设定编辑已邀请" : "完整协作者已邀请");
5191
+ renderMemberSelector(userId);
5192
+ toast(existing ? "成员模块权限已更新" : "成员已添加并保存模块权限");
4933
5193
  } catch (error) { toast(error.message, "error"); }
4934
5194
  });
4935
5195
  $("#platform-new-provider").addEventListener("click", () => openProviderDialog());
@@ -4942,6 +5202,8 @@ $("#new-volume-button").addEventListener("click", () => openVolumeDialog());
4942
5202
  $("#insight-button").addEventListener("click", () => showChapterInsight().catch((error) => toast(error.message, "error")));
4943
5203
  $("#versions-button").addEventListener("click", showVersions);
4944
5204
  $("#versions-close").addEventListener("click", () => $("#versions-dialog").close());
5205
+ $("#import-history-button").addEventListener("click", () => openImportHistory());
5206
+ $("#import-history-close").addEventListener("click", () => $("#import-history-dialog").close());
4945
5207
  $("#entity-history-close").addEventListener("click", () => $("#entity-history-dialog").close());
4946
5208
  $("#ai-tool-call-close").addEventListener("click", () => $("#ai-tool-call-dialog").close());
4947
5209
  $("#setting-editor-back").addEventListener("click", () => { void closeEntityEditor(); });