@musnows/scriverse 0.3.8 → 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,18 +82,56 @@ function analysisTaskStatusLabel(status) {
81
82
  }
82
83
 
83
84
  function canEditWork(work = state.work) {
84
- return ["admin", "owner", "editor"].includes(String(work?.accessRole));
85
+ return WORK_PERMISSION_MODULES.some((item) => canWriteUiModule(work, item.uiModule));
86
+ }
87
+
88
+ function canEditProse(work = state.work) {
89
+ return canWriteUiModule(work, "editor");
90
+ }
91
+
92
+ function canManageWork(work = state.work) {
93
+ return ["admin", "owner"].includes(String(work?.accessRole));
94
+ }
95
+
96
+ function canEditModule(module, work = state.work) {
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));
85
107
  }
86
108
 
87
109
  function applyWorkAccessMode() {
88
110
  const viewOnly = Boolean(state.work) && !canEditWork();
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);
89
116
  $("#app").classList.toggle("view-only-mode", viewOnly);
90
- document.body.classList.toggle("work-viewer-mode", viewOnly);
91
- $("#chapter-title").readOnly = viewOnly;
92
- $("#chapter-content").readOnly = viewOnly;
93
- $("#chapter-title").setAttribute("aria-readonly", String(viewOnly));
94
- $("#chapter-content").setAttribute("aria-readonly", String(viewOnly));
95
- if (viewOnly) {
117
+ $("#app").classList.toggle("prose-read-only-mode", proseReadOnly);
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);
127
+ $("#chapter-title").readOnly = proseReadOnly;
128
+ $("#chapter-content").readOnly = proseReadOnly;
129
+ $("#chapter-title").setAttribute("aria-readonly", String(proseReadOnly));
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);
134
+ if (proseReadOnly) {
96
135
  cancelChapterAutoSave();
97
136
  state.dirty = false;
98
137
  }
@@ -144,6 +183,8 @@ const panelLayoutStorageKey = "ai-novel-panel-layout-v1";
144
183
  const panelLayoutDefaults = Object.freeze({ leftWidth: 280, aiWidth: 360, leftCollapsed: false, aiCollapsed: false });
145
184
  let restoringPageRoute = true;
146
185
  let memberDialogWork = null;
186
+ let memberDialogMembers = [];
187
+ let memberDialogDirectory = [];
147
188
  let onboardingStep = 0;
148
189
  let onboardingAutoScheduled = false;
149
190
  let onboardingPositionFrame = null;
@@ -492,7 +533,8 @@ function showEntityEditorPage(type) {
492
533
  }
493
534
 
494
535
  function markEntityEditorDirty() {
495
- if (entityEditorType && canEditWork()) entityEditorDirty = true;
536
+ const module = entityEditorType === "setting" ? "settings" : "characters";
537
+ if (entityEditorType && canEditModule(module)) entityEditorDirty = true;
496
538
  }
497
539
 
