@musnows/scriverse 0.3.9 → 0.3.10

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,11 @@ 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");
89
90
  }
90
91
 
91
92
  function canManageWork(work = state.work) {
@@ -93,23 +94,43 @@ function canManageWork(work = state.work) {
93
94
  }
94
95
 
95
96
  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);
97
+ return canWriteUiModule(work, module);
98
+ }
99
+
100
+ function canReadModule(module, work = state.work) {
101
+ return canReadUiModule(work, module);
102
+ }
103
+
104
+ function canReadAggregateContent(work = state.work) {
105
+ return ["editor", "settings", "characters", "races", "organizations", "timeline", "relationships", "outlines"]
106
+ .every((module) => canReadModule(module, work));
98
107
  }
99
108
 
100
109
  function applyWorkAccessMode() {
101
110
  const viewOnly = Boolean(state.work) && !canEditWork();
102
- const settingsOnly = String(state.work?.accessRole) === "settings-editor";
103
111
  const proseReadOnly = Boolean(state.work) && !canEditProse();
112
+ const proseHidden = Boolean(state.work) && !canReadModule("editor");
113
+ const aiHidden = Boolean(state.work) && !canReadModule("tasks");
114
+ const aiReadOnly = Boolean(state.work) && !canEditModule("tasks");
115
+ const moduleReadOnly = Boolean(state.work) && !canEditModule(state.module);
104
116
  $("#app").classList.toggle("view-only-mode", viewOnly);
105
- $("#app").classList.toggle("settings-only-mode", settingsOnly);
106
117
  $("#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);
118
+ $("#app").classList.toggle("prose-hidden-mode", proseHidden);
119
+ $("#app").classList.toggle("ai-hidden-mode", aiHidden);
120
+ document.body.classList.toggle("work-viewer-mode", moduleReadOnly);
121
+ for (const item of WORK_PERMISSION_MODULES) {
122
+ const button = $(`#module-nav [data-module="${item.uiModule}"]`);
123
+ if (button) button.classList.toggle("permission-hidden", !canReadModule(item.uiModule));
124
+ }
125
+ $("#module-nav [data-work-settings]").classList.toggle("permission-hidden", Boolean(state.work) && !canManageWork());
126
+ $(".ai-panel").classList.toggle("permission-hidden", aiHidden);
109
127
  $("#chapter-title").readOnly = proseReadOnly;
110
128
  $("#chapter-content").readOnly = proseReadOnly;
111
129
  $("#chapter-title").setAttribute("aria-readonly", String(proseReadOnly));
112
130
  $("#chapter-content").setAttribute("aria-readonly", String(proseReadOnly));
131
+ $("#ai-prompt").readOnly = aiReadOnly;
132
+ $("#ai-prompt").setAttribute("aria-readonly", String(aiReadOnly));
133
+ $("#ai-send").classList.toggle("permission-hidden", aiReadOnly);
113
134
  if (proseReadOnly) {
114
135
  cancelChapterAutoSave();
115
136
  state.dirty = false;
@@ -162,6 +183,8 @@ const panelLayoutStorageKey = "ai-novel-panel-layout-v1";
162
183
  const panelLayoutDefaults = Object.freeze({ leftWidth: 280, aiWidth: 360, leftCollapsed: false, aiCollapsed: false });
163
184
  let restoringPageRoute = true;
164
185
  let memberDialogWork = null;
186
+ let memberDialogMembers = [];
187
+ let memberDialogDirectory = [];
165
188
  let onboardingStep = 0;
166
189
  let onboardingAutoScheduled = false;
167
190
  let onboardingPositionFrame = null;
@@ -510,7 +533,8 @@ function showEntityEditorPage(type) {
510
533
  }
511
534
 
512
535
  function markEntityEditorDirty() {
513
- if (entityEditorType && canEditWork()) entityEditorDirty = true;
536
+ const module = entityEditorType === "setting" ? "settings" : "characters";
537
+ if (entityEditorType && canEditModule(module)) entityEditorDirty = true;
514
538
  }
515
539
 
516
540
  function confirmEntityEditorDiscard(message) {
@@ -1905,13 +1929,14 @@ function captureSettingsReturnContext() {
1905
1929
  function renderSettingsHub() {
1906
1930
  const hasWork = Boolean(state.work);
1907
1931
  const canManageWork = hasWork && ["admin", "owner"].includes(String(state.work.accessRole));
1932
+ const canReadAggregate = hasWork && canReadAggregateContent();
1908
1933
  const isAdmin = state.user?.role === "admin";
1909
1934
  $("#platform-ai-button").classList.toggle("hidden", !isAdmin);
1910
1935
  $("#user-management-button").classList.toggle("hidden", !isAdmin);
1911
1936
  $("#platform-ui-settings-button").classList.toggle("hidden", !isAdmin);
1912
1937
  $("#collaboration-button").disabled = !canManageWork;
1913
- $("#top-search-button").disabled = !hasWork;
1914
- $("#export-button").disabled = !hasWork;
1938
+ $("#top-search-button").disabled = !canReadAggregate;
1939
+ $("#export-button").disabled = !canReadAggregate;
1915
1940
  $("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
1916
1941
  $("#settings-work-note").textContent = hasWork
1917
1942
  ? `当前作品:《${state.work.title}》。导出将作用于这部作品。`
@@ -1973,61 +1998,99 @@ async function openPlatformUiSettingsDialog() {
1973
1998
  }
1974
1999
  }
1975
2000
 
2001
+ function renderMemberPermissionGrid(value) {
2002
+ const permissions = normalizeModulePermissions(value, "custom");
2003
+ $("#member-permission-grid").innerHTML = WORK_PERMISSION_MODULES.map((item) => `<label class="member-permission-row">
2004
+ <span>${esc(item.label)}</span>
2005
+ <select data-member-permission="${esc(item.id)}" aria-label="${esc(item.label)}权限">
2006
+ <option value="none" ${permissions[item.id] === "none" ? "selected" : ""}>无权限</option>
2007
+ <option value="read" ${permissions[item.id] === "read" ? "selected" : ""}>只读</option>
2008
+ <option value="write" ${permissions[item.id] === "write" ? "selected" : ""}>可编辑</option>
2009
+ </select>
2010
+ </label>`).join("");
2011
+ }
2012
+
2013
+ function selectedMemberPermissions() {
2014
+ const permissions = emptyModulePermissions();
2015
+ $("#member-permission-grid").querySelectorAll("[data-member-permission]").forEach((select) => {
2016
+ permissions[select.dataset.memberPermission] = select.value;
2017
+ });
2018
+ return permissions;
2019
+ }
2020
+
2021
+ function selectMemberForConfiguration(userId) {
2022
+ const fieldset = $("#member-permission-fieldset");
2023
+ const member = memberDialogMembers.find((item) => item.userId === userId);
2024
+ fieldset.disabled = !userId;
2025
+ renderMemberPermissionGrid(member?.permissions ?? emptyModulePermissions());
2026
+ $("#member-permission-submit").textContent = member ? "更新模块权限" : "添加成员并保存";
2027
+ }
2028
+
2029
+ function renderMemberSelector(selectedUserId = "") {
2030
+ const ownerIds = new Set(memberDialogMembers.filter((member) => member.role === "owner").map((member) => member.userId));
2031
+ const people = new Map(memberDialogDirectory.map((user) => [user.userId, user]));
2032
+ for (const member of memberDialogMembers) if (member.role !== "owner") people.set(member.userId, member);
2033
+ const options = [...people.values()].filter((user) => !ownerIds.has(user.userId));
2034
+ $("#member-user-select").innerHTML = options.length
2035
+ ? `<option value="">选择用户</option>${options.map((user) => {
2036
+ const existing = memberDialogMembers.some((member) => member.userId === user.userId && member.role !== "owner");
2037
+ return `<option value="${esc(user.userId)}" ${selectedUserId === user.userId ? "selected" : ""}>${esc(user.displayName)} · @${esc(user.username)}${existing ? " · 已加入" : ""}</option>`;
2038
+ }).join("")}`
2039
+ : '<option value="">没有可配置的用户</option>';
2040
+ $("#member-user-select").disabled = !options.length;
2041
+ selectMemberForConfiguration(selectedUserId);
2042
+ }
2043
+
1976
2044
  function renderMembers(members) {
2045
+ memberDialogMembers = members;
1977
2046
  const work = memberDialogWork ?? state.work;
1978
2047
  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("");
2048
+ $("#members-list").innerHTML = members.map((member) => {
2049
+ const descriptionId = `member-role-summary-${member.userId}`;
2050
+ return `<article class="access-row">
2051
+ <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>
2052
+ ${member.role === "owner" || !canManage ? "<span></span>" : `<button type="button" data-configure-member="${esc(member.userId)}" aria-describedby="${esc(descriptionId)}">配置权限</button>`}
2053
+ ${member.role === "owner" || !canManage ? "<span></span>" : `<button type="button" data-remove-member="${esc(member.userId)}">移除</button>`}
2054
+ </article>`;
2055
+ }).join("");
1984
2056
  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
- }
2057
+ $("#members-list").querySelectorAll("[data-configure-member]").forEach((button) => button.addEventListener("click", () => {
2058
+ $("#member-user-select").value = button.dataset.configureMember;
2059
+ selectMemberForConfiguration(button.dataset.configureMember);
2060
+ $("#member-user-select").focus();
1996
2061
  }));
1997
2062
  $("#members-list").querySelectorAll("[data-remove-member]").forEach((button) => button.addEventListener("click", async () => {
1998
2063
  if (!work) return;
1999
2064
  try {
2000
2065
  const updated = await api(`/api/works/${encodeURIComponent(work.id)}/members/${encodeURIComponent(button.dataset.removeMember)}`, { method: "DELETE" });
2001
2066
  renderMembers(updated);
2002
- await fillMemberCandidates(updated);
2067
+ renderMemberSelector();
2003
2068
  toast("协作者已移除");
2004
2069
  } catch (error) { toast(error.message, "error"); }
2005
2070
  }));
2006
2071
  }
2007
2072
 
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
2073
  async function openMembersDialog(targetWork = state.work) {
2019
2074
  if (!targetWork) return;
2020
2075
  memberDialogWork = targetWork;
2021
2076
  const canManage = ["admin", "owner"].includes(String(targetWork.accessRole));
2022
2077
  $("#members-dialog-eyebrow").textContent = `作品权限 · 《${targetWork.title}》`;
2023
- $("#members-dialog-title").textContent = "可访问人";
2078
+ $("#members-dialog-title").textContent = "成员模块权限";
2024
2079
  $("#members-list").innerHTML = '<p class="empty-state">正在读取成员……</p>';
2025
- $("#member-invite-form").classList.toggle("hidden", !canManage);
2080
+ $("#member-permission-form").classList.toggle("hidden", !canManage);
2081
+ memberDialogMembers = [];
2082
+ memberDialogDirectory = [];
2083
+ renderMemberPermissionGrid(emptyModulePermissions());
2084
+ $("#member-permission-fieldset").disabled = true;
2026
2085
  $("#members-dialog").showModal();
2027
2086
  try {
2028
- const members = await api(`/api/works/${encodeURIComponent(targetWork.id)}/members`);
2087
+ const [members, directory] = await Promise.all([
2088
+ api(`/api/works/${encodeURIComponent(targetWork.id)}/members`),
2089
+ canManage ? api("/api/users/directory") : Promise.resolve([])
2090
+ ]);
2091
+ memberDialogDirectory = directory;
2029
2092
  renderMembers(members);
2030
- if (canManage) await fillMemberCandidates(members);
2093
+ if (canManage) renderMemberSelector();
2031
2094
  } catch (error) { $("#members-dialog").close(); toast(error.message, "error"); }
2032
2095
  }
2033
2096
 
@@ -2181,7 +2244,7 @@ function renderShelf() {
2181
2244
  <span class="book-cover-fallback">${esc(Array.from(work.title)[0] ?? "书")}</span>
2182
2245
  ${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面">` : ""}
2183
2246
  </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>
2247
+ <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
2248
  </button>
2186
2249
  ${canManageWork(work) ? `<button class="book-card-settings" type="button" data-edit-work="${esc(work.id)}" aria-label="作品设置" title="作品设置">设置</button>` : ""}
2187
2250
  </article>`).join("")}
@@ -2231,16 +2294,18 @@ async function selectWork(workId, preferredChapterId = null) {
2231
2294
  settingsReturnContext = null;
2232
2295
  state.work = nextWork;
2233
2296
  state.chapter = null;
2297
+ if (!canReadModule(state.module)) state.module = firstReadableUiModule(state.work) ?? "editor";
2234
2298
  applyWorkAccessMode();
2235
2299
  updateDocumentTitle(state.work);
2236
2300
  $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
2237
- $("#top-search-button").disabled = false;
2301
+ $("#top-search-button").disabled = !canReadAggregateContent();
2238
2302
  renderTree();
2239
2303
  const chapters = state.work.volumes.flatMap((volume) => volume.chapters);
2240
2304
  const targetChapter = chapters.find((chapter) => chapter.id === preferredChapterId) ?? chapters[0];
2241
2305
  if (state.module === "editor" && preferredChapterId) await selectChapter(preferredChapterId);
2242
2306
  else if (state.module === "editor" && targetChapter) await selectChapter(targetChapter.id);
2243
- else if (state.module === "editor") showWelcome(true);
2307
+ else if (state.module === "editor" && canReadModule("editor")) showWelcome(true);
2308
+ else if (!canReadModule(state.module)) showWelcome(true);
2244
2309
  else await showModule(state.module);
2245
2310
  return true;
2246
2311
  }
@@ -2310,6 +2375,7 @@ async function selectChapter(chapterId) {
2310
2375
  state.chapter = await api(`/api/chapters/${chapterId}`);
2311
2376
  lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
2312
2377
  state.module = "editor";
2378
+ applyWorkAccessMode();
2313
2379
  markActiveModule("editor");
2314
2380
  $("#welcome-view").classList.add("hidden");
2315
2381
  $("#module-view").classList.add("hidden");
@@ -2326,7 +2392,7 @@ async function selectChapter(chapterId) {
2326
2392
  scheduleChapterLineNumbers();
2327
2393
  $("#chapter-insight").classList.add("hidden");
2328
2394
  updateChapterStats();
2329
- if (!canEditProse()) setSaveState(canEditWork() ? "正文只读" : "仅查看");
2395
+ if (!canEditProse()) setSaveState("正文只读");
2330
2396
  else if (spacingChanged) scheduleChapterAutoSave(120);
2331
2397
  else setSaveState("已保存");
2332
2398
  renderTree();
@@ -2384,10 +2450,18 @@ const moduleMeta = {
2384
2450
 
2385
2451
  async function showModule(module) {
2386
2452
  if (!state.work) return showWelcome();
2387
- if (!canEditProse() && ["tasks", "ai-settings"].includes(module)) module = "editor";
2453
+ if (!canReadModule(module)) {
2454
+ const fallback = firstReadableUiModule(state.work);
2455
+ if (!fallback) {
2456
+ showWelcome(true);
2457
+ return toast("当前账户尚未获授权访问任何作品模块", "error");
2458
+ }
2459
+ module = fallback;
2460
+ }
2388
2461
  if (module !== "editor" && state.module === "editor" && !confirmDiscardChanges()) return;
2389
2462
  if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
2390
2463
  state.module = module;
2464
+ applyWorkAccessMode();
2391
2465
  markActiveModule(module);
2392
2466
  if (module === "editor") {
2393
2467
  if (state.chapter) await selectChapter(state.chapter.id);
@@ -2545,10 +2619,10 @@ async function renderSettings() {
2545
2619
  async function renderCharacters() {
2546
2620
  [state.characters, state.races, state.organizations] = await Promise.all([
2547
2621
  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`)
2622
+ canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
2623
+ canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
2550
2624
  ]);
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>`;
2625
+ 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
2626
  $("#module-content").innerHTML = auditPanel + (state.characters.length ? `<div class="card-grid">${state.characters.map((item) => {
2553
2627
  const details = normalizeCharacterDetails(item.attributes?.details);
2554
2628
  return `
@@ -2560,7 +2634,7 @@ async function renderCharacters() {
2560
2634
  <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
2635
  ${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
2636
  ${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>`;
2637
+ <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
2638
  }).join("")}</div>`
2565
2639
  : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
2566
2640
  $("#create-character-audit-task")?.addEventListener("click", async () => {
@@ -2618,7 +2692,7 @@ async function renderCharacters() {
2618
2692
  async function renderRaces() {
2619
2693
  [state.races, state.characters] = await Promise.all([
2620
2694
  apiAllPages(`/api/works/${state.work.id}/races`),
2621
- apiAllPages(`/api/works/${state.work.id}/characters`)
2695
+ canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
2622
2696
  ]);
2623
2697
  const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
2624
2698
  <summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
@@ -2628,7 +2702,7 @@ async function renderRaces() {
2628
2702
  <p>${esc(item.description || "尚未填写种族简介")}</p>
2629
2703
  <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
2704
  <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>
2705
+ <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
2706
  </article>
2633
2707
  ${item.children.length ? `<div class="race-tree-children">${item.children.map(renderRaceNode).join("")}</div>` : ""}
2634
2708
  </div>
@@ -2665,14 +2739,14 @@ async function renderRaces() {
2665
2739
  async function renderOrganizations() {
2666
2740
  [state.organizations, state.characters] = await Promise.all([
2667
2741
  apiAllPages(`/api/works/${state.work.id}/organizations`),
2668
- apiAllPages(`/api/works/${state.work.id}/characters`)
2742
+ canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
2669
2743
  ]);
2670
2744
  $("#module-content").innerHTML = state.organizations.length ? `<div class="card-grid organization-grid">${state.organizations.map((item) => `
2671
2745
  <article class="record-card organization-card"><small>${item.memberIds.length} 位成员 · ${item.settings.length} 条设定</small>
2672
2746
  <h3>${esc(item.name)}</h3><p>${esc(item.description || "尚未填写组织简介")}</p>
2673
2747
  <div class="organization-settings">${item.settings.map((setting) => `<span class="pill">${esc(setting)}</span>`).join("") || '<span class="pill">暂无组织设定</span>'}</div>
2674
2748
  <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>
2749
+ <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
2750
  </article>`).join("")}</div>` : emptyModule("还没有组织", "创建国家、机构、阵营或团队,并维护组织设定与成员。");
2677
2751
  $("#module-content").querySelectorAll("[data-edit-organization]").forEach((button) => button.addEventListener("click", () => openOrganizationDialog(state.organizations.find((item) => item.id === button.dataset.editOrganization))));
2678
2752
  $("#module-content").querySelectorAll("[data-merge-organization]").forEach((button) => button.addEventListener("click", () => {
@@ -2767,7 +2841,7 @@ async function renderOutlines() {
2767
2841
  }
2768
2842
 
2769
2843
  async function renderRelationships() {
2770
- state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
2844
+ state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
2771
2845
  const relationships = (await apiPage(`/api/works/${state.work.id}/relationships`)).items;
2772
2846
  const nameOf = (id) => state.characters.find((item) => item.id === id)?.name ?? "未知角色";
2773
2847
  state.galaxy?.destroy();
@@ -2798,9 +2872,13 @@ async function renderRelationships() {
2798
2872
  }
2799
2873
 
2800
2874
  async function renderReviews() {
2875
+ const canReadCharacters = canReadModule("characters");
2876
+ const canResolveReview = canEditModule("reviews");
2877
+ const canMergeCharacters = canResolveReview
2878
+ && ["characters", "races", "organizations", "timeline", "relationships"].every((module) => canEditModule(module));
2801
2879
  const [reviews, characters] = await Promise.all([
2802
2880
  apiPage(`/api/works/${state.work.id}/reviews`).then((result) => result.items),
2803
- apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`)
2881
+ canReadCharacters ? apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`) : Promise.resolve([])
2804
2882
  ]);
2805
2883
  const characterById = new Map(characters.map((character) => [character.id, character]));
2806
2884
  const duplicateCard = (item) => {
@@ -2808,17 +2886,21 @@ async function renderReviews() {
2808
2886
  const sides = refs.map((reference) => ({ reference, character: characterById.get(reference.id) }));
2809
2887
  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
2888
  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">
2889
+ const mergeActions = item.status === "pending" && sides.length === 2 && canMergeCharacters ? `
2812
2890
  <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>` : "";
2891
+ <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>` : "";
2892
+ const keepSeparateAction = item.status === "pending" && canResolveReview
2893
+ ? `<button data-keep-characters-separate="${esc(item.id)}">确认是不同角色</button>`
2894
+ : "";
2895
+ const actions = mergeActions || keepSeparateAction
2896
+ ? `<div class="card-actions character-duplicate-actions">${mergeActions}${keepSeparateAction}</div>`
2897
+ : "";
2816
2898
  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
2899
  };
2818
2900
  $("#module-content").innerHTML = reviews.length ? `<div class="card-grid">${reviews.map((item) => item.itemType === "character-duplicate" ? duplicateCard(item) : `
2819
2901
  <article class="record-card"><small>${esc(item.itemType)} · ${esc(item.severity)} · ${esc(item.status)}</small><h3>${esc(item.title)}</h3>
2820
2902
  <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>`
2903
+ ${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
2904
  : emptyModule("没有待审核事项", "候选设定、冲突与低置信度结论会集中显示在这里。");
2823
2905
  $("#module-content").querySelectorAll("[data-review-id]").forEach((button) => button.addEventListener("click", async () => {
2824
2906
  await api(`/api/reviews/${button.dataset.reviewId}`, { method: "PATCH", body: { status: button.dataset.reviewStatus } });
@@ -2861,12 +2943,15 @@ async function renderReviews() {
2861
2943
  async function renderTasks() {
2862
2944
  const [tasks, settings] = await Promise.all([
2863
2945
  apiPage(`/api/works/${state.work.id}/tasks`).then((result) => result.items),
2864
- api(`/api/works/${state.work.id}/ai-settings`)
2946
+ canReadModule("ai-settings")
2947
+ ? api(`/api/works/${state.work.id}/ai-settings`)
2948
+ : Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunBatchLimit: 20 })
2865
2949
  ]);
2950
+ const canConfigureAutoRun = canEditModule("tasks") && canEditModule("ai-settings");
2866
2951
  const pendingCount = tasks.filter((item) => item.status === "pending").length;
2867
2952
  const runningCount = tasks.filter((item) => item.status === "running").length;
2868
2953
  $("#module-content").innerHTML = `
2869
- <section class="task-auto-run-panel" aria-labelledby="task-auto-run-title">
2954
+ <section class="task-auto-run-panel ${canConfigureAutoRun ? "" : "hidden"}" aria-labelledby="task-auto-run-title">
2870
2955
  <div class="task-auto-run-copy">
2871
2956
  <strong id="task-auto-run-title">自动执行待分析任务</strong>
2872
2957
  <small>只执行已经进入“待执行”队列的任务,不会自动创建人物关系、世界观或其他分析。</small>
@@ -3084,6 +3169,10 @@ async function renderBookAiSettings() {
3084
3169
  "afterend",
3085
3170
  `<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
3171
  );
3172
+ if (!canEditModule("ai-settings")) {
3173
+ host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
3174
+ host.querySelectorAll(".primary-button").forEach((button) => button.classList.add("permission-hidden"));
3175
+ }
3087
3176
  $("#save-work-system-prompt").addEventListener("click", async () => {
3088
3177
  const button = $("#save-work-system-prompt");
3089
3178
  button.disabled = true;
@@ -3295,8 +3384,8 @@ async function loadAiReferences() {
3295
3384
  const workId = state.work?.id;
3296
3385
  if (!workId) return;
3297
3386
  const [characters, settings] = await Promise.all([
3298
- apiAllPages(`/api/works/${workId}/characters`),
3299
- apiAllPages(`/api/works/${workId}/settings`)
3387
+ canReadModule("characters") ? apiAllPages(`/api/works/${workId}/characters`) : Promise.resolve([]),
3388
+ canReadModule("settings") ? apiAllPages(`/api/works/${workId}/settings`) : Promise.resolve([])
3300
3389
  ]);
3301
3390
  if (state.work?.id !== workId) return;
3302
3391
  state.characters = characters;
@@ -3533,8 +3622,8 @@ function openWorkSettingsDialog(work) {
3533
3622
  if (!work) return;
3534
3623
  const canManageAccess = ["admin", "owner"].includes(String(work.accessRole));
3535
3624
  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>'}
3625
+ <div><strong id="work-access-title">成员权限</strong><small>选择成员后,可为每个作品模块单独设置无权限、只读或可编辑。</small><div class="work-access-options" aria-label="成员权限配置方式"><span>按成员配置</span><span>按模块授权</span><span>读写分离</span></div></div>
3626
+ ${canManageAccess ? '<button id="work-access-manage" class="ghost-button" type="button">配置成员权限</button>' : '<small>仅作品创建者或系统管理员可以调整访问权限。</small>'}
3538
3627
  </section>`;
3539
3628
  openDialog("作品信息",
3540
3629
  workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + accessField,
@@ -3610,13 +3699,13 @@ function openSettingEditor(item = null) {
3610
3699
  $("#setting-change-note").value = "";
3611
3700
  $("#setting-change-note-field").classList.toggle("hidden", !item);
3612
3701
  $("#setting-editor-submit").textContent = item ? "保存新版本" : "创建设定";
3613
- const viewOnly = !canEditWork();
3702
+ const viewOnly = !canEditModule("settings");
3614
3703
  $("#setting-editor-form").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = viewOnly; });
3615
3704
  $("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
3616
3705
  $("#setting-editor-submit").classList.toggle("hidden", viewOnly);
3617
3706
  $("#setting-editor-form").onsubmit = async (event) => {
3618
3707
  event.preventDefault();
3619
- if (!canEditWork()) return;
3708
+ if (!canEditModule("settings")) return;
3620
3709
  const form = new FormData(event.currentTarget);
3621
3710
  const submit = $("#setting-editor-submit");
3622
3711
  submit.disabled = true;
@@ -3698,11 +3787,11 @@ function renderCharacterEditorRelationships() {
3698
3787
  const relationLabel = [category, relationship.subtype].filter(Boolean).join(" · ") || "未细分";
3699
3788
  const keywords = Array.isArray(relationship.keywords) ? relationship.keywords : [];
3700
3789
  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>
3790
+ <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
3791
  <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
3792
  </article>`;
3704
3793
  }).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>'}`;
3794
+ 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
3795
  host.querySelectorAll("[data-character-relationship-edit]").forEach((button) => button.addEventListener("click", () => {
3707
3796
  const relationship = characterEditorRelationships.find((item) => item.id === button.dataset.characterRelationshipEdit);
3708
3797
  if (relationship) void openRelationshipDialog(relationship, { characterId });
@@ -3932,9 +4021,9 @@ function renderCharacterMarkdownSections() {
3932
4021
  host.innerHTML = '<div class="character-editor-empty-field"><b>Markdown 档案章节</b><span>创建人物档案后即可添加背景故事、能力、经历和研究记录。</span></div>';
3933
4022
  return;
3934
4023
  }
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>`;
4024
+ 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
4025
  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>
4026
+ <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
4027
  <div class="character-markdown-document message-body">${renderMarkdown(section.contentMarkdown) || '<p class="character-markdown-empty">本章节暂无正文。</p>'}</div>
3939
4028
  <div data-character-section-versions-host="${esc(section.id)}"></div>
3940
4029
  </article>`).join("");
@@ -3992,14 +4081,20 @@ function renderCharacterEditorFields(item) {
3992
4081
  characterEditorSection("basic", "基础资料", "用于检索、去重和建立人物在作品中的基本归属。",
3993
4082
  field("name", "标准名", "text", item?.name) +
3994
4083
  field("aliases", "别名", "item-list", item?.aliases ?? []) +
3995
- (state.races.length
4084
+ (!canReadModule("races")
4085
+ ? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
4086
+ : state.races.length
3996
4087
  ? field("raceId", "种族", "select", item?.raceId ?? "", raceOptions)
3997
4088
  : '<div class="character-editor-empty-field"><b>种族</b><span>尚未创建种族,请先在“种族”模块建立档案。</span></div>') +
3998
- (organizationOptions.length
4089
+ (!canReadModule("organizations")
4090
+ ? '<div class="character-editor-empty-field"><b>所属组织</b><span>当前账户没有组织模块读取权限,原有绑定不会被修改。</span></div>'
4091
+ : organizationOptions.length
3999
4092
  ? field("organizationIds", "所属组织(可多选)", "chips", item?.organizationIds ?? [], organizationOptions)
4000
4093
  : '<div class="character-editor-empty-field"><b>所属组织</b><span>尚未创建组织,可稍后在“组织”模块中补充。</span></div>') +
4001
4094
  field("visibility", "可见范围", "select", item?.visibility ?? "author", [["author", "仅作者"], ["collaborators", "协作者"], ["public", "公开"]]) +
4002
- field("firstChapterId", "首次登场章节", "select", item?.firstChapterId ?? "", chapterOptions)),
4095
+ (canReadModule("editor")
4096
+ ? field("firstChapterId", "首次登场章节", "select", item?.firstChapterId ?? "", chapterOptions)
4097
+ : '<div class="character-editor-empty-field"><b>首次登场章节</b><span>当前账户没有正文读取权限,原有绑定不会被修改。</span></div>')),
4003
4098
  characterEditorSection("profile", "人物档案", "记录人物定位、行为动力和便于创作时快速理解的简介。",
4004
4099
  field("identity", "身份与定位", "text", item?.attributes?.identity) +
4005
4100
  field("motivation", "核心动机", "textarea", item?.profile?.motivation) +
@@ -4035,11 +4130,9 @@ function collectCharacterBody(form) {
4035
4130
  const item = characterEditorItem;
4036
4131
  const profile = { ...(item?.profile ?? {}) };
4037
4132
  delete profile.sections;
4038
- return {
4133
+ const body = {
4039
4134
  name: String(form.get("name") ?? "").trim(),
4040
4135
  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
4136
  attributes: {
4044
4137
  ...(item?.attributes ?? {}),
4045
4138
  identity: String(form.get("identity") ?? "").trim(),
@@ -4053,9 +4146,12 @@ function collectCharacterBody(form) {
4053
4146
  currentState: buildCharacterState(form.getAll("stateKey"), form.getAll("stateValue"), item?.currentState ?? {}),
4054
4147
  lockedFields: form.getAll("lockedFields").map((value) => String(value).trim()).filter(Boolean),
4055
4148
  visibility: String(form.get("visibility") ?? "author"),
4056
- firstChapterId: form.get("firstChapterId") || null,
4057
4149
  changeNote: String(form.get("changeNote") ?? "").trim()
4058
4150
  };
4151
+ if (canReadModule("races")) body.raceId = form.get("raceId") || null;
4152
+ if (canReadModule("organizations")) body.organizationIds = form.getAll("organizationIds").map(String);
4153
+ if (canReadModule("editor")) body.firstChapterId = form.get("firstChapterId") || null;
4154
+ return body;
4059
4155
  }
4060
4156
 
4061
4157
  function renderCharacterHistory() {
@@ -4127,8 +4223,8 @@ async function showCharacterHistory() {
4127
4223
 
4128
4224
  async function openCharacterEditor(item = null) {
4129
4225
  [state.races, state.organizations] = await Promise.all([
4130
- apiAllPages(`/api/works/${state.work.id}/races`),
4131
- apiAllPages(`/api/works/${state.work.id}/organizations`)
4226
+ canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
4227
+ canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
4132
4228
  ]);
4133
4229
  characterEditorItem = item ?? null;
4134
4230
  characterEditorVersions = [];
@@ -4144,7 +4240,7 @@ async function openCharacterEditor(item = null) {
4144
4240
  $("#character-history-button").title = item ? "查看、比较和回滚历史版本" : "创建人物档案后即可查看版本历史";
4145
4241
  setCharacterHistoryVisible(false);
4146
4242
  renderCharacterEditorFields(item);
4147
- const viewOnly = !canEditWork();
4243
+ const viewOnly = !canEditModule("characters");
4148
4244
  if (viewOnly) {
4149
4245
  $("#character-editor-eyebrow").textContent = "人物档案";
4150
4246
  $("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
@@ -4156,12 +4252,12 @@ async function openCharacterEditor(item = null) {
4156
4252
  button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
4157
4253
  });
4158
4254
  const relationshipTab = document.querySelector("[data-character-editor-tab='relationships']");
4159
- relationshipTab.disabled = !item;
4160
- relationshipTab.title = item ? "查看和编辑人物关系" : "创建人物档案后即可维护人物关系";
4255
+ relationshipTab.disabled = !item || !canReadModule("relationships");
4256
+ relationshipTab.title = !canReadModule("relationships") ? "当前账户没有关系模块读取权限" : item ? "查看和编辑人物关系" : "创建人物档案后即可维护人物关系";
4161
4257
  const form = $("#character-editor-form");
4162
4258
  form.onsubmit = async (event) => {
4163
4259
  event.preventDefault();
4164
- if (!canEditWork()) return;
4260
+ if (!canEditModule("characters")) return;
4165
4261
  const submit = $("#character-editor-submit");
4166
4262
  submit.disabled = true;
4167
4263
  try {
@@ -4183,13 +4279,13 @@ async function openCharacterEditor(item = null) {
4183
4279
  };
4184
4280
  showEntityEditorPage("character");
4185
4281
  if (item) {
4186
- void loadCharacterEditorRelationships(item.id);
4282
+ if (canReadModule("relationships")) void loadCharacterEditorRelationships(item.id);
4187
4283
  void loadCharacterMarkdownSections(item.id);
4188
4284
  }
4189
4285
  }
4190
4286
 
4191
4287
  async function openRaceDialog(item) {
4192
- state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
4288
+ state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
4193
4289
  const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
4194
4290
  const parentOptions = [["", "无(根种族)"], ...eligibleRaceParents(state.races, item?.id)
4195
4291
  .sort((left, right) => racePathLabel(left).localeCompare(racePathLabel(right), "zh-CN"))
@@ -4202,7 +4298,8 @@ async function openRaceDialog(item) {
4202
4298
  (memberOptions.length ? field("memberIds", "属于该种族的角色(可多选)", "chips", item?.memberIds ?? [], memberOptions) : ""),
4203
4299
  async (form) => {
4204
4300
  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) };
4301
+ const body = { name: form.get("name"), parentRaceId: form.get("parentRaceId") || null, description: form.get("description"), settings };
4302
+ if (canReadModule("characters")) body.memberIds = form.getAll("memberIds").map(String);
4206
4303
  await api(item ? `/api/races/${item.id}` : `/api/works/${state.work.id}/races`, { method: item ? "PATCH" : "POST", body });
4207
4304
  await renderRaces();
4208
4305
  await loadAiReferences();
@@ -4210,7 +4307,7 @@ async function openRaceDialog(item) {
4210
4307
  }
4211
4308
 
4212
4309
  async function openOrganizationDialog(item) {
4213
- state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
4310
+ state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
4214
4311
  const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
4215
4312
  openDialog(item ? "编辑组织" : "新建组织",
4216
4313
  field("name", "组织名称", "text", item?.name) +
@@ -4219,7 +4316,8 @@ async function openOrganizationDialog(item) {
4219
4316
  (memberOptions.length ? field("memberIds", "组织成员(可多选)", "chips", item?.memberIds ?? [], memberOptions) : ""),
4220
4317
  async (form) => {
4221
4318
  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) };
4319
+ const body = { name: form.get("name"), description: form.get("description"), settings };
4320
+ if (canReadModule("characters")) body.memberIds = form.getAll("memberIds").map(String);
4223
4321
  await api(item ? `/api/organizations/${item.id}` : `/api/works/${state.work.id}/organizations`, { method: item ? "PATCH" : "POST", body });
4224
4322
  await renderOrganizations();
4225
4323
  await loadAiReferences();
@@ -4316,6 +4414,7 @@ function openTimelineSplitDialog(item) {
4316
4414
  }
4317
4415
 
4318
4416
  async function openRelationshipDialog(item, options = {}) {
4417
+ if (!canReadModule("characters")) return toast("配置人物关系前需要角色模块读取权限", "error");
4319
4418
  state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
4320
4419
  if (state.characters.length < 2) return toast("至少需要两个角色才能创建关系", "error");
4321
4420
  const characterOptions = state.characters.map((item) => [item.id, item.name]);
@@ -4918,18 +5017,34 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
4918
5017
  }
4919
5018
  });
4920
5019
  $("#members-dialog-close").addEventListener("click", () => $("#members-dialog").close());
4921
- $("#members-dialog").addEventListener("close", () => { memberDialogWork = null; });
4922
- $("#member-invite-form").addEventListener("submit", async (event) => {
5020
+ $("#members-dialog").addEventListener("close", () => {
5021
+ memberDialogWork = null;
5022
+ memberDialogMembers = [];
5023
+ memberDialogDirectory = [];
5024
+ });
5025
+ $("#member-user-select").addEventListener("change", () => selectMemberForConfiguration($("#member-user-select").value));
5026
+ $("#member-permission-form").querySelectorAll("[data-permission-preset]").forEach((button) => button.addEventListener("click", () => {
5027
+ $("#member-permission-grid").querySelectorAll("[data-member-permission]").forEach((select) => {
5028
+ select.value = button.dataset.permissionPreset;
5029
+ });
5030
+ }));
5031
+ $("#member-permission-form").addEventListener("submit", async (event) => {
4923
5032
  event.preventDefault();
4924
5033
  const userId = $("#member-user-select").value;
4925
5034
  const work = memberDialogWork ?? state.work;
4926
5035
  if (!work || !userId) return;
4927
5036
  try {
4928
- const role = $("#member-role-select").value;
4929
- const members = await api(`/api/works/${encodeURIComponent(work.id)}/members`, { method: "POST", body: { userId, role } });
5037
+ const existing = memberDialogMembers.some((member) => member.userId === userId && member.role !== "owner");
5038
+ const permissions = selectedMemberPermissions();
5039
+ const members = await api(existing
5040
+ ? `/api/works/${encodeURIComponent(work.id)}/members/${encodeURIComponent(userId)}`
5041
+ : `/api/works/${encodeURIComponent(work.id)}/members`, {
5042
+ method: existing ? "PATCH" : "POST",
5043
+ body: existing ? { permissions } : { userId, permissions }
5044
+ });
4930
5045
  renderMembers(members);
4931
- await fillMemberCandidates(members);
4932
- toast(role === "viewer" ? "仅查看成员已邀请" : role === "settings-editor" ? "设定编辑已邀请" : "完整协作者已邀请");
5046
+ renderMemberSelector(userId);
5047
+ toast(existing ? "成员模块权限已更新" : "成员已添加并保存模块权限");
4933
5048
  } catch (error) { toast(error.message, "error"); }
4934
5049
  });
4935
5050
  $("#platform-new-provider").addEventListener("click", () => openProviderDialog());