@musnows/scriverse 0.3.8 → 0.3.9

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.
@@ -81,18 +81,36 @@ function analysisTaskStatusLabel(status) {
81
81
  }
82
82
 
83
83
  function canEditWork(work = state.work) {
84
+ return ["admin", "owner", "editor", "settings-editor"].includes(String(work?.accessRole));
85
+ }
86
+
87
+ function canEditProse(work = state.work) {
84
88
  return ["admin", "owner", "editor"].includes(String(work?.accessRole));
85
89
  }
86
90
 
91
+ function canManageWork(work = state.work) {
92
+ return ["admin", "owner"].includes(String(work?.accessRole));
93
+ }
94
+
95
+ 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);
98
+ }
99
+
87
100
  function applyWorkAccessMode() {
88
101
  const viewOnly = Boolean(state.work) && !canEditWork();
102
+ const settingsOnly = String(state.work?.accessRole) === "settings-editor";
103
+ const proseReadOnly = Boolean(state.work) && !canEditProse();
89
104
  $("#app").classList.toggle("view-only-mode", viewOnly);
105
+ $("#app").classList.toggle("settings-only-mode", settingsOnly);
106
+ $("#app").classList.toggle("prose-read-only-mode", proseReadOnly);
90
107
  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) {
108
+ document.body.classList.toggle("work-settings-editor-mode", settingsOnly);
109
+ $("#chapter-title").readOnly = proseReadOnly;
110
+ $("#chapter-content").readOnly = proseReadOnly;
111
+ $("#chapter-title").setAttribute("aria-readonly", String(proseReadOnly));
112
+ $("#chapter-content").setAttribute("aria-readonly", String(proseReadOnly));
113
+ if (proseReadOnly) {
96
114
  cancelChapterAutoSave();
97
115
  state.dirty = false;
98
116
  }
@@ -1424,15 +1442,75 @@ applyTypographySettings(typographySettings);
1424
1442
  applyColorTheme(currentColorTheme());
1425
1443
  applyPanelLayout();
1426
1444
 
1445
+ function optimisticVersionForPath(path) {
1446
+ const normalizedPath = String(path).split("?")[0];
1447
+ const find = (items, id) => items.find((item) => String(item?.id ?? item?.chapterId ?? "") === id)?.versionNo;
1448
+ const workMatch = normalizedPath.match(/^\/api\/works\/([^/]+)(?:\/(?:cover|import|file-versions\/[^/]+\/restore))?$/u);
1449
+ if (workMatch) {
1450
+ const workId = decodeURIComponent(workMatch[1]);
1451
+ return state.works.find((item) => item.id === workId)?.versionNo ?? (state.work?.id === workId ? state.work.versionNo : undefined);
1452
+ }
1453
+ const resourceMatch = normalizedPath.match(/^\/api\/(volumes|chapters|settings|races|organizations|timeline-tracks|timeline|relationships|foreshadows|characters|character-sections)\/([^/]+)(?:\/(?:restore|move|split))?$/u);
1454
+ if (resourceMatch) {
1455
+ const resourceId = decodeURIComponent(resourceMatch[2]);
1456
+ const collection = {
1457
+ settings: state.settings,
1458
+ races: state.races,
1459
+ organizations: state.organizations,
1460
+ "timeline-tracks": state.timelineTracks,
1461
+ characters: state.characters
1462
+ }[resourceMatch[1]] ?? [];
1463
+ if (resourceMatch[1] === "chapters" && state.chapter?.id === resourceId) return state.chapter.versionNo;
1464
+ if (resourceMatch[1] === "volumes") return find(state.work?.volumes ?? [], resourceId);
1465
+ if (resourceMatch[1] === "character-sections") return find(characterEditorSections, resourceId);
1466
+ if (resourceMatch[1] === "characters" && characterEditorItem?.id === resourceId) return characterEditorItem.versionNo;
1467
+ return find(collection, resourceId);
1468
+ }
1469
+ const outlineMatch = normalizedPath.match(/^\/api\/chapters\/([^/]+)\/outline$/u);
1470
+ if (outlineMatch) return find(state.outlines ?? [], decodeURIComponent(outlineMatch[1]));
1471
+ const entityRestoreMatch = normalizedPath.match(/^\/api\/entity-versions\/(work|volume|setting|race|organization|timeline-track|timeline-event|relationship|chapter-outline|foreshadow)\/([^/]+)\/restore$/u);
1472
+ if (entityRestoreMatch) {
1473
+ const entityType = entityRestoreMatch[1];
1474
+ const entityId = decodeURIComponent(entityRestoreMatch[2]);
1475
+ if (entityType === "work") return state.works.find((item) => item.id === entityId)?.versionNo ?? (state.work?.id === entityId ? state.work.versionNo : undefined);
1476
+ if (entityType === "volume") return find(state.work?.volumes ?? [], entityId);
1477
+ if (entityType === "chapter-outline") return find(state.outlines ?? [], entityId);
1478
+ const collection = {
1479
+ setting: state.settings,
1480
+ race: state.races,
1481
+ organization: state.organizations,
1482
+ "timeline-track": state.timelineTracks
1483
+ }[entityType] ?? [];
1484
+ return find(collection, entityId);
1485
+ }
1486
+ return undefined;
1487
+ }
1488
+
1489
+ function attachOptimisticVersion(path, method, body) {
1490
+ if (!["PATCH", "PUT", "DELETE", "POST"].includes(method)) return body;
1491
+ if (body instanceof FormData) {
1492
+ if (!body.has("expectedVersionNo")) {
1493
+ const versionNo = optimisticVersionForPath(path);
1494
+ if (Number.isInteger(versionNo) && versionNo > 0) body.append("expectedVersionNo", String(versionNo));
1495
+ }
1496
+ return body;
1497
+ }
1498
+ const currentBody = body && typeof body === "object" && !Array.isArray(body) ? body : {};
1499
+ if (currentBody.expectedVersionNo !== undefined) return currentBody;
1500
+ const versionNo = optimisticVersionForPath(path);
1501
+ return Number.isInteger(versionNo) && versionNo > 0 ? { ...currentBody, expectedVersionNo: versionNo } : body;
1502
+ }
1503
+
1427
1504
  async function api(path, options = {}) {
1428
1505
  const method = String(options.method ?? "GET").toUpperCase();
1506
+ const body = attachOptimisticVersion(path, method, options.body);
1429
1507
  const headers = { ...(options.headers ?? {}) };
1430
1508
  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 } : {
1509
+ if (!(body instanceof FormData)) headers["Content-Type"] = "application/json";
1510
+ const response = await fetch(path, body instanceof FormData ? { ...options, body, headers } : {
1433
1511
  ...options,
1434
1512
  headers,
1435
- body: options.body && typeof options.body !== "string" ? JSON.stringify(options.body) : options.body
1513
+ body: body && typeof body !== "string" ? JSON.stringify(body) : body
1436
1514
  });
1437
1515
  if (!response.ok) {
1438
1516
  const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
@@ -1616,7 +1694,7 @@ function cancelChapterAutoSave() {
1616
1694
  }
1617
1695
 
1618
1696
  function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
1619
- if (!state.chapter || !canEditWork()) return;
1697
+ if (!state.chapter || !canEditProse()) return;
1620
1698
  cancelChapterAutoSave();
1621
1699
  setSaveState("等待自动保存", true);
1622
1700
  chapterAutoSaveTimer = setTimeout(() => {
@@ -1626,7 +1704,7 @@ function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
1626
1704
  }
1627
1705
 
1628
1706
  async function persistChapter({ automatic = false } = {}) {
1629
- if (!canEditWork()) return null;
1707
+ if (!canEditProse()) return null;
1630
1708
  if (!state.chapter) {
1631
1709
  if (!automatic) toast("请先选择章节", "error");
1632
1710
  return null;
@@ -1694,6 +1772,19 @@ function confirmDiscardChanges(message = "当前章节有未保存修改,继
1694
1772
  return window.confirm(message);
1695
1773
  }
1696
1774
 
1775
+ function chooseExistingWorkImportMode(file) {
1776
+ const dialog = $("#import-mode-dialog");
1777
+ $("#import-mode-file-summary").textContent = `文件:${file.name};当前作品:《${state.work.title}》`;
1778
+ $("#import-mode-unsaved-warning").classList.toggle("hidden", !state.dirty);
1779
+ dialog.returnValue = "cancel";
1780
+ dialog.showModal();
1781
+ return new Promise((resolve) => {
1782
+ dialog.addEventListener("close", () => {
1783
+ resolve(["append", "overwrite"].includes(dialog.returnValue) ? dialog.returnValue : null);
1784
+ }, { once: true });
1785
+ });
1786
+ }
1787
+
1697
1788
  function updateDocumentTitle(work = null) {
1698
1789
  const workTitle = String(work?.title ?? "").trim();
1699
1790
  document.title = workTitle ? `${workTitle} · 叙界` : platformDocumentTitle;
@@ -1886,8 +1977,8 @@ function renderMembers(members) {
1886
1977
  const work = memberDialogWork ?? state.work;
1887
1978
  const canManage = ["admin", "owner"].includes(String(work?.accessRole));
1888
1979
  $("#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>`}
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>`}
1891
1982
  ${member.role === "owner" || !canManage ? "<span></span>" : `<button type="button" data-remove-member="${esc(member.userId)}">移除</button>`}
1892
1983
  </article>`).join("");
1893
1984
  bindUserAvatarFallbacks($("#members-list"));
@@ -2090,9 +2181,9 @@ function renderShelf() {
2090
2181
  <span class="book-cover-fallback">${esc(Array.from(work.title)[0] ?? "书")}</span>
2091
2182
  ${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面">` : ""}
2092
2183
  </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>
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>
2094
2185
  </button>
2095
- ${canEditWork(work) ? `<button class="book-card-settings" type="button" data-edit-work="${esc(work.id)}" aria-label="作品设置" title="作品设置">设置</button>` : ""}
2186
+ ${canManageWork(work) ? `<button class="book-card-settings" type="button" data-edit-work="${esc(work.id)}" aria-label="作品设置" title="作品设置">设置</button>` : ""}
2096
2187
  </article>`).join("")}
2097
2188
  <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
2189
  shelf.querySelectorAll("[data-open-work]").forEach((button) => button.addEventListener("click", () => selectWork(button.dataset.openWork)));
@@ -2177,7 +2268,7 @@ function renderTree() {
2177
2268
  renderTree();
2178
2269
  });
2179
2270
  button.addEventListener("contextmenu", (event) => {
2180
- if (!canEditWork()) return;
2271
+ if (!canEditProse()) return;
2181
2272
  event.preventDefault();
2182
2273
  openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeToggle));
2183
2274
  });
@@ -2185,7 +2276,7 @@ function renderTree() {
2185
2276
  $("#novel-tree").querySelectorAll("[data-chapter-id]").forEach((button) => {
2186
2277
  button.addEventListener("click", () => selectChapter(button.dataset.chapterId));
2187
2278
  button.addEventListener("contextmenu", (event) => {
2188
- if (!canEditWork()) return;
2279
+ if (!canEditProse()) return;
2189
2280
  event.preventDefault();
2190
2281
  openChapterTypeMenu(button.dataset.chapterId, event.clientX, event.clientY);
2191
2282
  });
@@ -2235,7 +2326,7 @@ async function selectChapter(chapterId) {
2235
2326
  scheduleChapterLineNumbers();
2236
2327
  $("#chapter-insight").classList.add("hidden");
2237
2328
  updateChapterStats();
2238
- if (!canEditWork()) setSaveState("仅查看");
2329
+ if (!canEditProse()) setSaveState(canEditWork() ? "正文只读" : "仅查看");
2239
2330
  else if (spacingChanged) scheduleChapterAutoSave(120);
2240
2331
  else setSaveState("已保存");
2241
2332
  renderTree();
@@ -2293,7 +2384,7 @@ const moduleMeta = {
2293
2384
 
2294
2385
  async function showModule(module) {
2295
2386
  if (!state.work) return showWelcome();
2296
- if (!canEditWork() && ["tasks", "ai-settings"].includes(module)) module = "editor";
2387
+ if (!canEditProse() && ["tasks", "ai-settings"].includes(module)) module = "editor";
2297
2388
  if (module !== "editor" && state.module === "editor" && !confirmDiscardChanges()) return;
2298
2389
  if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
2299
2390
  state.module = module;
@@ -2315,7 +2406,7 @@ async function showModule(module) {
2315
2406
  $("#module-title").textContent = meta[1];
2316
2407
  $("#module-description").textContent = meta[2];
2317
2408
  $("#module-create-button").textContent = meta[3];
2318
- $("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditWork());
2409
+ $("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditModule(module));
2319
2410
  $("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
2320
2411
  try {
2321
2412
  if (module === "settings") await renderSettings();
@@ -2404,6 +2495,36 @@ function bindEntityHistoryButtons(refresh) {
2404
2495
  }));
2405
2496
  }
2406
2497
 
2498
+ function openEntityMergeDialog({ typeLabel, source, candidates, endpoint, body, refresh, impact }) {
2499
+ const targetOptions = candidates
2500
+ .filter((candidate) => candidate.id !== source.id)
2501
+ .map((candidate) => [candidate.id, candidate.name]);
2502
+ openDialog(`合并${typeLabel}`,
2503
+ `<p class="merge-dialog-note">“${esc(source.name)}”将合并到所选档案,目标档案会保留。${esc(impact)}</p>` +
2504
+ field("targetId", `目标${typeLabel}`, "select", targetOptions[0]?.[0] ?? "", targetOptions),
2505
+ async (form) => {
2506
+ const target = candidates.find((candidate) => candidate.id === form.get("targetId"));
2507
+ if (!target) throw new Error(`请选择目标${typeLabel}`);
2508
+ await api(endpoint(source), { method: "POST", body: body(target) });
2509
+ await refresh();
2510
+ await loadAiReferences();
2511
+ toast(`已将“${source.name}”合并到“${target.name}”`);
2512
+ }, "人工资料管理", { submitLabel: "确认合并" });
2513
+ }
2514
+
2515
+ async function deleteManagedEntity({ typeLabel, item, endpoint, refresh, warning = "" }) {
2516
+ const detail = warning ? `\n${warning}` : "";
2517
+ if (!window.confirm(`确认删除${typeLabel}“${item.name}”吗?${detail}`)) return;
2518
+ try {
2519
+ await api(endpoint(item), { method: "DELETE" });
2520
+ await refresh();
2521
+ await loadAiReferences();
2522
+ toast(`已删除${typeLabel}“${item.name}”`);
2523
+ } catch (error) {
2524
+ toast(error.message, "error");
2525
+ }
2526
+ }
2527
+
2407
2528
  async function renderSettings() {
2408
2529
  const records = (await apiPage(`/api/works/${state.work.id}/settings`)).items;
2409
2530
  state.settings = records;
@@ -2439,7 +2560,7 @@ async function renderCharacters() {
2439
2560
  <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
2561
  ${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
2562
  ${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>`;
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>`;
2443
2564
  }).join("")}</div>`
2444
2565
  : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
2445
2566
  $("#create-character-audit-task")?.addEventListener("click", async () => {
@@ -2464,6 +2585,34 @@ async function renderCharacters() {
2464
2585
  });
2465
2586
  });
2466
2587
  $("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterEditor(state.characters.find((item) => item.id === button.dataset.editCharacter))));
2588
+ $("#module-content").querySelectorAll("[data-merge-character]").forEach((button) => button.addEventListener("click", () => {
2589
+ const source = state.characters.find((item) => item.id === button.dataset.mergeCharacter);
2590
+ if (!source) return;
2591
+ openEntityMergeDialog({
2592
+ typeLabel: "角色",
2593
+ source,
2594
+ candidates: state.characters,
2595
+ endpoint: (item) => `/api/characters/${encodeURIComponent(item.id)}/merge`,
2596
+ body: (target) => ({
2597
+ targetCharacterId: target.id,
2598
+ expectedTargetVersionNo: target.versionNo,
2599
+ expectedSourceVersionNo: source.versionNo
2600
+ }),
2601
+ refresh: renderCharacters,
2602
+ impact: "来源角色的别名、组织、档案章节、时间线与人物关系会迁移到目标角色。"
2603
+ });
2604
+ }));
2605
+ $("#module-content").querySelectorAll("[data-delete-character]").forEach((button) => button.addEventListener("click", () => {
2606
+ const item = state.characters.find((character) => character.id === button.dataset.deleteCharacter);
2607
+ if (!item) return;
2608
+ void deleteManagedEntity({
2609
+ typeLabel: "角色",
2610
+ item,
2611
+ endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}`,
2612
+ refresh: renderCharacters,
2613
+ warning: "相关人物关系会删除,时间线中的参与者引用会移除。"
2614
+ });
2615
+ }));
2467
2616
  }
2468
2617
 
2469
2618
  async function renderRaces() {
@@ -2479,13 +2628,37 @@ async function renderRaces() {
2479
2628
  <p>${esc(item.description || "尚未填写种族简介")}</p>
2480
2629
  <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
2630
  <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>
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>
2483
2632
  </article>
2484
2633
  ${item.children.length ? `<div class="race-tree-children">${item.children.map(renderRaceNode).join("")}</div>` : ""}
2485
2634
  </div>
2486
2635
  </details>`;
2487
2636
  $("#module-content").innerHTML = state.races.length ? `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>` : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
2488
2637
  $("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => openRaceDialog(state.races.find((item) => item.id === button.dataset.editRace))));
2638
+ $("#module-content").querySelectorAll("[data-merge-race]").forEach((button) => button.addEventListener("click", () => {
2639
+ const source = state.races.find((item) => item.id === button.dataset.mergeRace);
2640
+ if (!source) return;
2641
+ openEntityMergeDialog({
2642
+ typeLabel: "种族",
2643
+ source,
2644
+ candidates: state.races,
2645
+ endpoint: (item) => `/api/races/${encodeURIComponent(item.id)}/merge`,
2646
+ body: (target) => ({ targetRaceId: target.id }),
2647
+ refresh: renderRaces,
2648
+ impact: "来源种族的角色、子种族、简介与共同设定会迁移到目标种族。"
2649
+ });
2650
+ }));
2651
+ $("#module-content").querySelectorAll("[data-delete-race]").forEach((button) => button.addEventListener("click", () => {
2652
+ const item = state.races.find((race) => race.id === button.dataset.deleteRace);
2653
+ if (!item) return;
2654
+ void deleteManagedEntity({
2655
+ typeLabel: "种族",
2656
+ item,
2657
+ endpoint: (race) => `/api/races/${encodeURIComponent(race.id)}`,
2658
+ refresh: renderRaces,
2659
+ warning: "已绑定角色将变为未指定种族;有子种族时需先迁移或合并。"
2660
+ });
2661
+ }));
2489
2662
  bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
2490
2663
  }
2491
2664
 
@@ -2499,9 +2672,33 @@ async function renderOrganizations() {
2499
2672
  <h3>${esc(item.name)}</h3><p>${esc(item.description || "尚未填写组织简介")}</p>
2500
2673
  <div class="organization-settings">${item.settings.map((setting) => `<span class="pill">${esc(setting)}</span>`).join("") || '<span class="pill">暂无组织设定</span>'}</div>
2501
2674
  <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>
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>
2503
2676
  </article>`).join("")}</div>` : emptyModule("还没有组织", "创建国家、机构、阵营或团队,并维护组织设定与成员。");
2504
2677
  $("#module-content").querySelectorAll("[data-edit-organization]").forEach((button) => button.addEventListener("click", () => openOrganizationDialog(state.organizations.find((item) => item.id === button.dataset.editOrganization))));
2678
+ $("#module-content").querySelectorAll("[data-merge-organization]").forEach((button) => button.addEventListener("click", () => {
2679
+ const source = state.organizations.find((item) => item.id === button.dataset.mergeOrganization);
2680
+ if (!source) return;
2681
+ openEntityMergeDialog({
2682
+ typeLabel: "组织",
2683
+ source,
2684
+ candidates: state.organizations,
2685
+ endpoint: (item) => `/api/organizations/${encodeURIComponent(item.id)}/merge`,
2686
+ body: (target) => ({ targetOrganizationId: target.id }),
2687
+ refresh: renderOrganizations,
2688
+ impact: "来源组织的成员、简介与组织设定会迁移到目标组织。"
2689
+ });
2690
+ }));
2691
+ $("#module-content").querySelectorAll("[data-delete-organization]").forEach((button) => button.addEventListener("click", () => {
2692
+ const item = state.organizations.find((organization) => organization.id === button.dataset.deleteOrganization);
2693
+ if (!item) return;
2694
+ void deleteManagedEntity({
2695
+ typeLabel: "组织",
2696
+ item,
2697
+ endpoint: (organization) => `/api/organizations/${encodeURIComponent(organization.id)}`,
2698
+ refresh: renderOrganizations,
2699
+ warning: "角色与该组织的成员关系会一并移除。"
2700
+ });
2701
+ }));
2505
2702
  bindEntityHistoryButtons(async () => { await renderOrganizations(); await loadAiReferences(); });
2506
2703
  }
2507
2704
 
@@ -2527,7 +2724,12 @@ async function renderTimeline() {
2527
2724
  const eventIds = [...$("#module-content").querySelectorAll("[data-event-select]:checked")].map((input) => input.dataset.eventSelect);
2528
2725
  if (eventIds.length < 2) return toast("请至少选择两个时间事件", "error");
2529
2726
  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 } });
2727
+ await api(`/api/works/${state.work.id}/timeline/merge`, { method: "POST", body: {
2728
+ eventIds,
2729
+ name: form.get("name"),
2730
+ description: form.get("description") || undefined,
2731
+ expectedVersionNos: Object.fromEntries(eventIds.map((eventId) => [eventId, Number(events.find((event) => event.id === eventId)?.versionNo)]))
2732
+ } });
2531
2733
  await renderTimeline();
2532
2734
  }, "保留参与者与证据");
2533
2735
  });
@@ -3361,6 +3563,7 @@ function openWorkSettingsDialog(work) {
3361
3563
 
3362
3564
  async function openChapterDialog() {
3363
3565
  if (!state.work) return openWorkDialog();
3566
+ if (!canEditProse()) return toast("当前权限只能编辑设定资料,正文为只读", "error");
3364
3567
  if (!state.work.volumes.length) {
3365
3568
  await api(`/api/works/${state.work.id}/volumes`, { method: "POST", body: { title: "正文", kind: "main" } });
3366
3569
  state.work = await api(`/api/works/${state.work.id}`);
@@ -3375,6 +3578,7 @@ async function openChapterDialog() {
3375
3578
 
3376
3579
  function openVolumeDialog(item) {
3377
3580
  if (!state.work) return openWorkDialog();
3581
+ if (!canEditProse()) return toast("当前权限只能编辑设定资料,不能修改分卷", "error");
3378
3582
  const kindOptions = [["main", "正文卷"], ["prequel", "前传"], ["extra", "番外"], ["epilogue", "后记"], ["appendix", "附录"]];
3379
3583
  openDialog(item ? "编辑分卷" : "新建分卷",
3380
3584
  field("title", "分卷名称", "text", item?.title) +
@@ -3706,7 +3910,7 @@ async function showCharacterSectionVersions(sectionId) {
3706
3910
  host.querySelectorAll("[data-character-section-restore]").forEach((button) => button.addEventListener("click", async () => {
3707
3911
  button.disabled = true;
3708
3912
  try {
3709
- await api(`/api/character-sections/${sectionId}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.characterSectionRestore) } });
3913
+ await api(`/api/character-sections/${sectionId}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.characterSectionRestore) } });
3710
3914
  characterEditorSections = await api(`/api/characters/${characterEditorItem.id}/sections`);
3711
3915
  renderCharacterMarkdownSections();
3712
3916
  await Promise.all([renderCharacters(), loadAiReferences()]);
@@ -4033,7 +4237,7 @@ function openTimelineDialog(item, preferredTrackId = null) {
4033
4237
  const trackOptions = [["", "未分组"], ...state.timelineTracks.map((track) => [track.id, track.name])];
4034
4238
  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
4239
  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" };
4240
+ 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
4241
  await api(item ? `/api/timeline/${item.id}` : `/api/works/${state.work.id}/timeline`, { method: item ? "PATCH" : "POST", body });
4038
4242
  await renderTimeline();
4039
4243
  }, item ? "人工调整" : "作者确认事件");
@@ -4049,7 +4253,7 @@ function openOutlineDialog(item) {
4049
4253
  field("status", "规划状态", "select", item.status ?? "draft", [["draft", "草稿"], ["ready", "可执行"], ["completed", "已完成"]]),
4050
4254
  async (form) => {
4051
4255
  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")
4256
+ goal: form.get("goal"), conflict: form.get("conflict"), turningPoint: form.get("turningPoint"), notes: form.get("notes"), status: form.get("status"), expectedVersionNo: item.versionNo
4053
4257
  } });
4054
4258
  await renderOutlines();
4055
4259
  toast("章节规划已保存");
@@ -4092,7 +4296,8 @@ function openForeshadowDialog(item) {
4092
4296
  const occurrences = [...preservedOccurrences, ...editedOccurrences];
4093
4297
  const body = {
4094
4298
  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
4299
+ plannedPayoffChapterId: form.get("payoffChapterId") || null, resolutionNote: form.get("resolutionNote"), occurrences,
4300
+ ...(item ? { expectedVersionNo: item.versionNo } : {})
4096
4301
  };
4097
4302
  await api(item ? `/api/foreshadows/${item.id}` : `/api/works/${state.work.id}/foreshadows`, { method: item ? "PATCH" : "POST", body });
4098
4303
  await renderOutlines();
@@ -4105,7 +4310,7 @@ function openTimelineSplitDialog(item) {
4105
4310
  await api(`/api/timeline/${item.id}/split`, { method: "POST", body: { parts: [
4106
4311
  { name: form.get("firstName"), description: form.get("firstDescription") },
4107
4312
  { name: form.get("secondName"), description: form.get("secondDescription") }
4108
- ] } });
4313
+ ], expectedVersionNo: item.versionNo } });
4109
4314
  await renderTimeline();
4110
4315
  }, "原证据同步保留");
4111
4316
  }
@@ -4118,7 +4323,7 @@ async function openRelationshipDialog(item, options = {}) {
4118
4323
  const defaultTo = characterOptions.find(([id]) => id !== defaultFrom)?.[0] ?? characterOptions[1][0];
4119
4324
  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
4325
  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" } });
4326
+ 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
4327
  await refreshRelationshipSurfaces(options.characterId ?? null);
4123
4328
  }, item ? "关系档案" : "人工确认关系");
4124
4329
  }
@@ -4421,7 +4626,7 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
4421
4626
  async function showVersions() {
4422
4627
  if (!state.chapter) return;
4423
4628
  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("");
4629
+ $("#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
4630
  $("#versions-list").querySelectorAll("[data-restore-version]").forEach((button) => button.addEventListener("click", async () => {
4426
4631
  if (!window.confirm(`将版本 v${button.dataset.restoreVersion} 恢复为一个新的保存版本?`)) return;
4427
4632
  state.chapter = await api(`/api/chapters/${state.chapter.id}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.restoreVersion) } });
@@ -4724,7 +4929,7 @@ $("#member-invite-form").addEventListener("submit", async (event) => {
4724
4929
  const members = await api(`/api/works/${encodeURIComponent(work.id)}/members`, { method: "POST", body: { userId, role } });
4725
4930
  renderMembers(members);
4726
4931
  await fillMemberCandidates(members);
4727
- toast(role === "viewer" ? "仅查看成员已邀请" : "协作者已邀请");
4932
+ toast(role === "viewer" ? "仅查看成员已邀请" : role === "settings-editor" ? "设定编辑已邀请" : "完整协作者已邀请");
4728
4933
  } catch (error) { toast(error.message, "error"); }
4729
4934
  });
4730
4935
  $("#platform-new-provider").addEventListener("click", () => openProviderDialog());
@@ -4873,22 +5078,35 @@ $("#ai-mention-menu").addEventListener("click", (event) => {
4873
5078
  if (button) selectAiMention(button);
4874
5079
  });
4875
5080
  $("#import-file").addEventListener("change", async (event) => {
4876
- if (!state.work || !event.target.files[0]) return;
4877
- if (!confirmDiscardChanges("导入会替换当前作品目录,未保存修改将丢失。是否继续?")) {
5081
+ const file = event.target.files[0];
5082
+ if (!state.work || !file) return;
5083
+ if (!canEditProse()) {
5084
+ event.target.value = "";
5085
+ toast("当前权限只能编辑设定资料,不能导入正文", "error");
5086
+ return;
5087
+ }
5088
+ const mode = await chooseExistingWorkImportMode(file);
5089
+ if (!mode) {
4878
5090
  event.target.value = "";
4879
5091
  return;
4880
5092
  }
5093
+ cancelChapterAutoSave();
4881
5094
  const body = new FormData();
4882
- body.append("file", event.target.files[0]);
5095
+ body.append("file", file);
5096
+ body.append("mode", mode);
5097
+ body.append("expectedVersionNo", String(state.work.versionNo));
4883
5098
  try {
4884
5099
  const result = await api(`/api/works/${state.work.id}/import`, { method: "POST", body });
4885
- setSaveState("已导入");
5100
+ setSaveState(mode === "append" ? "已追加" : "已覆盖");
4886
5101
  state.work = result.tree;
4887
5102
  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"); }
5103
+ const completion = mode === "append" ? "正文追加完成" : "正文覆盖完成";
5104
+ toast(result.warnings.length ? `${completion}:${result.warnings.join(";")}` : completion);
5105
+ if (result.firstImportedChapterId) await selectChapter(result.firstImportedChapterId);
5106
+ } catch (error) {
5107
+ toast(error.message, "error");
5108
+ if (state.dirty) scheduleChapterAutoSave();
5109
+ }
4892
5110
  event.target.value = "";
4893
5111
  });
4894
5112
  $("#new-import-file").addEventListener("change", async (event) => {
@@ -9,7 +9,7 @@
9
9
  <script src="/theme-init.js?v=20260720-route-skeleton"></script>
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
- <link rel="stylesheet" href="/styles.css?v=20260722-entity-editor-page">
12
+ <link rel="stylesheet" href="/styles.css?v=20260722-settings-import-mode">
13
13
  </head>
14
14
  <body class="auth-pending">
15
15
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -316,6 +316,29 @@
316
316
  </form>
317
317
  </dialog>
318
318
 
319
+ <dialog id="import-mode-dialog" class="dialog import-mode-dialog" aria-labelledby="import-mode-dialog-title" aria-describedby="import-mode-dialog-description" data-testid="import-mode-dialog">
320
+ <form method="dialog">
321
+ <div class="dialog-header">
322
+ <div><span class="eyebrow">已有项目</span><h2 id="import-mode-dialog-title">选择正文导入方式</h2></div>
323
+ <button class="dialog-close" value="cancel" aria-label="取消导入" type="submit">×</button>
324
+ </div>
325
+ <div class="import-mode-body">
326
+ <p id="import-mode-dialog-description">即将把所选文件导入当前作品。请选择追加还是覆盖正文。</p>
327
+ <p id="import-mode-file-summary" class="import-mode-file-summary"></p>
328
+ <p id="import-mode-unsaved-warning" class="import-mode-unsaved-warning hidden" role="alert">当前章节有未保存修改,继续导入会丢失这些本地修改。</p>
329
+ <div class="import-mode-options" aria-label="导入方式说明">
330
+ <section><strong>追加正文</strong><span>保留现有卷章,把新文件解析出的卷章添加到目录末尾。</span></section>
331
+ <section><strong>覆盖正文</strong><span>删除现有正文目录后写入新文件;导入前会保留可恢复的版本快照。</span></section>
332
+ </div>
333
+ </div>
334
+ <div class="dialog-actions">
335
+ <button class="ghost-button" value="cancel" type="submit">取消</button>
336
+ <button id="import-mode-append" class="ghost-button" value="append" type="submit">追加正文</button>
337
+ <button id="import-mode-overwrite" class="primary-button" value="overwrite" type="submit">覆盖正文</button>
338
+ </div>
339
+ </form>
340
+ </dialog>
341
+
319
342
  <section id="entity-editor-view" class="entity-editor-view hidden" aria-label="全屏资料编辑">
320
343
  <form id="setting-editor-form" class="entity-editor-page hidden" aria-labelledby="setting-editor-title">
321
344
  <header class="entity-editor-header">
@@ -508,7 +531,7 @@
508
531
  <dialog id="members-dialog" class="dialog wide-dialog" aria-labelledby="members-dialog-title">
509
532
  <div class="dialog-header"><div><span id="members-dialog-eyebrow" class="eyebrow">作品权限</span><h2 id="members-dialog-title">可访问人</h2></div><button id="members-dialog-close" class="dialog-close" aria-label="关闭" type="button">×</button></div>
510
533
  <div class="access-dialog-body">
511
- <form id="member-invite-form" class="member-invite-form"><label>邀请已注册用户<select id="member-user-select" required></select></label><label>访问权限<select id="member-role-select" required><option value="viewer">仅查看</option><option value="editor">可编辑</option></select></label><button class="primary-button" type="submit">发送邀请</button></form>
534
+ <form id="member-invite-form" class="member-invite-form"><label>邀请已注册用户<select id="member-user-select" required></select></label><label>访问权限<select id="member-role-select" required><option value="viewer">仅查看</option><option value="settings-editor">仅编辑设定</option><option value="editor">编辑正文与设定</option></select></label><button class="primary-button" type="submit">发送邀请</button></form>
512
535
  <div id="members-list" class="access-list"></div>
513
536
  </div>
514
537
  </dialog>
@@ -575,6 +598,6 @@
575
598
  </dialog>
576
599
 
577
600
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
578
- <script type="module" src="/app.js?v=20260722-pagination-entity-editor-page"></script>
601
+ <script type="module" src="/app.js?v=20260722-settings-import-version-lock"></script>
579
602
  </body>
580
603
  </html>