498
540
  function confirmEntityEditorDiscard(message) {
@@ -1424,15 +1466,75 @@ applyTypographySettings(typographySettings);
1424
1466
  applyColorTheme(currentColorTheme());
1425
1467
  applyPanelLayout();
1426
1468
 
1469
+ function optimisticVersionForPath(path) {
1470
+ const normalizedPath = String(path).split("?")[0];
1471
+ const find = (items, id) => items.find((item) => String(item?.id ?? item?.chapterId ?? "") === id)?.versionNo;
1472
+ const workMatch = normalizedPath.match(/^\/api\/works\/([^/]+)(?:\/(?:cover|import|file-versions\/[^/]+\/restore))?$/u);
1473
+ if (workMatch) {
1474
+ const workId = decodeURIComponent(workMatch[1]);
1475
+ return state.works.find((item) => item.id === workId)?.versionNo ?? (state.work?.id === workId ? state.work.versionNo : undefined);
1476
+ }
1477
+ const resourceMatch = normalizedPath.match(/^\/api\/(volumes|chapters|settings|races|organizations|timeline-tracks|timeline|relationships|foreshadows|characters|character-sections)\/([^/]+)(?:\/(?:restore|move|split))?$/u);
1478
+ if (resourceMatch) {
1479
+ const resourceId = decodeURIComponent(resourceMatch[2]);
1480
+ const collection = {
1481
+ settings: state.settings,
1482
+ races: state.races,
1483
+ organizations: state.organizations,
1484
+ "timeline-tracks": state.timelineTracks,
1485
+ characters: state.characters
1486
+ }[resourceMatch[1]] ?? [];
1487
+ if (resourceMatch[1] === "chapters" && state.chapter?.id === resourceId) return state.chapter.versionNo;
1488
+ if (resourceMatch[1] === "volumes") return find(state.work?.volumes ?? [], resourceId);
1489
+ if (resourceMatch[1] === "character-sections") return find(characterEditorSections, resourceId);
1490
+ if (resourceMatch[1] === "characters" && characterEditorItem?.id === resourceId) return characterEditorItem.versionNo;
1491
+ return find(collection, resourceId);
1492
+ }
1493
+ const outlineMatch = normalizedPath.match(/^\/api\/chapters\/([^/]+)\/outline$/u);
1494
+ if (outlineMatch) return find(state.outlines ?? [], decodeURIComponent(outlineMatch[1]));
1495
+ const entityRestoreMatch = normalizedPath.match(/^\/api\/entity-versions\/(work|volume|setting|race|organization|timeline-track|timeline-event|relationship|chapter-outline|foreshadow)\/([^/]+)\/restore$/u);
1496
+ if (entityRestoreMatch) {
1497
+ const entityType = entityRestoreMatch[1];
1498
+ const entityId = decodeURIComponent(entityRestoreMatch[2]);
1499
+ if (entityType === "work") return state.works.find((item) => item.id === entityId)?.versionNo ?? (state.work?.id === entityId ? state.work.versionNo : undefined);
1500
+ if (entityType === "volume") return find(state.work?.volumes ?? [], entityId);
1501
+ if (entityType === "chapter-outline") return find(state.outlines ?? [], entityId);
1502
+ const collection = {
1503
+ setting: state.settings,
1504
+ race: state.races,
1505
+ organization: state.organizations,
1506
+ "timeline-track": state.timelineTracks
1507
+ }[entityType] ?? [];
1508
+ return find(collection, entityId);
1509
+ }
1510
+ return undefined;
1511
+ }
1512
+
1513
+ function attachOptimisticVersion(path, method, body) {
1514
+ if (!["PATCH", "PUT", "DELETE", "POST"].includes(method)) return body;
1515
+ if (body instanceof FormData) {
1516
+ if (!body.has("expectedVersionNo")) {
1517
+ const versionNo = optimisticVersionForPath(path);
1518
+ if (Number.isInteger(versionNo) && versionNo > 0) body.append("expectedVersionNo", String(versionNo));
1519
+ }
1520
+ return body;
1521
+ }
1522
+ const currentBody = body && typeof body === "object" && !Array.isArray(body) ? body : {};
1523
+ if (currentBody.expectedVersionNo !== undefined) return currentBody;
1524
+ const versionNo = optimisticVersionForPath(path);
1525
+ return Number.isInteger(versionNo) && versionNo > 0 ? { ...currentBody, expectedVersionNo: versionNo } : body;
1526
+ }
1527
+
1427
1528
  async function api(path, options = {}) {
1428
1529
  const method = String(options.method ?? "GET").toUpperCase();
1530
+ const body = attachOptimisticVersion(path, method, options.body);
1429
1531
  const headers = { ...(options.headers ?? {}) };
1430
1532
  if (state.csrfToken && !["GET", "HEAD", "OPTIONS"].includes(method)) headers["X-CSRF-Token"] = state.csrfToken;
1431
- if (!(options.body instanceof FormData)) headers["Content-Type"] = "application/json";
1432
- const response = await fetch(path, options.body instanceof FormData ? { ...options, headers } : {
1533
+ if (!(body instanceof FormData)) headers["Content-Type"] = "application/json";
1534
+ const response = await fetch(path, body instanceof FormData ? { ...options, body, headers } : {
1433
1535
  ...options,
1434
1536
  headers,
1435
- body: options.body && typeof options.body !== "string" ? JSON.stringify(options.body) : options.body
1537
+ body: body && typeof body !== "string" ? JSON.stringify(body) : body
1436
1538
  });
1437
1539
  if (!response.ok) {
1438
1540
  const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
@@ -1616,7 +1718,7 @@ function cancelChapterAutoSave() {
1616
1718
  }
1617
1719
 
1618
1720
  function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
1619
- if (!state.chapter || !canEditWork()) return;
1721
+ if (!state.chapter || !canEditProse()) return;
1620
1722
  cancelChapterAutoSave();
1621
1723
  setSaveState("等待自动保存", true);
1622
1724
  chapterAutoSaveTimer = setTimeout(() => {
@@ -1626,7 +1728,7 @@ function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
1626
1728
  }
1627
1729
 
1628
1730
  async function persistChapter({ automatic = false } = {}) {
1629
- if (!canEditWork()) return null;
1731
+ if (!canEditProse()) return null;
1630
1732
  if (!state.chapter) {
1631
1733
  if (!automatic) toast("请先选择章节", "error");
1632
1734
  return null;
@@ -1694,6 +1796,19 @@ function confirmDiscardChanges(message = "当前章节有未保存修改,继
1694
1796
  return window.confirm(message);
1695
1797
  }
1696
1798
 
1799
+ function chooseExistingWorkImportMode(file) {
1800
+ const dialog = $("#import-mode-dialog");
1801
+ $("#import-mode-file-summary").textContent = `文件:${file.name};当前作品:《${state.work.title}》`;
1802
+ $("#import-mode-unsaved-warning").classList.toggle("hidden", !state.dirty);
1803
+ dialog.returnValue = "cancel";
1804
+ dialog.showModal();
1805
+ return new Promise((resolve) => {
1806
+ dialog.addEventListener("close", () => {
1807
+ resolve(["append", "overwrite"].includes(dialog.returnValue) ? dialog.returnValue : null);
1808
+ }, { once: true });
1809
+ });
1810
+ }
1811
+
1697
1812
  function updateDocumentTitle(work = null) {
1698
1813
  const workTitle = String(work?.title ?? "").trim();
1699
1814
  document.title = workTitle ? `${workTitle} · 叙界` : platformDocumentTitle;
@@ -1814,13 +1929,14 @@ function captureSettingsReturnContext() {
1814
1929
  function renderSettingsHub() {
1815
1930
  const hasWork = Boolean(state.work);
1816
1931
  const canManageWork = hasWork && ["admin", "owner"].includes(String(state.work.accessRole));
1932
+ const canReadAggregate = hasWork && canReadAggregateContent();
1817
1933
  const isAdmin = state.user?.role === "admin";
1818
1934
  $("#platform-ai-button").classList.toggle("hidden", !isAdmin);
1819
1935
  $("#user-management-button").classList.toggle("hidden", !isAdmin);
1820
1936
  $("#platform-ui-settings-button").classList.toggle("hidden", !isAdmin);
1821
1937
  $("#collaboration-button").disabled = !canManageWork;
1822
- $("#top-search-button").disabled = !hasWork;
1823
- $("#export-button").disabled = !hasWork;
1938
+ $("#top-search-button").disabled = !canReadAggregate;
1939
+ $("#export-button").disabled = !canReadAggregate;
1824
1940
  $("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
1825
1941
  $("#settings-work-note").textContent = hasWork
1826
1942
  ? `当前作品:《${state.work.title}》。导出将作用于这部作品。`
@@ -1882,61 +1998,99 @@ async function openPlatformUiSettingsDialog() {
1882
1998
  }
1883
1999
  }
1884
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
+
1885
2044
  function renderMembers(members) {
2045
+ memberDialogMembers = members;
1886
2046
  const work = memberDialogWork ?? state.work;
1887
2047
  const canManage = ["admin", "owner"].includes(String(work?.accessRole));
1888
- $("#members-list").innerHTML = members.map((member) => `<article class="access-row">
1889
- <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.status === "disabled" ? " · 已停用" : ""}</small></div></div>
1890
- ${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="editor" ${member.role === "editor" ? "selected" : ""}>可编辑</option></select>`}
1891
- ${member.role === "owner" || !canManage ? "<span></span>" : `<button type="button" data-remove-member="${esc(member.userId)}">移除</button>`}
1892
- </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("");
1893
2056
  bindUserAvatarFallbacks($("#members-list"));
1894
- $("#members-list").querySelectorAll("[data-member-role]").forEach((select) => select.addEventListener("change", async () => {
1895
- if (!work) return;
1896
- const previousRole = members.find((member) => member.userId === select.dataset.memberRole)?.role ?? "viewer";
1897
- try {
1898
- const updated = await api(`/api/works/${encodeURIComponent(work.id)}/members/${encodeURIComponent(select.dataset.memberRole)}`, { method: "PATCH", body: { role: select.value } });
1899
- renderMembers(updated);
1900
- toast("成员权限已更新");
1901
- } catch (error) {
1902
- select.value = previousRole;
1903
- toast(error.message, "error");
1904
- }
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();
1905
2061
  }));
1906
2062
  $("#members-list").querySelectorAll("[data-remove-member]").forEach((button) => button.addEventListener("click", async () => {
1907
2063
  if (!work) return;
1908
2064
  try {
1909
2065
  const updated = await api(`/api/works/${encodeURIComponent(work.id)}/members/${encodeURIComponent(button.dataset.removeMember)}`, { method: "DELETE" });
1910
2066
  renderMembers(updated);
1911
- await fillMemberCandidates(updated);
2067
+ renderMemberSelector();
1912
2068
  toast("协作者已移除");
1913
2069
  } catch (error) { toast(error.message, "error"); }
1914
2070
  }));
1915
2071
  }
1916
2072
 
1917
- async function fillMemberCandidates(members) {
1918
- const directory = await api("/api/users/directory");
1919
- const memberIds = new Set(members.map((member) => member.userId));
1920
- const candidates = directory.filter((user) => !memberIds.has(user.userId));
1921
- $("#member-user-select").innerHTML = candidates.length
1922
- ? `<option value="">选择用户</option>${candidates.map((user) => `<option value="${esc(user.userId)}">${esc(user.displayName)} · @${esc(user.username)}</option>`).join("")}`
1923
- : '<option value="">没有可邀请的用户</option>';
1924
- $("#member-user-select").disabled = !candidates.length;
1925
- }
1926
-
1927
2073
  async function openMembersDialog(targetWork = state.work) {
1928
2074
  if (!targetWork) return;
1929
2075
  memberDialogWork = targetWork;
1930
2076
  const canManage = ["admin", "owner"].includes(String(targetWork.accessRole));
1931
2077
  $("#members-dialog-eyebrow").textContent = `作品权限 · 《${targetWork.title}》`;
1932
- $("#members-dialog-title").textContent = "可访问人";
2078
+ $("#members-dialog-title").textContent = "成员模块权限";
1933
2079
  $("#members-list").innerHTML = '<p class="empty-state">正在读取成员……</p>';
1934
- $("#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;
1935
2085
  $("#members-dialog").showModal();
1936
2086
  try {
1937
- 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;
1938
2092
  renderMembers(members);
1939
- if (canManage) await fillMemberCandidates(members);
2093
+ if (canManage) renderMemberSelector();
1940
2094
  } catch (error) { $("#members-dialog").close(); toast(error.message, "error"); }
1941
2095
  }
1942
2096
 
@@ -2090,9 +2244,9 @@ function renderShelf() {
2090
2244
  <span class="book-cover-fallback">${esc(Array.from(work.title)[0] ?? "书")}</span>
2091
2245
  ${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面">` : ""}
2092
2246
  </span>
2093
- <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 === "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>
2094
2248
  </button>
2095
- ${canEditWork(work) ? `<button class="book-card-settings" type="button" data-edit-work="${esc(work.id)}" aria-label="作品设置" title="作品设置">设置</button>` : ""}
2249
+ ${canManageWork(work) ? `<button class="book-card-settings" type="button" data-edit-work="${esc(work.id)}" aria-label="作品设置" title="作品设置">设置</button>` : ""}
2096
2250
  </article>`).join("")}
2097
2251
  <button class="book-card book-add-card" id="book-add-card" type="button" aria-label="新建作品" data-testid="book-add-card"><span>+</span><strong>新建作品</strong><small>从零开始或导入 TXT / DOCX</small></button>`;
2098
2252
  shelf.querySelectorAll("[data-open-work]").forEach((button) => button.addEventListener("click", () => selectWork(button.dataset.openWork)));
@@ -2140,16 +2294,18 @@ async function selectWork(workId, preferredChapterId = null) {
2140
2294
  settingsReturnContext = null;
2141
2295
  state.work = nextWork;
2142
2296
  state.chapter = null;
2297
+ if (!canReadModule(state.module)) state.module = firstReadableUiModule(state.work) ?? "editor";
2143
2298
  applyWorkAccessMode();
2144
2299
  updateDocumentTitle(state.work);
2145
2300
  $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
2146
- $("#top-search-button").disabled = false;
2301
+ $("#top-search-button").disabled = !canReadAggregateContent();
2147
2302
  renderTree();
2148
2303
  const chapters = state.work.volumes.flatMap((volume) => volume.chapters);
2149
2304
  const targetChapter = chapters.find((chapter) => chapter.id === preferredChapterId) ?? chapters[0];
2150
2305
  if (state.module === "editor" && preferredChapterId) await selectChapter(preferredChapterId);
2151
2306
  else if (state.module === "editor" && targetChapter) await selectChapter(targetChapter.id);
2152
- 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);
2153
2309
  else await showModule(state.module);
2154
2310
  return true;
2155
2311
  }
@@ -2177,7 +2333,7 @@ function renderTree() {
2177
2333
  renderTree();
2178
2334
  });
2179
2335
  button.addEventListener("contextmenu", (event) => {
2180
- if (!canEditWork()) return;
2336
+ if (!canEditProse()) return;
2181
2337
  event.preventDefault();
2182
2338
  openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeToggle));
2183
2339
  });
@@ -2185,7 +2341,7 @@ function renderTree() {
2185
2341
  $("#novel-tree").querySelectorAll("[data-chapter-id]").forEach((button) => {
2186
2342
  button.addEventListener("click", () => selectChapter(button.dataset.chapterId));
2187
2343
  button.addEventListener("contextmenu", (event) => {
2188
- if (!canEditWork()) return;
2344
+ if (!canEditProse()) return;
2189
2345
  event.preventDefault();
2190
2346
  openChapterTypeMenu(button.dataset.chapterId, event.clientX, event.clientY);
2191
2347
  });
@@ -2219,6 +2375,7 @@ async function selectChapter(chapterId) {
2219
2375
  state.chapter = await api(`/api/chapters/${chapterId}`);
2220
2376
  lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
2221
2377
  state.module = "editor";
2378
+ applyWorkAccessMode();
2222
2379
  markActiveModule("editor");
2223
2380
  $("#welcome-view").classList.add("hidden");
2224
2381
  $("#module-view").classList.add("hidden");
@@ -2235,7 +2392,7 @@ async function selectChapter(chapterId) {
2235
2392
  scheduleChapterLineNumbers();
2236
2393
  $("#chapter-insight").classList.add("hidden");
2237
2394
  updateChapterStats();
2238
- if (!canEditWork()) setSaveState("仅查看");
2395
+ if (!canEditProse()) setSaveState("正文只读");
2239
2396
  else if (spacingChanged) scheduleChapterAutoSave(120);
2240
2397
  else setSaveState("已保存");
2241
2398
  renderTree();
@@ -2293,10 +2450,18 @@ const moduleMeta = {
2293
2450
 
2294
2451
  async function showModule(module) {
2295
2452
  if (!state.work) return showWelcome();
2296
- if (!canEditWork() && ["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
+ }
2297
2461
  if (module !== "editor" && state.module === "editor" && !confirmDiscardChanges()) return;
2298
2462
  if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
2299
2463
  state.module = module;
2464
+ applyWorkAccessMode();
2300
2465
  markActiveModule(module);
2301
2466
  if (module === "editor") {
2302
2467
  if (state.chapter) await selectChapter(state.chapter.id);
@@ -2315,7 +2480,7 @@ async function showModule(module) {
2315
2480
  $("#module-title").textContent = meta[1];
2316
2481
  $("#module-description").textContent = meta[2];
2317
2482
  $("#module-create-button").textContent = meta[3];
2318
- $("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditWork());
2483
+ $("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditModule(module));
2319
2484
  $("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
2320
2485
  try {
2321
2486
  if (module === "settings") await renderSettings();
@@ -2404,6 +2569,36 @@ function bindEntityHistoryButtons(refresh) {
2404
2569
  }));
2405
2570
  }
2406
2571
 
2572
+ function openEntityMergeDialog({ typeLabel, source, candidates, endpoint, body, refresh, impact }) {
2573
+ const targetOptions = candidates
2574
+ .filter((candidate) => candidate.id !== source.id)
2575
+ .map((candidate) => [candidate.id, candidate.name]);
2576
+ openDialog(`合并${typeLabel}`,
2577
+ `<p class="merge-dialog-note">“${esc(source.name)}”将合并到所选档案,目标档案会保留。${esc(impact)}</p>` +
2578
+ field("targetId", `目标${typeLabel}`, "select", targetOptions[0]?.[0] ?? "", targetOptions),
2579
+ async (form) => {
2580
+ const target = candidates.find((candidate) => candidate.id === form.get("targetId"));
2581
+ if (!target) throw new Error(`请选择目标${typeLabel}`);
2582
+ await api(endpoint(source), { method: "POST", body: body(target) });
2583
+ await refresh();
2584
+ await loadAiReferences();
2585
+ toast(`已将“${source.name}”合并到“${target.name}”`);
2586
+ }, "人工资料管理", { submitLabel: "确认合并" });
2587
+ }
2588
+
2589
+ async function deleteManagedEntity({ typeLabel, item, endpoint, refresh, warning = "" }) {
2590
+ const detail = warning ? `\n${warning}` : "";
2591
+ if (!window.confirm(`确认删除${typeLabel}“${item.name}”吗?${detail}`)) return;
2592
+ try {
2593
+ await api(endpoint(item), { method: "DELETE" });
2594
+ await refresh();
2595
+ await loadAiReferences();
2596
+ toast(`已删除${typeLabel}“${item.name}”`);
2597
+ } catch (error) {
2598
+ toast(error.message, "error");
2599
+ }
2600
+ }
2601
+
2407
2602
  async function renderSettings() {
2408
2603
  const records = (await apiPage(`/api/works/${state.work.id}/settings`)).items;
2409
2604
  state.settings = records;
@@ -2424,10 +2619,10 @@ async function renderSettings() {
2424
2619
  async function renderCharacters() {
2425
2620
  [state.characters, state.races, state.organizations] = await Promise.all([
2426
2621
  apiPage(`/api/works/${state.work.id}/characters`).then((result) => result.items),
2427
- apiAllPages(`/api/works/${state.work.id}/races`),
2428
- 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([])
2429
2624
  ]);
2430
- 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>` : "";
2431
2626
  $("#module-content").innerHTML = auditPanel + (state.characters.length ? `<div class="card-grid">${state.characters.map((item) => {
2432
2627
  const details = normalizeCharacterDetails(item.attributes?.details);
2433
2628
  return `
@@ -2439,7 +2634,7 @@ async function renderCharacters() {
2439
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>
2440
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>`}
2441
2636
  ${item.profileSectionCount ? `<small class="character-section-count">${item.profileSectionCount} 个设定章节</small>` : ""}
2442
- <div class="card-actions"><button data-edit-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>`;
2443
2638
  }).join("")}</div>`
2444
2639
  : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
2445
2640
  $("#create-character-audit-task")?.addEventListener("click", async () => {
@@ -2464,12 +2659,40 @@ async function renderCharacters() {
2464
2659
  });
2465
2660
  });
2466
2661
  $("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterEditor(state.characters.find((item) => item.id === button.dataset.editCharacter))));
2662
+ $("#module-content").querySelectorAll("[data-merge-character]").forEach((button) => button.addEventListener("click", () => {
2663
+ const source = state.characters.find((item) => item.id === button.dataset.mergeCharacter);
2664
+ if (!source) return;
2665
+ openEntityMergeDialog({
2666
+ typeLabel: "角色",
2667
+ source,
2668
+ candidates: state.characters,
2669
+ endpoint: (item) => `/api/characters/${encodeURIComponent(item.id)}/merge`,
2670
+ body: (target) => ({
2671
+ targetCharacterId: target.id,
2672
+ expectedTargetVersionNo: target.versionNo,
2673
+ expectedSourceVersionNo: source.versionNo
2674
+ }),
2675
+ refresh: renderCharacters,
2676
+ impact: "来源角色的别名、组织、档案章节、时间线与人物关系会迁移到目标角色。"
2677
+ });
2678
+ }));
2679
+ $("#module-content").querySelectorAll("[data-delete-character]").forEach((button) => button.addEventListener("click", () => {
2680
+ const item = state.characters.find((character) => character.id === button.dataset.deleteCharacter);
2681
+ if (!item) return;
2682
+ void deleteManagedEntity({
2683
+ typeLabel: "角色",
2684
+ item,
2685
+ endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}`,
2686
+ refresh: renderCharacters,
2687
+ warning: "相关人物关系会删除,时间线中的参与者引用会移除。"
2688
+ });
2689
+ }));
2467
2690
  }
2468
2691
 
2469
2692
  async function renderRaces() {
2470
2693
  [state.races, state.characters] = await Promise.all([
2471
2694
  apiAllPages(`/api/works/${state.work.id}/races`),
2472
- apiAllPages(`/api/works/${state.work.id}/characters`)
2695
+ canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
2473
2696
  ]);
2474
2697
  const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
2475
2698
  <summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
@@ -2479,29 +2702,77 @@ async function renderRaces() {
2479
2702
  <p>${esc(item.description || "尚未填写种族简介")}</p>
2480
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>
2481
2704
  <p class="race-members">直接角色:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
2482
- <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></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>
2483
2706
  </article>
2484
2707
  ${item.children.length ? `<div class="race-tree-children">${item.children.map(renderRaceNode).join("")}</div>` : ""}
2485
2708
  </div>
2486
2709
  </details>`;
2487
2710
  $("#module-content").innerHTML = state.races.length ? `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>` : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
2488
2711
  $("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => openRaceDialog(state.races.find((item) => item.id === button.dataset.editRace))));
2712
+ $("#module-content").querySelectorAll("[data-merge-race]").forEach((button) => button.addEventListener("click", () => {
2713
+ const source = state.races.find((item) => item.id === button.dataset.mergeRace);
2714
+ if (!source) return;
2715
+ openEntityMergeDialog({
2716
+ typeLabel: "种族",
2717
+ source,
2718
+ candidates: state.races,
2719
+ endpoint: (item) => `/api/races/${encodeURIComponent(item.id)}/merge`,
2720
+ body: (target) => ({ targetRaceId: target.id }),
2721
+ refresh: renderRaces,
2722
+ impact: "来源种族的角色、子种族、简介与共同设定会迁移到目标种族。"
2723
+ });
2724
+ }));
2725
+ $("#module-content").querySelectorAll("[data-delete-race]").forEach((button) => button.addEventListener("click", () => {
2726
+ const item = state.races.find((race) => race.id === button.dataset.deleteRace);
2727
+ if (!item) return;
2728
+ void deleteManagedEntity({
2729
+ typeLabel: "种族",
2730
+ item,
2731
+ endpoint: (race) => `/api/races/${encodeURIComponent(race.id)}`,
2732
+ refresh: renderRaces,
2733
+ warning: "已绑定角色将变为未指定种族;有子种族时需先迁移或合并。"
2734
+ });
2735
+ }));
2489
2736
  bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
2490
2737
  }
2491
2738
 
2492
2739
  async function renderOrganizations() {
2493
2740
  [state.organizations, state.characters] = await Promise.all([
2494
2741
  apiAllPages(`/api/works/${state.work.id}/organizations`),
2495
- apiAllPages(`/api/works/${state.work.id}/characters`)
2742
+ canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
2496
2743
  ]);
2497
2744
  $("#module-content").innerHTML = state.organizations.length ? `<div class="card-grid organization-grid">${state.organizations.map((item) => `
2498
2745
  <article class="record-card organization-card"><small>${item.memberIds.length} 位成员 · ${item.settings.length} 条设定</small>
2499
2746
  <h3>${esc(item.name)}</h3><p>${esc(item.description || "尚未填写组织简介")}</p>
2500
2747
  <div class="organization-settings">${item.settings.map((setting) => `<span class="pill">${esc(setting)}</span>`).join("") || '<span class="pill">暂无组织设定</span>'}</div>
2501
2748
  <p class="organization-members">成员:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
2502
- <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></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>
2503
2750
  </article>`).join("")}</div>` : emptyModule("还没有组织", "创建国家、机构、阵营或团队,并维护组织设定与成员。");
2504
2751
  $("#module-content").querySelectorAll("[data-edit-organization]").forEach((button) => button.addEventListener("click", () => openOrganizationDialog(state.organizations.find((item) => item.id === button.dataset.editOrganization))));
2752
+ $("#module-content").querySelectorAll("[data-merge-organization]").forEach((button) => button.addEventListener("click", () => {
2753
+ const source = state.organizations.find((item) => item.id === button.dataset.mergeOrganization);
2754
+ if (!source) return;
2755
+ openEntityMergeDialog({
2756
+ typeLabel: "组织",
2757
+ source,
2758
+ candidates: state.organizations,
2759
+ endpoint: (item) => `/api/organizations/${encodeURIComponent(item.id)}/merge`,
2760
+ body: (target) => ({ targetOrganizationId: target.id }),
2761
+ refresh: renderOrganizations,
2762
+ impact: "来源组织的成员、简介与组织设定会迁移到目标组织。"
2763
+ });
2764
+ }));
2765
+ $("#module-content").querySelectorAll("[data-delete-organization]").forEach((button) => button.addEventListener("click", () => {
2766
+ const item = state.organizations.find((organization) => organization.id === button.dataset.deleteOrganization);
2767
+ if (!item) return;
2768
+ void deleteManagedEntity({
2769
+ typeLabel: "组织",
2770
+ item,
2771
+ endpoint: (organization) => `/api/organizations/${encodeURIComponent(organization.id)}`,
2772
+ refresh: renderOrganizations,
2773
+ warning: "角色与该组织的成员关系会一并移除。"
2774
+ });
2775
+ }));
2505
2776
  bindEntityHistoryButtons(async () => { await renderOrganizations(); await loadAiReferences(); });
2506
2777
  }
2507
2778
 
@@ -2527,7 +2798,12 @@ async function renderTimeline() {
2527
2798
  const eventIds = [...$("#module-content").querySelectorAll("[data-event-select]:checked")].map((input) => input.dataset.eventSelect);
2528
2799
  if (eventIds.length < 2) return toast("请至少选择两个时间事件", "error");
2529
2800
  openDialog("合并时间事件", field("name", "合并后的事件名称") + field("description", "合并说明(留空则拼接原说明)", "textarea"), async (form) => {
2530
- await api(`/api/works/${state.work.id}/timeline/merge`, { method: "POST", body: { eventIds, name: form.get("name"), description: form.get("description") || undefined } });
2801
+ await api(`/api/works/${state.work.id}/timeline/merge`, { method: "POST", body: {
2802
+ eventIds,
2803
+ name: form.get("name"),
2804
+ description: form.get("description") || undefined,
2805
+ expectedVersionNos: Object.fromEntries(eventIds.map((eventId) => [eventId, Number(events.find((event) => event.id === eventId)?.versionNo)]))
2806
+ } });
2531
2807
  await renderTimeline();
2532
2808
  }, "保留参与者与证据");
2533
2809
  });
@@ -2565,7 +2841,7 @@ async function renderOutlines() {
2565
2841
  }
2566
2842
 
2567
2843
  async function renderRelationships() {
2568
- state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
2844
+ state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
2569
2845
  const relationships = (await apiPage(`/api/works/${state.work.id}/relationships`)).items;
2570
2846
  const nameOf = (id) => state.characters.find((item) => item.id === id)?.name ?? "未知角色";
2571
2847
  state.galaxy?.destroy();
@@ -2596,9 +2872,13 @@ async function renderRelationships() {
2596
2872
  }
2597
2873
 
2598
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));
2599
2879
  const [reviews, characters] = await Promise.all([
2600
2880
  apiPage(`/api/works/${state.work.id}/reviews`).then((result) => result.items),
2601
- apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`)
2881
+ canReadCharacters ? apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`) : Promise.resolve([])
2602
2882
  ]);
2603
2883
  const characterById = new Map(characters.map((character) => [character.id, character]));
2604
2884
  const duplicateCard = (item) => {
@@ -2606,17 +2886,21 @@ async function renderReviews() {
2606
2886
  const sides = refs.map((reference) => ({ reference, character: characterById.get(reference.id) }));
2607
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("");
2608
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("");
2609
- 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 ? `
2610
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>
2611
- <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>
2612
- <button data-keep-characters-separate="${esc(item.id)}">确认是不同角色</button>
2613
- </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
+ : "";
2614
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>`;
2615
2899
  };
2616
2900
  $("#module-content").innerHTML = reviews.length ? `<div class="card-grid">${reviews.map((item) => item.itemType === "character-duplicate" ? duplicateCard(item) : `
2617
2901
  <article class="record-card"><small>${esc(item.itemType)} · ${esc(item.severity)} · ${esc(item.status)}</small><h3>${esc(item.title)}</h3>
2618
2902
  <p>${esc(item.description)}${item.suggestion ? `\n建议:${esc(item.suggestion)}` : ""}</p>
2619
- ${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>`
2620
2904
  : emptyModule("没有待审核事项", "候选设定、冲突与低置信度结论会集中显示在这里。");
2621
2905
  $("#module-content").querySelectorAll("[data-review-id]").forEach((button) => button.addEventListener("click", async () => {
2622
2906
  await api(`/api/reviews/${button.dataset.reviewId}`, { method: "PATCH", body: { status: button.dataset.reviewStatus } });
@@ -2659,12 +2943,15 @@ async function renderReviews() {
2659
2943
  async function renderTasks() {
2660
2944
  const [tasks, settings] = await Promise.all([
2661
2945
  apiPage(`/api/works/${state.work.id}/tasks`).then((result) => result.items),
2662
- 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 })
2663
2949
  ]);
2950
+ const canConfigureAutoRun = canEditModule("tasks") && canEditModule("ai-settings");
2664
2951
  const pendingCount = tasks.filter((item) => item.status === "pending").length;
2665
2952
  const runningCount = tasks.filter((item) => item.status === "running").length;
2666
2953
  $("#module-content").innerHTML = `
2667
- <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">
2668
2955
  <div class="task-auto-run-copy">
2669
2956
  <strong id="task-auto-run-title">自动执行待分析任务</strong>
2670
2957
  <small>只执行已经进入“待执行”队列的任务,不会自动创建人物关系、世界观或其他分析。</small>
@@ -2882,6 +3169,10 @@ async function renderBookAiSettings() {
2882
3169
  "afterend",
2883
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>`
2884
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
+ }
2885
3176
  $("#save-work-system-prompt").addEventListener("click", async () => {
2886
3177
  const button = $("#save-work-system-prompt");
2887
3178
  button.disabled = true;
@@ -3093,8 +3384,8 @@ async function loadAiReferences() {
3093
3384
  const workId = state.work?.id;
3094
3385
  if (!workId) return;
3095
3386
  const [characters, settings] = await Promise.all([
3096
- apiAllPages(`/api/works/${workId}/characters`),
3097
- 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([])
3098
3389
  ]);
3099
3390
  if (state.work?.id !== workId) return;
3100
3391
  state.characters = characters;
@@ -3331,8 +3622,8 @@ function openWorkSettingsDialog(work) {
3331
3622
  if (!work) return;
3332
3623
  const canManageAccess = ["admin", "owner"].includes(String(work.accessRole));
3333
3624
  const accessField = `<section class="work-access-field" aria-labelledby="work-access-title">
3334
- <div><strong id="work-access-title">可访问人</strong><small>可以分别授予成员仅查看或共同编辑权限。</small></div>
3335
- ${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>'}
3336
3627
  </section>`;
3337
3628
  openDialog("作品信息",
3338
3629
  workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + accessField,
@@ -3361,6 +3652,7 @@ function openWorkSettingsDialog(work) {
3361
3652
 
3362
3653
  async function openChapterDialog() {
3363
3654
  if (!state.work) return openWorkDialog();
3655
+ if (!canEditProse()) return toast("当前权限只能编辑设定资料,正文为只读", "error");
3364
3656
  if (!state.work.volumes.length) {
3365
3657
  await api(`/api/works/${state.work.id}/volumes`, { method: "POST", body: { title: "正文", kind: "main" } });
3366
3658
  state.work = await api(`/api/works/${state.work.id}`);
@@ -3375,6 +3667,7 @@ async function openChapterDialog() {
3375
3667
 
3376
3668
  function openVolumeDialog(item) {
3377
3669
  if (!state.work) return openWorkDialog();
3670
+ if (!canEditProse()) return toast("当前权限只能编辑设定资料,不能修改分卷", "error");
3378
3671
  const kindOptions = [["main", "正文卷"], ["prequel", "前传"], ["extra", "番外"], ["epilogue", "后记"], ["appendix", "附录"]];
3379
3672
  openDialog(item ? "编辑分卷" : "新建分卷",
3380
3673
  field("title", "分卷名称", "text", item?.title) +
@@ -3406,13 +3699,13 @@ function openSettingEditor(item = null) {
3406
3699
  $("#setting-change-note").value = "";
3407
3700
  $("#setting-change-note-field").classList.toggle("hidden", !item);
3408
3701
  $("#setting-editor-submit").textContent = item ? "保存新版本" : "创建设定";
3409
- const viewOnly = !canEditWork();
3702
+ const viewOnly = !canEditModule("settings");
3410
3703
  $("#setting-editor-form").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = viewOnly; });
3411
3704
  $("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
3412
3705
  $("#setting-editor-submit").classList.toggle("hidden", viewOnly);
3413
3706
  $("#setting-editor-form").onsubmit = async (event) => {
3414
3707
  event.preventDefault();
3415
- if (!canEditWork()) return;
3708
+ if (!canEditModule("settings")) return;
3416
3709
  const form = new FormData(event.currentTarget);
3417
3710
  const submit = $("#setting-editor-submit");
3418
3711
  submit.disabled = true;
@@ -3494,11 +3787,11 @@ function renderCharacterEditorRelationships() {
3494
3787
  const relationLabel = [category, relationship.subtype].filter(Boolean).join(" · ") || "未细分";
3495
3788
  const keywords = Array.isArray(relationship.keywords) ? relationship.keywords : [];
3496
3789
  return `<article class="character-relationship-row">
3497
- <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>
3498
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>
3499
3792
  </article>`;
3500
3793
  }).join("");
3501
- 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>'}`;
3502
3795
  host.querySelectorAll("[data-character-relationship-edit]").forEach((button) => button.addEventListener("click", () => {
3503
3796
  const relationship = characterEditorRelationships.find((item) => item.id === button.dataset.characterRelationshipEdit);
3504
3797
  if (relationship) void openRelationshipDialog(relationship, { characterId });
@@ -3706,7 +3999,7 @@ async function showCharacterSectionVersions(sectionId) {
3706
3999
  host.querySelectorAll("[data-character-section-restore]").forEach((button) => button.addEventListener("click", async () => {
3707
4000
  button.disabled = true;
3708
4001
  try {
3709
- await api(`/api/character-sections/${sectionId}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.characterSectionRestore) } });
4002
+ await api(`/api/character-sections/${sectionId}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.characterSectionRestore) } });
3710
4003
  characterEditorSections = await api(`/api/characters/${characterEditorItem.id}/sections`);
3711
4004
  renderCharacterMarkdownSections();
3712
4005
  await Promise.all([renderCharacters(), loadAiReferences()]);
@@ -3728,9 +4021,9 @@ function renderCharacterMarkdownSections() {
3728
4021
  host.innerHTML = '<div class="character-editor-empty-field"><b>Markdown 档案章节</b><span>创建人物档案后即可添加背景故事、能力、经历和研究记录。</span></div>';
3729
4022
  return;
3730
4023
  }
3731
- 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>`;
3732
4025
  const sections = characterEditorSections.map((section) => `<article class="character-markdown-section">
3733
- <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>
3734
4027
  <div class="character-markdown-document message-body">${renderMarkdown(section.contentMarkdown) || '<p class="character-markdown-empty">本章节暂无正文。</p>'}</div>
3735
4028
  <div data-character-section-versions-host="${esc(section.id)}"></div>
3736
4029
  </article>`).join("");
@@ -3788,14 +4081,20 @@ function renderCharacterEditorFields(item) {
3788
4081
  characterEditorSection("basic", "基础资料", "用于检索、去重和建立人物在作品中的基本归属。",
3789
4082
  field("name", "标准名", "text", item?.name) +
3790
4083
  field("aliases", "别名", "item-list", item?.aliases ?? []) +
3791
- (state.races.length
4084
+ (!canReadModule("races")
4085
+ ? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
4086
+ : state.races.length
3792
4087
  ? field("raceId", "种族", "select", item?.raceId ?? "", raceOptions)
3793
4088
  : '<div class="character-editor-empty-field"><b>种族</b><span>尚未创建种族,请先在“种族”模块建立档案。</span></div>') +
3794
- (organizationOptions.length
4089
+ (!canReadModule("organizations")
4090
+ ? '<div class="character-editor-empty-field"><b>所属组织</b><span>当前账户没有组织模块读取权限,原有绑定不会被修改。</span></div>'
4091
+ : organizationOptions.length
3795
4092
  ? field("organizationIds", "所属组织(可多选)", "chips", item?.organizationIds ?? [], organizationOptions)
3796
4093
  : '<div class="character-editor-empty-field"><b>所属组织</b><span>尚未创建组织,可稍后在“组织”模块中补充。</span></div>') +
3797
4094
  field("visibility", "可见范围", "select", item?.visibility ?? "author", [["author", "仅作者"], ["collaborators", "协作者"], ["public", "公开"]]) +
3798
- 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>')),
3799
4098
  characterEditorSection("profile", "人物档案", "记录人物定位、行为动力和便于创作时快速理解的简介。",
3800
4099
  field("identity", "身份与定位", "text", item?.attributes?.identity) +
3801
4100
  field("motivation", "核心动机", "textarea", item?.profile?.motivation) +
@@ -3831,11 +4130,9 @@ function collectCharacterBody(form) {
3831
4130
  const item = characterEditorItem;
3832
4131
  const profile = { ...(item?.profile ?? {}) };
3833
4132
  delete profile.sections;
3834
- return {
4133
+ const body = {
3835
4134
  name: String(form.get("name") ?? "").trim(),
3836
4135
  aliases: form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean),
3837
- raceId: form.get("raceId") || null,
3838
- organizationIds: form.getAll("organizationIds").map(String),
3839
4136
  attributes: {
3840
4137
  ...(item?.attributes ?? {}),
3841
4138
  identity: String(form.get("identity") ?? "").trim(),
@@ -3849,9 +4146,12 @@ function collectCharacterBody(form) {
3849
4146
  currentState: buildCharacterState(form.getAll("stateKey"), form.getAll("stateValue"), item?.currentState ?? {}),
3850
4147
  lockedFields: form.getAll("lockedFields").map((value) => String(value).trim()).filter(Boolean),
3851
4148
  visibility: String(form.get("visibility") ?? "author"),
3852
- firstChapterId: form.get("firstChapterId") || null,
3853
4149
  changeNote: String(form.get("changeNote") ?? "").trim()
3854
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;
3855
4155
  }
3856
4156
 
3857
4157
  function renderCharacterHistory() {
@@ -3923,8 +4223,8 @@ async function showCharacterHistory() {
3923
4223
 
3924
4224
  async function openCharacterEditor(item = null) {
3925
4225
  [state.races, state.organizations] = await Promise.all([
3926
- apiAllPages(`/api/works/${state.work.id}/races`),
3927
- 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([])
3928
4228
  ]);
3929
4229
  characterEditorItem = item ?? null;
3930
4230
  characterEditorVersions = [];
@@ -3940,7 +4240,7 @@ async function openCharacterEditor(item = null) {
3940
4240
  $("#character-history-button").title = item ? "查看、比较和回滚历史版本" : "创建人物档案后即可查看版本历史";
3941
4241
  setCharacterHistoryVisible(false);
3942
4242
  renderCharacterEditorFields(item);
3943
- const viewOnly = !canEditWork();
4243
+ const viewOnly = !canEditModule("characters");
3944
4244
  if (viewOnly) {
3945
4245
  $("#character-editor-eyebrow").textContent = "人物档案";
3946
4246
  $("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
@@ -3952,12 +4252,12 @@ async function openCharacterEditor(item = null) {
3952
4252
  button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
3953
4253
  });
3954
4254
  const relationshipTab = document.querySelector("[data-character-editor-tab='relationships']");
3955
- relationshipTab.disabled = !item;
3956
- relationshipTab.title = item ? "查看和编辑人物关系" : "创建人物档案后即可维护人物关系";
4255
+ relationshipTab.disabled = !item || !canReadModule("relationships");
4256
+ relationshipTab.title = !canReadModule("relationships") ? "当前账户没有关系模块读取权限" : item ? "查看和编辑人物关系" : "创建人物档案后即可维护人物关系";
3957
4257
  const form = $("#character-editor-form");
3958
4258
  form.onsubmit = async (event) => {
3959
4259
  event.preventDefault();
3960
- if (!canEditWork()) return;
4260
+ if (!canEditModule("characters")) return;
3961
4261
  const submit = $("#character-editor-submit");
3962
4262
  submit.disabled = true;
3963
4263
  try {
@@ -3979,13 +4279,13 @@ async function openCharacterEditor(item = null) {
3979
4279
  };
3980
4280
  showEntityEditorPage("character");
3981
4281
  if (item) {
3982
- void loadCharacterEditorRelationships(item.id);
4282
+ if (canReadModule("relationships")) void loadCharacterEditorRelationships(item.id);
3983
4283
  void loadCharacterMarkdownSections(item.id);
3984
4284
  }
3985
4285
  }
3986
4286
 
3987
4287
  async function openRaceDialog(item) {
3988
- state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
4288
+ state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
3989
4289
  const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
3990
4290
  const parentOptions = [["", "无(根种族)"], ...eligibleRaceParents(state.races, item?.id)
3991
4291
  .sort((left, right) => racePathLabel(left).localeCompare(racePathLabel(right), "zh-CN"))
@@ -3998,7 +4298,8 @@ async function openRaceDialog(item) {
3998
4298
  (memberOptions.length ? field("memberIds", "属于该种族的角色(可多选)", "chips", item?.memberIds ?? [], memberOptions) : ""),
3999
4299
  async (form) => {
4000
4300
  const settings = form.getAll("settings").map((value) => String(value).trim()).filter(Boolean);
4001
- 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);
4002
4303
  await api(item ? `/api/races/${item.id}` : `/api/works/${state.work.id}/races`, { method: item ? "PATCH" : "POST", body });
4003
4304
  await renderRaces();
4004
4305
  await loadAiReferences();
@@ -4006,7 +4307,7 @@ async function openRaceDialog(item) {
4006
4307
  }
4007
4308
 
4008
4309
  async function openOrganizationDialog(item) {
4009
- state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
4310
+ state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
4010
4311
  const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
4011
4312
  openDialog(item ? "编辑组织" : "新建组织",
4012
4313
  field("name", "组织名称", "text", item?.name) +
@@ -4015,7 +4316,8 @@ async function openOrganizationDialog(item) {
4015
4316
  (memberOptions.length ? field("memberIds", "组织成员(可多选)", "chips", item?.memberIds ?? [], memberOptions) : ""),
4016
4317
  async (form) => {
4017
4318
  const settings = form.getAll("settings").map((value) => String(value).trim()).filter(Boolean);
4018
- 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);
4019
4321
  await api(item ? `/api/organizations/${item.id}` : `/api/works/${state.work.id}/organizations`, { method: item ? "PATCH" : "POST", body });
4020
4322
  await renderOrganizations();
4021
4323
  await loadAiReferences();
@@ -4033,7 +4335,7 @@ function openTimelineDialog(item, preferredTrackId = null) {
4033
4335
  const trackOptions = [["", "未分组"], ...state.timelineTracks.map((track) => [track.id, track.name])];
4034
4336
  openDialog(item ? "编辑大事件" : "新建大事件", field("trackId", "所属独立时间轴", "select", item?.trackId ?? preferredTrackId ?? "", trackOptions) + field("name", "事件名称", "text", item?.name) + field("timeLabel", "时间描述", "text", item?.timeLabel ?? "时间待定") + field("timeSort", "排序值(留空表示时间待定)", "number", item?.timeSort ?? "") + field("eventType", "事件类型", "text", item?.eventType ?? "other") + field("location", "地点", "text", item?.location) + field("description", "事件简述", "textarea", item?.description), async (form) => {
4035
4337
  const rawSort = String(form.get("timeSort") ?? "").trim();
4036
- const body = { trackId: form.get("trackId") || null, name: form.get("name"), timeLabel: form.get("timeLabel"), timeSort: rawSort ? Number(rawSort) : null, eventType: form.get("eventType"), location: form.get("location"), description: form.get("description"), status: item?.status ?? "confirmed" };
4338
+ const body = { trackId: form.get("trackId") || null, name: form.get("name"), timeLabel: form.get("timeLabel"), timeSort: rawSort ? Number(rawSort) : null, eventType: form.get("eventType"), location: form.get("location"), description: form.get("description"), status: item?.status ?? "confirmed", ...(item ? { expectedVersionNo: item.versionNo } : {}) };
4037
4339
  await api(item ? `/api/timeline/${item.id}` : `/api/works/${state.work.id}/timeline`, { method: item ? "PATCH" : "POST", body });
4038
4340
  await renderTimeline();
4039
4341
  }, item ? "人工调整" : "作者确认事件");
@@ -4049,7 +4351,7 @@ function openOutlineDialog(item) {
4049
4351
  field("status", "规划状态", "select", item.status ?? "draft", [["draft", "草稿"], ["ready", "可执行"], ["completed", "已完成"]]),
4050
4352
  async (form) => {
4051
4353
  await api(`/api/chapters/${item.chapterId}/outline`, { method: "PUT", body: {
4052
- goal: form.get("goal"), conflict: form.get("conflict"), turningPoint: form.get("turningPoint"), notes: form.get("notes"), status: form.get("status")
4354
+ goal: form.get("goal"), conflict: form.get("conflict"), turningPoint: form.get("turningPoint"), notes: form.get("notes"), status: form.get("status"), expectedVersionNo: item.versionNo
4053
4355
  } });
4054
4356
  await renderOutlines();
4055
4357
  toast("章节规划已保存");
@@ -4092,7 +4394,8 @@ function openForeshadowDialog(item) {
4092
4394
  const occurrences = [...preservedOccurrences, ...editedOccurrences];
4093
4395
  const body = {
4094
4396
  title: form.get("title"), description: form.get("description"), importance: form.get("importance"), status: form.get("status"),
4095
- plannedPayoffChapterId: form.get("payoffChapterId") || null, resolutionNote: form.get("resolutionNote"), occurrences
4397
+ plannedPayoffChapterId: form.get("payoffChapterId") || null, resolutionNote: form.get("resolutionNote"), occurrences,
4398
+ ...(item ? { expectedVersionNo: item.versionNo } : {})
4096
4399
  };
4097
4400
  await api(item ? `/api/foreshadows/${item.id}` : `/api/works/${state.work.id}/foreshadows`, { method: item ? "PATCH" : "POST", body });
4098
4401
  await renderOutlines();
@@ -4105,12 +4408,13 @@ function openTimelineSplitDialog(item) {
4105
4408
  await api(`/api/timeline/${item.id}/split`, { method: "POST", body: { parts: [
4106
4409
  { name: form.get("firstName"), description: form.get("firstDescription") },
4107
4410
  { name: form.get("secondName"), description: form.get("secondDescription") }
4108
- ] } });
4411
+ ], expectedVersionNo: item.versionNo } });
4109
4412
  await renderTimeline();
4110
4413
  }, "原证据同步保留");
4111
4414
  }
4112
4415
 
4113
4416
  async function openRelationshipDialog(item, options = {}) {
4417
+ if (!canReadModule("characters")) return toast("配置人物关系前需要角色模块读取权限", "error");
4114
4418
  state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
4115
4419
  if (state.characters.length < 2) return toast("至少需要两个角色才能创建关系", "error");
4116
4420
  const characterOptions = state.characters.map((item) => [item.id, item.name]);
@@ -4118,7 +4422,7 @@ async function openRelationshipDialog(item, options = {}) {
4118
4422
  const defaultTo = characterOptions.find(([id]) => id !== defaultFrom)?.[0] ?? characterOptions[1][0];
4119
4423
  openDialog(item ? "编辑人物关系" : "新建人物关系", field("from", "起点人物", "select", item?.fromCharacterId ?? defaultFrom, characterOptions) + field("to", "终点人物", "select", item?.toCharacterId ?? defaultTo, characterOptions) + field("category", "关系大类", "select", item?.category ?? "social", [["family", "亲属"], ["social", "社交"], ["emotional", "情感"], ["conflict", "冲突"], ["uncertain", "未确定"]]) + field("subtype", "关系子类", "text", item?.subtype) + field("keywords", "关系关键词", "keyword-chips", item?.keywords ?? []) + field("confidence", "置信度(0-1)", "number", item?.confidence ?? "1") + field("directed", "有方向性", "checkbox", item?.directed ?? false), async (form) => {
4120
4424
  const keywords = uniqueRelationshipKeywords(form.getAll("keywords").map(String));
4121
- await api(item ? `/api/relationships/${item.id}` : `/api/works/${state.work.id}/relationships`, { method: item ? "PATCH" : "POST", body: { fromCharacterId: form.get("from"), toCharacterId: form.get("to"), category: form.get("category"), subtype: form.get("subtype"), keywords, confidence: Number(form.get("confidence")), directed: form.get("directed") === "on", confirmationStatus: item?.confirmationStatus ?? "confirmed" } });
4425
+ await api(item ? `/api/relationships/${item.id}` : `/api/works/${state.work.id}/relationships`, { method: item ? "PATCH" : "POST", body: { fromCharacterId: form.get("from"), toCharacterId: form.get("to"), category: form.get("category"), subtype: form.get("subtype"), keywords, confidence: Number(form.get("confidence")), directed: form.get("directed") === "on", confirmationStatus: item?.confirmationStatus ?? "confirmed", ...(item ? { expectedVersionNo: item.versionNo } : {}) } });
4122
4426
  await refreshRelationshipSurfaces(options.characterId ?? null);
4123
4427
  }, item ? "关系档案" : "人工确认关系");
4124
4428
  }
@@ -4421,7 +4725,7 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
4421
4725
  async function showVersions() {
4422
4726
  if (!state.chapter) return;
4423
4727
  const versions = await api(`/api/chapters/${state.chapter.id}/versions`);
4424
- $("#versions-list").innerHTML = versions.map((version) => `<div class="version-row"><div><b>v${version.versionNo}</b><small>${esc(version.source)} · ${esc(version.actor || "历史数据")}</small></div><p>${esc(version.content.slice(0, 300) || "空白章节")}</p><button class="ghost-button" data-restore-version="${version.versionNo}">恢复</button></div>`).join("");
4728
+ $("#versions-list").innerHTML = versions.map((version) => `<div class="version-row"><div><b>v${version.versionNo}</b><small>${esc(version.source)} · ${esc(version.actor || "历史数据")}</small></div><p>${esc(version.content.slice(0, 300) || "空白章节")}</p>${canEditProse() ? `<button class="ghost-button" data-restore-version="${version.versionNo}">恢复</button>` : ""}</div>`).join("");
4425
4729
  $("#versions-list").querySelectorAll("[data-restore-version]").forEach((button) => button.addEventListener("click", async () => {
4426
4730
  if (!window.confirm(`将版本 v${button.dataset.restoreVersion} 恢复为一个新的保存版本?`)) return;
4427
4731
  state.chapter = await api(`/api/chapters/${state.chapter.id}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.restoreVersion) } });
@@ -4713,18 +5017,34 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
4713
5017
  }
4714
5018
  });
4715
5019
  $("#members-dialog-close").addEventListener("click", () => $("#members-dialog").close());
4716
- $("#members-dialog").addEventListener("close", () => { memberDialogWork = null; });
4717
- $("#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) => {
4718
5032
  event.preventDefault();
4719
5033
  const userId = $("#member-user-select").value;
4720
5034
  const work = memberDialogWork ?? state.work;
4721
5035
  if (!work || !userId) return;
4722
5036
  try {
4723
- const role = $("#member-role-select").value;
4724
- 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
+ });
4725
5045
  renderMembers(members);
4726
- await fillMemberCandidates(members);
4727
- toast(role === "viewer" ? "仅查看成员已邀请" : "协作者已邀请");
5046
+ renderMemberSelector(userId);
5047
+ toast(existing ? "成员模块权限已更新" : "成员已添加并保存模块权限");
4728
5048
  } catch (error) { toast(error.message, "error"); }
4729
5049
  });
4730
5050
  $("#platform-new-provider").addEventListener("click", () => openProviderDialog());
@@ -4873,22 +5193,35 @@ $("#ai-mention-menu").addEventListener("click", (event) => {
4873
5193
  if (button) selectAiMention(button);
4874
5194
  });
4875
5195
  $("#import-file").addEventListener("change", async (event) => {
4876
- if (!state.work || !event.target.files[0]) return;
4877
- if (!confirmDiscardChanges("导入会替换当前作品目录,未保存修改将丢失。是否继续?")) {
5196
+ const file = event.target.files[0];
5197
+ if (!state.work || !file) return;
5198
+ if (!canEditProse()) {
5199
+ event.target.value = "";
5200
+ toast("当前权限只能编辑设定资料,不能导入正文", "error");
5201
+ return;
5202
+ }
5203
+ const mode = await chooseExistingWorkImportMode(file);
5204
+ if (!mode) {
4878
5205
  event.target.value = "";
4879
5206
  return;
4880
5207
  }
5208
+ cancelChapterAutoSave();
4881
5209
  const body = new FormData();
4882
- body.append("file", event.target.files[0]);
5210
+ body.append("file", file);
5211
+ body.append("mode", mode);
5212
+ body.append("expectedVersionNo", String(state.work.versionNo));
4883
5213
  try {
4884
5214
  const result = await api(`/api/works/${state.work.id}/import`, { method: "POST", body });
4885
- setSaveState("已导入");
5215
+ setSaveState(mode === "append" ? "已追加" : "已覆盖");
4886
5216
  state.work = result.tree;
4887
5217
  renderTree();
4888
- toast(result.warnings.length ? `导入完成:${result.warnings.join("")}` : "导入完成");
4889
- const first = state.work.volumes.flatMap((volume) => volume.chapters)[0];
4890
- if (first) await selectChapter(first.id);
4891
- } catch (error) { toast(error.message, "error"); }
5218
+ const completion = mode === "append" ? "正文追加完成" : "正文覆盖完成";
5219
+ toast(result.warnings.length ? `${completion}:${result.warnings.join(";")}` : completion);
5220
+ if (result.firstImportedChapterId) await selectChapter(result.firstImportedChapterId);
5221
+ } catch (error) {
5222
+ toast(error.message, "error");
5223
+ if (state.dirty) scheduleChapterAutoSave();
5224
+ }
4892
5225
  event.target.value = "";
4893
5226
  });
4894
5227
  $("#new-import-file").addEventListener("change", async (event) => {