@musnows/scriverse 0.3.7 → 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.
@@ -1,6 +1,6 @@
1
1
  import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260721-release-0.3.6";
2
2
  import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
- import { renderMarkdown } from "/markdown.js?v=20260721-character-attachments";
3
+ import { renderMarkdown } from "/markdown.js?v=20260722-inline-code";
4
4
  import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
5
5
  import { shouldShowAiQuickActions } from "/ai-conversation.js?v=20260713-quick-actions";
6
6
  import { calculateLineNumberRowHeight, calculateLineNumberRowTop, calculateLineNumberTextOffset, calculateLineNumberTop } from "/line-number-layout.js?v=20260713-row-box-alignment";
@@ -14,7 +14,7 @@ import { THEME_STORAGE_KEY, nextTheme, normalizeTheme, themeToggleLabel } from "
14
14
  import { buildCharacterDetails, buildCharacterState, characterStateEntries, normalizeCharacterDetails, normalizeCharacterSections } from "/character-profile.js?v=20260713-character-editor";
15
15
  import { characterVersionSourceLabel, describeCharacterVersionChanges } from "/character-version.js?v=20260713-character-history";
16
16
  import { VERSIONED_ENTITY_LABELS, entityVersionSnapshotSummary, entityVersionSourceLabel } from "/entity-version.js?v=20260714-all-knowledge-history";
17
- import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260714-refresh-restore";
17
+ import { parsePageRoute, serializePageRoute } from "/page-route.js?v=20260722-entity-editor-page";
18
18
  import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelationshipKeywords } from "/relationship-keywords.js?v=20260720-relationship-keyword-chips";
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";
@@ -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
  }
@@ -352,6 +370,10 @@ function replacePageRoute(route) {
352
370
 
353
371
  function currentPageRoute() {
354
372
  const workId = state.work?.id ?? null;
373
+ if (!$("#entity-editor-view").classList.contains("hidden") && workId && entityEditorType) {
374
+ const entityId = entityEditorType === "setting" ? settingEditorItem?.id : characterEditorItem?.id;
375
+ return { view: "entity-editor", workId, entity: entityEditorType, entityId: entityId ?? null };
376
+ }
355
377
  if (!$("#settings-hub-view").classList.contains("hidden")) return { view: "settings", workId, ...settingsRouteContext() };
356
378
  if (!$("#platform-ai-view").classList.contains("hidden")) return { view: "platform-ai", workId, ...settingsRouteContext() };
357
379
  if (!$("#shelf-view").classList.contains("hidden")) return { view: "shelf" };
@@ -461,6 +483,9 @@ const chapterAutoSaveDelay = 800;
461
483
  let aiMentionMatch = null;
462
484
  let aiMentionRange = null;
463
485
  let settingsReturnContext = null;
486
+ let entityEditorType = null;
487
+ let entityEditorDirty = false;
488
+ let settingEditorItem = null;
464
489
  let characterEditorItem = null;
465
490
  let characterEditorVersions = [];
466
491
  let characterEditorRelationships = [];
@@ -468,8 +493,49 @@ let characterEditorRelationshipsLoading = false;
468
493
  let characterEditorSections = [];
469
494
  let characterSectionPreviewTimer = null;
470
495
  let characterSectionPendingAttachments = [];
496
+ let characterSectionEditorDirty = false;
471
497
  let entityHistoryContext = null;
472
498
 
499
+ function showEntityEditorPage(type) {
500
+ entityEditorType = type;
501
+ entityEditorDirty = false;
502
+ characterSectionEditorDirty = false;
503
+ $("#entity-editor-view").classList.remove("hidden");
504
+ $("#setting-editor-form").classList.toggle("hidden", type !== "setting");
505
+ $("#character-editor-form").classList.toggle("hidden", type !== "character");
506
+ $("#character-section-editor-view").classList.add("hidden");
507
+ $("#app").inert = true;
508
+ document.body.classList.add("entity-editor-open");
509
+ replacePageRoute(currentPageRoute());
510
+ }
511
+
512
+ function markEntityEditorDirty() {
513
+ if (entityEditorType && canEditWork()) entityEditorDirty = true;
514
+ }
515
+
516
+ function confirmEntityEditorDiscard(message) {
517
+ if (!entityEditorDirty) return true;
518
+ return window.confirm(message ?? "当前资料有未保存修改,返回列表将丢弃这些修改。是否继续?");
519
+ }
520
+
521
+ async function closeEntityEditor({ force = false } = {}) {
522
+ if (!$("#character-section-editor-view").classList.contains("hidden")) return closeCharacterSectionEditor({ force });
523
+ if (!force && !confirmEntityEditorDiscard()) return false;
524
+ await discardPendingCharacterAttachments();
525
+ const module = entityEditorType === "setting" ? "settings" : "characters";
526
+ entityEditorType = null;
527
+ entityEditorDirty = false;
528
+ settingEditorItem = null;
529
+ characterEditorItem = null;
530
+ $("#entity-editor-view").classList.add("hidden");
531
+ $("#setting-editor-form").classList.add("hidden");
532
+ $("#character-editor-form").classList.add("hidden");
533
+ $("#app").inert = false;
534
+ document.body.classList.remove("entity-editor-open");
535
+ await showModule(module);
536
+ return true;
537
+ }
538
+
473
539
  function setModuleNavExpanded(expanded) {
474
540
  moduleNavExpanded = expanded;
475
541
  $("#module-more-button .nav-label").textContent = expanded ? "收起" : "更多";
@@ -1016,7 +1082,7 @@ function renderAiConversationHistory() {
1016
1082
  async function loadAiConversations(openLatest = true) {
1017
1083
  const workId = state.work?.id;
1018
1084
  if (!workId) return;
1019
- const conversations = await api(`/api/works/${workId}/ai-conversations`);
1085
+ const conversations = (await apiPage(`/api/works/${workId}/ai-conversations`)).items;
1020
1086
  if (state.work?.id !== workId) return;
1021
1087
  state.aiConversations = conversations;
1022
1088
  loadedAiConversationsWorkId = workId;
@@ -1045,7 +1111,7 @@ async function ensureAiConversationsLoaded() {
1045
1111
  }
1046
1112
 
1047
1113
  async function openAiConversation(conversationId, hideHistory = true) {
1048
- const conversation = await api(`/api/ai-conversations/${conversationId}`);
1114
+ const conversation = await api(`/api/ai-conversations/${conversationId}?page=1&limit=100`);
1049
1115
  state.aiConversationId = conversation.id;
1050
1116
  state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
1051
1117
  $("#ai-conversation-title").textContent = conversation.title;
@@ -1376,15 +1442,75 @@ applyTypographySettings(typographySettings);
1376
1442
  applyColorTheme(currentColorTheme());
1377
1443
  applyPanelLayout();
1378
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
+
1379
1504
  async function api(path, options = {}) {
1380
1505
  const method = String(options.method ?? "GET").toUpperCase();
1506
+ const body = attachOptimisticVersion(path, method, options.body);
1381
1507
  const headers = { ...(options.headers ?? {}) };
1382
1508
  if (state.csrfToken && !["GET", "HEAD", "OPTIONS"].includes(method)) headers["X-CSRF-Token"] = state.csrfToken;
1383
- if (!(options.body instanceof FormData)) headers["Content-Type"] = "application/json";
1384
- 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 } : {
1385
1511
  ...options,
1386
1512
  headers,
1387
- body: options.body && typeof options.body !== "string" ? JSON.stringify(options.body) : options.body
1513
+ body: body && typeof body !== "string" ? JSON.stringify(body) : body
1388
1514
  });
1389
1515
  if (!response.ok) {
1390
1516
  const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
@@ -1396,6 +1522,24 @@ async function api(path, options = {}) {
1396
1522
  return payload.data;
1397
1523
  }
1398
1524
 
1525
+ async function apiPage(path, page = 1, limit = 50) {
1526
+ const separator = path.includes("?") ? "&" : "?";
1527
+ const result = await api(`${path}${separator}page=${page}&limit=${limit}`);
1528
+ if (Array.isArray(result)) return { items: result, page, limit, hasMore: false, nextPage: null };
1529
+ return result;
1530
+ }
1531
+
1532
+ async function apiAllPages(path, limit = 100) {
1533
+ const items = [];
1534
+ let page = 1;
1535
+ while (true) {
1536
+ const result = await apiPage(path, page, limit);
1537
+ items.push(...(result.items ?? []));
1538
+ if (!result.hasMore || !result.nextPage) return items;
1539
+ page = result.nextPage;
1540
+ }
1541
+ }
1542
+
1399
1543
  function selectAuthMode(mode) {
1400
1544
  const registerTab = $("#auth-register-tab");
1401
1545
  const login = mode === "login" || registerTab.disabled;
@@ -1550,7 +1694,7 @@ function cancelChapterAutoSave() {
1550
1694
  }
1551
1695
 
1552
1696
  function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
1553
- if (!state.chapter || !canEditWork()) return;
1697
+ if (!state.chapter || !canEditProse()) return;
1554
1698
  cancelChapterAutoSave();
1555
1699
  setSaveState("等待自动保存", true);
1556
1700
  chapterAutoSaveTimer = setTimeout(() => {
@@ -1560,7 +1704,7 @@ function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
1560
1704
  }
1561
1705
 
1562
1706
  async function persistChapter({ automatic = false } = {}) {
1563
- if (!canEditWork()) return null;
1707
+ if (!canEditProse()) return null;
1564
1708
  if (!state.chapter) {
1565
1709
  if (!automatic) toast("请先选择章节", "error");
1566
1710
  return null;
@@ -1628,13 +1772,26 @@ function confirmDiscardChanges(message = "当前章节有未保存修改,继
1628
1772
  return window.confirm(message);
1629
1773
  }
1630
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
+
1631
1788
  function updateDocumentTitle(work = null) {
1632
1789
  const workTitle = String(work?.title ?? "").trim();
1633
1790
  document.title = workTitle ? `${workTitle} · 叙界` : platformDocumentTitle;
1634
1791
  }
1635
1792
 
1636
1793
  async function loadWorks(preferredId) {
1637
- state.works = await api("/api/works");
1794
+ state.works = (await apiPage("/api/works")).items;
1638
1795
  if (preferredId) {
1639
1796
  await selectWork(preferredId);
1640
1797
  return;
@@ -1658,7 +1815,7 @@ async function initializePage() {
1658
1815
  return;
1659
1816
  }
1660
1817
  const route = parsePageRoute(window.location.hash);
1661
- state.works = await api("/api/works");
1818
+ state.works = (await apiPage("/api/works")).items;
1662
1819
  try {
1663
1820
  if (route.view === "shelf") {
1664
1821
  showShelf();
@@ -1672,16 +1829,30 @@ async function initializePage() {
1672
1829
  }
1673
1830
 
1674
1831
  if (requestedWork) {
1675
- state.module = route.view === "module" ? route.module : "editor";
1832
+ state.module = route.view === "module"
1833
+ ? route.module
1834
+ : route.view === "entity-editor"
1835
+ ? (route.entity === "setting" ? "settings" : "characters")
1836
+ : "editor";
1676
1837
  await selectWork(requestedWork.id, route.view === "editor" ? route.chapterId : null);
1677
1838
  }
1678
1839
 
1679
1840
  if (route.view === "editor") {
1680
- const chapterExists = state.work?.volumes.some((volume) => volume.chapters.some((chapter) => chapter.id === route.chapterId));
1681
- if (route.chapterId && chapterExists && state.chapter?.id !== route.chapterId) await selectChapter(route.chapterId);
1841
+ if (route.chapterId && state.chapter?.id !== route.chapterId) await selectChapter(route.chapterId);
1682
1842
  return;
1683
1843
  }
1684
1844
  if (route.view === "module") return;
1845
+ if (route.view === "entity-editor") {
1846
+ const records = route.entity === "setting" ? state.settings : state.characters;
1847
+ const item = route.entityId ? records.find((record) => record.id === route.entityId) : null;
1848
+ if (route.entityId && !item) {
1849
+ toast(route.entity === "setting" ? "未找到要编辑的设定" : "未找到要编辑的角色", "error");
1850
+ return;
1851
+ }
1852
+ if (route.entity === "setting") openSettingEditor(item);
1853
+ else await openCharacterEditor(item);
1854
+ return;
1855
+ }
1685
1856
  if (route.view === "welcome") {
1686
1857
  showWelcome(true);
1687
1858
  return;
@@ -1784,7 +1955,7 @@ async function openUsersDialog() {
1784
1955
  }
1785
1956
  $("#users-list").innerHTML = '<p class="empty-state">正在读取用户……</p>';
1786
1957
  $("#users-dialog").showModal();
1787
- try { renderUsers(await api("/api/users")); }
1958
+ try { renderUsers((await apiPage("/api/users")).items); }
1788
1959
  catch (error) { $("#users-dialog").close(); toast(error.message, "error"); }
1789
1960
  }
1790
1961
 
@@ -1806,8 +1977,8 @@ function renderMembers(members) {
1806
1977
  const work = memberDialogWork ?? state.work;
1807
1978
  const canManage = ["admin", "owner"].includes(String(work?.accessRole));
1808
1979
  $("#members-list").innerHTML = members.map((member) => `<article class="access-row">
1809
- <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>
1810
- ${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>`}
1811
1982
  ${member.role === "owner" || !canManage ? "<span></span>" : `<button type="button" data-remove-member="${esc(member.userId)}">移除</button>`}
1812
1983
  </article>`).join("");
1813
1984
  bindUserAvatarFallbacks($("#members-list"));
@@ -1921,13 +2092,13 @@ async function openSearchResult(result) {
1921
2092
  if (result.type === "character") {
1922
2093
  await showModule("characters");
1923
2094
  const character = state.characters.find((item) => item.id === result.id);
1924
- if (character) openCharacterDialog(character);
2095
+ if (character) openCharacterEditor(character);
1925
2096
  return;
1926
2097
  }
1927
2098
  if (result.type === "setting") {
1928
2099
  await showModule("settings");
1929
2100
  const setting = await api(`/api/settings/${encodeURIComponent(result.id)}`);
1930
- openSettingDialog(setting);
2101
+ openSettingEditor(setting);
1931
2102
  return;
1932
2103
  }
1933
2104
  if (result.type === "race") {
@@ -2010,9 +2181,9 @@ function renderShelf() {
2010
2181
  <span class="book-cover-fallback">${esc(Array.from(work.title)[0] ?? "书")}</span>
2011
2182
  ${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面">` : ""}
2012
2183
  </span>
2013
- <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>
2014
2185
  </button>
2015
- ${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>` : ""}
2016
2187
  </article>`).join("")}
2017
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>`;
2018
2189
  shelf.querySelectorAll("[data-open-work]").forEach((button) => button.addEventListener("click", () => selectWork(button.dataset.openWork)));
@@ -2026,7 +2197,7 @@ function renderShelf() {
2026
2197
  async function selectWork(workId, preferredChapterId = null) {
2027
2198
  const discarding = state.work?.id !== workId && state.dirty;
2028
2199
  if (discarding && !confirmDiscardChanges()) return false;
2029
- const nextWork = await api(`/api/works/${workId}`);
2200
+ const nextWork = await api(`/api/works/${workId}?page=1&limit=100`);
2030
2201
  if (state.work?.id !== nextWork.id) {
2031
2202
  loadedAiModelsWorkId = null;
2032
2203
  loadedAiReferencesWorkId = null;
@@ -2067,7 +2238,8 @@ async function selectWork(workId, preferredChapterId = null) {
2067
2238
  renderTree();
2068
2239
  const chapters = state.work.volumes.flatMap((volume) => volume.chapters);
2069
2240
  const targetChapter = chapters.find((chapter) => chapter.id === preferredChapterId) ?? chapters[0];
2070
- if (state.module === "editor" && targetChapter) await selectChapter(targetChapter.id);
2241
+ if (state.module === "editor" && preferredChapterId) await selectChapter(preferredChapterId);
2242
+ else if (state.module === "editor" && targetChapter) await selectChapter(targetChapter.id);
2071
2243
  else if (state.module === "editor") showWelcome(true);
2072
2244
  else await showModule(state.module);
2073
2245
  return true;
@@ -2096,7 +2268,7 @@ function renderTree() {
2096
2268
  renderTree();
2097
2269
  });
2098
2270
  button.addEventListener("contextmenu", (event) => {
2099
- if (!canEditWork()) return;
2271
+ if (!canEditProse()) return;
2100
2272
  event.preventDefault();
2101
2273
  openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeToggle));
2102
2274
  });
@@ -2104,7 +2276,7 @@ function renderTree() {
2104
2276
  $("#novel-tree").querySelectorAll("[data-chapter-id]").forEach((button) => {
2105
2277
  button.addEventListener("click", () => selectChapter(button.dataset.chapterId));
2106
2278
  button.addEventListener("contextmenu", (event) => {
2107
- if (!canEditWork()) return;
2279
+ if (!canEditProse()) return;
2108
2280
  event.preventDefault();
2109
2281
  openChapterTypeMenu(button.dataset.chapterId, event.clientX, event.clientY);
2110
2282
  });
@@ -2154,7 +2326,7 @@ async function selectChapter(chapterId) {
2154
2326
  scheduleChapterLineNumbers();
2155
2327
  $("#chapter-insight").classList.add("hidden");
2156
2328
  updateChapterStats();
2157
- if (!canEditWork()) setSaveState("仅查看");
2329
+ if (!canEditProse()) setSaveState(canEditWork() ? "正文只读" : "仅查看");
2158
2330
  else if (spacingChanged) scheduleChapterAutoSave(120);
2159
2331
  else setSaveState("已保存");
2160
2332
  renderTree();
@@ -2212,7 +2384,7 @@ const moduleMeta = {
2212
2384
 
2213
2385
  async function showModule(module) {
2214
2386
  if (!state.work) return showWelcome();
2215
- if (!canEditWork() && ["tasks", "ai-settings"].includes(module)) module = "editor";
2387
+ if (!canEditProse() && ["tasks", "ai-settings"].includes(module)) module = "editor";
2216
2388
  if (module !== "editor" && state.module === "editor" && !confirmDiscardChanges()) return;
2217
2389
  if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
2218
2390
  state.module = module;
@@ -2234,7 +2406,7 @@ async function showModule(module) {
2234
2406
  $("#module-title").textContent = meta[1];
2235
2407
  $("#module-description").textContent = meta[2];
2236
2408
  $("#module-create-button").textContent = meta[3];
2237
- $("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditWork());
2409
+ $("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditModule(module));
2238
2410
  $("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
2239
2411
  try {
2240
2412
  if (module === "settings") await renderSettings();
@@ -2323,8 +2495,38 @@ function bindEntityHistoryButtons(refresh) {
2323
2495
  }));
2324
2496
  }
2325
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
+
2326
2528
  async function renderSettings() {
2327
- const records = await api(`/api/works/${state.work.id}/settings`);
2529
+ const records = (await apiPage(`/api/works/${state.work.id}/settings`)).items;
2328
2530
  state.settings = records;
2329
2531
  $("#module-content").innerHTML = records.length ? `<div class="card-grid">${records.map((item) => `
2330
2532
  <article class="record-card"><small>${esc(item.category)} · ${item.locked ? "已锁定" : esc(item.status)}</small>
@@ -2336,15 +2538,15 @@ async function renderSettings() {
2336
2538
  await renderSettings();
2337
2539
  await loadAiReferences();
2338
2540
  }));
2339
- $("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => openSettingDialog(records.find((item) => item.id === button.dataset.editSetting))));
2541
+ $("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => openSettingEditor(records.find((item) => item.id === button.dataset.editSetting))));
2340
2542
  bindEntityHistoryButtons(async () => { await renderSettings(); await loadAiReferences(); });
2341
2543
  }
2342
2544
 
2343
2545
  async function renderCharacters() {
2344
2546
  [state.characters, state.races, state.organizations] = await Promise.all([
2345
- api(`/api/works/${state.work.id}/characters`),
2346
- api(`/api/works/${state.work.id}/races`),
2347
- api(`/api/works/${state.work.id}/organizations`)
2547
+ apiPage(`/api/works/${state.work.id}/characters`).then((result) => result.items),
2548
+ apiAllPages(`/api/works/${state.work.id}/races`),
2549
+ apiAllPages(`/api/works/${state.work.id}/organizations`)
2348
2550
  ]);
2349
2551
  const auditPanel = `<section class="character-audit-panel"><div><strong>角色身份确认</strong><small>让 AI 查询角色档案并搜索正文,找出可能被误建成两个档案的同一角色。AI 只提交审核建议,不会自动合并。</small></div><button id="create-character-audit-task" class="ghost-button" type="button" ${state.characters.length < 2 ? "disabled" : ""}>AI 角色查重</button></section>`;
2350
2552
  $("#module-content").innerHTML = auditPanel + (state.characters.length ? `<div class="card-grid">${state.characters.map((item) => {
@@ -2358,7 +2560,7 @@ async function renderCharacters() {
2358
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>
2359
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>`}
2360
2562
  ${item.profileSectionCount ? `<small class="character-section-count">${item.profileSectionCount} 个设定章节</small>` : ""}
2361
- <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>`;
2362
2564
  }).join("")}</div>`
2363
2565
  : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
2364
2566
  $("#create-character-audit-task")?.addEventListener("click", async () => {
@@ -2374,7 +2576,7 @@ async function renderCharacters() {
2374
2576
  }
2375
2577
  });
2376
2578
  $("#module-content").querySelectorAll("[data-open-character]").forEach((card) => {
2377
- const open = () => openCharacterDialog(state.characters.find((item) => item.id === card.dataset.openCharacter));
2579
+ const open = () => openCharacterEditor(state.characters.find((item) => item.id === card.dataset.openCharacter));
2378
2580
  card.addEventListener("click", (event) => { if (!event.target.closest("button")) open(); });
2379
2581
  card.addEventListener("keydown", (event) => {
2380
2582
  if (event.key !== "Enter" && event.key !== " ") return;
@@ -2382,13 +2584,41 @@ async function renderCharacters() {
2382
2584
  open();
2383
2585
  });
2384
2586
  });
2385
- $("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterDialog(state.characters.find((item) => item.id === button.dataset.editCharacter))));
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
+ }));
2386
2616
  }
2387
2617
 
2388
2618
  async function renderRaces() {
2389
2619
  [state.races, state.characters] = await Promise.all([
2390
- api(`/api/works/${state.work.id}/races`),
2391
- api(`/api/works/${state.work.id}/characters`)
2620
+ apiAllPages(`/api/works/${state.work.id}/races`),
2621
+ apiAllPages(`/api/works/${state.work.id}/characters`)
2392
2622
  ]);
2393
2623
  const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
2394
2624
  <summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
@@ -2398,36 +2628,84 @@ async function renderRaces() {
2398
2628
  <p>${esc(item.description || "尚未填写种族简介")}</p>
2399
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>
2400
2630
  <p class="race-members">直接角色:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
2401
- <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>
2402
2632
  </article>
2403
2633
  ${item.children.length ? `<div class="race-tree-children">${item.children.map(renderRaceNode).join("")}</div>` : ""}
2404
2634
  </div>
2405
2635
  </details>`;
2406
2636
  $("#module-content").innerHTML = state.races.length ? `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>` : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
2407
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
+ }));
2408
2662
  bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
2409
2663
  }
2410
2664
 
2411
2665
  async function renderOrganizations() {
2412
2666
  [state.organizations, state.characters] = await Promise.all([
2413
- api(`/api/works/${state.work.id}/organizations`),
2414
- api(`/api/works/${state.work.id}/characters`)
2667
+ apiAllPages(`/api/works/${state.work.id}/organizations`),
2668
+ apiAllPages(`/api/works/${state.work.id}/characters`)
2415
2669
  ]);
2416
2670
  $("#module-content").innerHTML = state.organizations.length ? `<div class="card-grid organization-grid">${state.organizations.map((item) => `
2417
2671
  <article class="record-card organization-card"><small>${item.memberIds.length} 位成员 · ${item.settings.length} 条设定</small>
2418
2672
  <h3>${esc(item.name)}</h3><p>${esc(item.description || "尚未填写组织简介")}</p>
2419
2673
  <div class="organization-settings">${item.settings.map((setting) => `<span class="pill">${esc(setting)}</span>`).join("") || '<span class="pill">暂无组织设定</span>'}</div>
2420
2674
  <p class="organization-members">成员:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
2421
- <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>
2422
2676
  </article>`).join("")}</div>` : emptyModule("还没有组织", "创建国家、机构、阵营或团队,并维护组织设定与成员。");
2423
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
+ }));
2424
2702
  bindEntityHistoryButtons(async () => { await renderOrganizations(); await loadAiReferences(); });
2425
2703
  }
2426
2704
 
2427
2705
  async function renderTimeline() {
2428
2706
  const [events, tracks] = await Promise.all([
2429
- api(`/api/works/${state.work.id}/timeline`),
2430
- api(`/api/works/${state.work.id}/timeline-tracks`)
2707
+ apiPage(`/api/works/${state.work.id}/timeline`).then((result) => result.items),
2708
+ apiAllPages(`/api/works/${state.work.id}/timeline-tracks`)
2431
2709
  ]);
2432
2710
  state.timelineTracks = tracks;
2433
2711
  const lanes = [...tracks, { id: "", name: "未分组时间轴", description: "尚未归入独立大事件的时间节点。", sortOrder: Number.MAX_SAFE_INTEGER }];
@@ -2446,7 +2724,12 @@ async function renderTimeline() {
2446
2724
  const eventIds = [...$("#module-content").querySelectorAll("[data-event-select]:checked")].map((input) => input.dataset.eventSelect);
2447
2725
  if (eventIds.length < 2) return toast("请至少选择两个时间事件", "error");
2448
2726
  openDialog("合并时间事件", field("name", "合并后的事件名称") + field("description", "合并说明(留空则拼接原说明)", "textarea"), async (form) => {
2449
- 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
+ } });
2450
2733
  await renderTimeline();
2451
2734
  }, "保留参与者与证据");
2452
2735
  });
@@ -2455,8 +2738,8 @@ async function renderTimeline() {
2455
2738
  async function renderOutlines() {
2456
2739
  const currentChapterId = state.chapter?.id;
2457
2740
  const [outlines, foreshadows] = await Promise.all([
2458
- api(`/api/works/${state.work.id}/outlines`),
2459
- api(`/api/works/${state.work.id}/foreshadows?status=all${currentChapterId ? `&currentChapterId=${encodeURIComponent(currentChapterId)}` : ""}`)
2741
+ apiPage(`/api/works/${state.work.id}/outlines`).then((result) => result.items),
2742
+ apiPage(`/api/works/${state.work.id}/foreshadows?status=all${currentChapterId ? `&currentChapterId=${encodeURIComponent(currentChapterId)}` : ""}`).then((result) => result.items)
2460
2743
  ]);
2461
2744
  const unresolved = foreshadows.filter((item) => item.unresolved);
2462
2745
  const overdue = unresolved.filter((item) => item.overdue);
@@ -2484,8 +2767,8 @@ async function renderOutlines() {
2484
2767
  }
2485
2768
 
2486
2769
  async function renderRelationships() {
2487
- state.characters = await api(`/api/works/${state.work.id}/characters`);
2488
- const relationships = await api(`/api/works/${state.work.id}/relationships`);
2770
+ state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
2771
+ const relationships = (await apiPage(`/api/works/${state.work.id}/relationships`)).items;
2489
2772
  const nameOf = (id) => state.characters.find((item) => item.id === id)?.name ?? "未知角色";
2490
2773
  state.galaxy?.destroy();
2491
2774
  state.relationshipExpandedMap?.destroy?.();
@@ -2516,8 +2799,8 @@ async function renderRelationships() {
2516
2799
 
2517
2800
  async function renderReviews() {
2518
2801
  const [reviews, characters] = await Promise.all([
2519
- api(`/api/works/${state.work.id}/reviews`),
2520
- api(`/api/works/${state.work.id}/characters?includeMerged=1`)
2802
+ apiPage(`/api/works/${state.work.id}/reviews`).then((result) => result.items),
2803
+ apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`)
2521
2804
  ]);
2522
2805
  const characterById = new Map(characters.map((character) => [character.id, character]));
2523
2806
  const duplicateCard = (item) => {
@@ -2577,7 +2860,7 @@ async function renderReviews() {
2577
2860
 
2578
2861
  async function renderTasks() {
2579
2862
  const [tasks, settings] = await Promise.all([
2580
- api(`/api/works/${state.work.id}/tasks`),
2863
+ apiPage(`/api/works/${state.work.id}/tasks`).then((result) => result.items),
2581
2864
  api(`/api/works/${state.work.id}/ai-settings`)
2582
2865
  ]);
2583
2866
  const pendingCount = tasks.filter((item) => item.status === "pending").length;
@@ -3012,8 +3295,8 @@ async function loadAiReferences() {
3012
3295
  const workId = state.work?.id;
3013
3296
  if (!workId) return;
3014
3297
  const [characters, settings] = await Promise.all([
3015
- api(`/api/works/${workId}/characters`),
3016
- api(`/api/works/${workId}/settings`)
3298
+ apiAllPages(`/api/works/${workId}/characters`),
3299
+ apiAllPages(`/api/works/${workId}/settings`)
3017
3300
  ]);
3018
3301
  if (state.work?.id !== workId) return;
3019
3302
  state.characters = characters;
@@ -3230,7 +3513,7 @@ function bindWorkCoverControls(work) {
3230
3513
  $("#work-cover-remove")?.addEventListener("click", async () => {
3231
3514
  try {
3232
3515
  await api(`/api/works/${work.id}/cover`, { method: "DELETE" });
3233
- state.works = await api("/api/works");
3516
+ state.works = (await apiPage("/api/works")).items;
3234
3517
  const updated = state.works.find((item) => item.id === work.id) ?? { ...work, coverUrl: null };
3235
3518
  Object.assign(work, updated);
3236
3519
  const coverField = $("#dialog-fields")?.querySelector(".work-cover-field");
@@ -3257,7 +3540,7 @@ function openWorkSettingsDialog(work) {
3257
3540
  workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + accessField,
3258
3541
  async (form) => {
3259
3542
  await api(`/api/works/${work.id}`, { method: "PATCH", body: { title: form.get("title"), author: form.get("author"), description: form.get("description") } });
3260
- state.works = await api("/api/works");
3543
+ state.works = (await apiPage("/api/works")).items;
3261
3544
  const updated = state.works.find((item) => item.id === work.id);
3262
3545
  if (updated) Object.assign(work, updated);
3263
3546
  if (state.work?.id === work.id) {
@@ -3280,6 +3563,7 @@ function openWorkSettingsDialog(work) {
3280
3563
 
3281
3564
  async function openChapterDialog() {
3282
3565
  if (!state.work) return openWorkDialog();
3566
+ if (!canEditProse()) return toast("当前权限只能编辑设定资料,正文为只读", "error");
3283
3567
  if (!state.work.volumes.length) {
3284
3568
  await api(`/api/works/${state.work.id}/volumes`, { method: "POST", body: { title: "正文", kind: "main" } });
3285
3569
  state.work = await api(`/api/works/${state.work.id}`);
@@ -3294,6 +3578,7 @@ async function openChapterDialog() {
3294
3578
 
3295
3579
  function openVolumeDialog(item) {
3296
3580
  if (!state.work) return openWorkDialog();
3581
+ if (!canEditProse()) return toast("当前权限只能编辑设定资料,不能修改分卷", "error");
3297
3582
  const kindOptions = [["main", "正文卷"], ["prequel", "前传"], ["extra", "番外"], ["epilogue", "后记"], ["appendix", "附录"]];
3298
3583
  openDialog(item ? "编辑分卷" : "新建分卷",
3299
3584
  field("title", "分卷名称", "text", item?.title) +
@@ -3314,17 +3599,50 @@ function openVolumeDialog(item) {
3314
3599
  }, "分卷设置");
3315
3600
  }
3316
3601
 
3317
- function openSettingDialog(item) {
3318
- openDialog(item ? "编辑设定" : "新建设定",
3319
- field("title", "标题", "text", item?.title) +
3320
- field("category", "分类", "select", item?.category ?? "世界规则", [["世界规则", "世界规则"], ["历史与年代", "历史与年代"], ["地点与地图", "地点与地图"], ["组织与阵营", "组织与阵营"], ["物种与族群", "物种与族群"], ["科技与物品", "科技与物品"], ["术语与称谓", "术语与称谓"], ["创作约束", "创作约束"]]) +
3321
- field("content", "设定说明", "textarea", item?.content) + field("locked", "锁定为 AI 硬约束", "checkbox", item?.locked),
3322
- async (form) => {
3323
- const body = { title: form.get("title"), category: form.get("category"), content: form.get("content"), locked: form.get("locked") === "on", status: form.get("locked") === "on" ? "confirmed" : (item?.status ?? "draft") };
3602
+ function openSettingEditor(item = null) {
3603
+ settingEditorItem = item;
3604
+ $("#setting-editor-eyebrow").textContent = item ? "人工修正" : "作者事实";
3605
+ $("#setting-editor-title").textContent = item ? `编辑“${item.title}”` : "新建设定";
3606
+ $("#setting-editor-name").value = item?.title ?? "";
3607
+ $("#setting-editor-category").value = item?.category ?? "世界规则";
3608
+ $("#setting-editor-locked").checked = Boolean(item?.locked);
3609
+ $("#setting-editor-body").value = item?.content ?? "";
3610
+ $("#setting-change-note").value = "";
3611
+ $("#setting-change-note-field").classList.toggle("hidden", !item);
3612
+ $("#setting-editor-submit").textContent = item ? "保存新版本" : "创建设定";
3613
+ const viewOnly = !canEditWork();
3614
+ $("#setting-editor-form").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = viewOnly; });
3615
+ $("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
3616
+ $("#setting-editor-submit").classList.toggle("hidden", viewOnly);
3617
+ $("#setting-editor-form").onsubmit = async (event) => {
3618
+ event.preventDefault();
3619
+ if (!canEditWork()) return;
3620
+ const form = new FormData(event.currentTarget);
3621
+ const submit = $("#setting-editor-submit");
3622
+ submit.disabled = true;
3623
+ try {
3624
+ const locked = form.get("locked") === "on";
3625
+ const body = {
3626
+ title: String(form.get("title") ?? "").trim(),
3627
+ category: String(form.get("category") ?? "世界规则"),
3628
+ content: String(form.get("content") ?? ""),
3629
+ locked,
3630
+ status: locked ? "confirmed" : (item?.status ?? "draft"),
3631
+ ...(item ? { changeNote: String(form.get("changeNote") ?? "").trim() } : {})
3632
+ };
3324
3633
  await api(item ? `/api/settings/${item.id}` : `/api/works/${state.work.id}/settings`, { method: item ? "PATCH" : "POST", body });
3325
- await renderSettings();
3634
+ entityEditorDirty = false;
3326
3635
  await loadAiReferences();
3327
- }, item ? "人工修正" : "作者事实");
3636
+ await closeEntityEditor({ force: true });
3637
+ toast(item ? "设定新版本已保存" : "设定已创建");
3638
+ } catch (error) {
3639
+ toast(error.message, "error");
3640
+ } finally {
3641
+ submit.disabled = false;
3642
+ }
3643
+ };
3644
+ showEntityEditorPage("setting");
3645
+ $("#setting-editor-name").focus();
3328
3646
  }
3329
3647
 
3330
3648
  function characterEditorSection(key, title, description, content) {
@@ -3400,8 +3718,8 @@ async function loadCharacterEditorRelationships(characterId) {
3400
3718
  let loaded = false;
3401
3719
  try {
3402
3720
  const [characters, relationships] = await Promise.all([
3403
- api(`/api/works/${workId}/characters`),
3404
- api(`/api/works/${workId}/relationships`)
3721
+ apiAllPages(`/api/works/${workId}/characters`),
3722
+ apiAllPages(`/api/works/${workId}/relationships`)
3405
3723
  ]);
3406
3724
  if (state.work?.id !== workId || characterEditorItem?.id !== characterId) return;
3407
3725
  state.characters = characters;
@@ -3422,7 +3740,7 @@ async function loadCharacterEditorRelationships(characterId) {
3422
3740
  async function refreshRelationshipSurfaces(characterId = null) {
3423
3741
  const tasks = [];
3424
3742
  if (state.module === "relationships") tasks.push(renderRelationships());
3425
- if (characterId && $("#character-editor-dialog").open && characterEditorItem?.id === characterId) {
3743
+ if (characterId && entityEditorType === "character" && !$("#entity-editor-view").classList.contains("hidden") && characterEditorItem?.id === characterId) {
3426
3744
  tasks.push(loadCharacterEditorRelationships(characterId));
3427
3745
  }
3428
3746
  await Promise.all(tasks);
@@ -3459,9 +3777,29 @@ function scheduleCharacterSectionPreview() {
3459
3777
  }, 260);
3460
3778
  }
3461
3779
 
3780
+ async function closeCharacterSectionEditor({ force = false } = {}) {
3781
+ if (!force && characterSectionEditorDirty && !window.confirm("当前 Markdown 章节有未保存修改,返回人物档案将丢弃这些修改。是否继续?")) return false;
3782
+ if (characterSectionPreviewTimer !== null) {
3783
+ clearTimeout(characterSectionPreviewTimer);
3784
+ characterSectionPreviewTimer = null;
3785
+ }
3786
+ await discardPendingCharacterAttachments();
3787
+ characterSectionEditorDirty = false;
3788
+ $("#character-section-editor-view").classList.add("hidden");
3789
+ $("#character-editor-form").classList.remove("hidden");
3790
+ $("#character-section-editor-host").innerHTML = "";
3791
+ replacePageRoute(currentPageRoute());
3792
+ return true;
3793
+ }
3794
+
3462
3795
  function characterSectionEditorHtml(section = null) {
3463
3796
  const options = Object.entries(characterSectionTypeLabels).map(([value, label]) => `<option value="${value}" ${section?.sectionType === value ? "selected" : ""}>${esc(label)}</option>`).join("");
3464
- return `<section class="character-markdown-editor" aria-label="${section ? "编辑" : "新建"}人物 Markdown 章节">
3797
+ return `<div class="character-section-editor-shell">
3798
+ <header class="character-section-editor-header">
3799
+ <div><span class="eyebrow">人物 Markdown 档案</span><h2 id="character-section-editor-title">${section ? `编辑“${esc(section.title)}”` : "新建档案章节"}</h2></div>
3800
+ <button class="entity-editor-back" type="button" data-character-section-edit-close>返回人物档案</button>
3801
+ </header>
3802
+ <section class="character-markdown-editor" aria-label="${section ? "编辑" : "新建"}人物 Markdown 章节">
3465
3803
  <div class="character-markdown-editor-meta">
3466
3804
  <label>章节类型<select id="character-section-type">${options}</select></label>
3467
3805
  <label>章节标题<input id="character-section-title" maxlength="200" value="${esc(section?.title ?? "")}" placeholder="例如:背景故事" required></label>
@@ -3476,17 +3814,23 @@ function characterSectionEditorHtml(section = null) {
3476
3814
  <label>Markdown 原文<textarea id="character-section-markdown" maxlength="500000" spellcheck="true" placeholder="支持标题、列表、引用、表格、链接和图片">${esc(section?.contentMarkdown ?? "")}</textarea></label>
3477
3815
  <div><span class="character-markdown-preview-label">安全预览</span><article id="character-section-preview" class="character-markdown-document message-body">${renderMarkdown(section?.contentMarkdown ?? "") || '<p class="character-markdown-empty">预览区域暂无内容。</p>'}</article></div>
3478
3816
  </div>
3479
- <label class="character-markdown-change-note">版本说明<input id="character-section-change-note" maxlength="500" placeholder="可选,例如:补充远古时期经历"></label>
3480
- <div class="character-markdown-editor-actions"><button type="button" data-character-section-edit-cancel>取消</button><button type="button" class="primary-button" data-character-section-edit-save>${section ? "保存章节版本" : "创建章节"}</button></div>
3481
- </section>`;
3817
+ <div class="character-markdown-editor-footer">
3818
+ <label class="character-markdown-change-note">版本说明<input id="character-section-change-note" maxlength="500" placeholder="可选,例如:补充远古时期经历"></label>
3819
+ <div class="character-markdown-editor-actions"><button type="button" data-character-section-edit-cancel>取消</button><button type="button" class="primary-button" data-character-section-edit-save>${section ? "保存章节版本" : "创建章节"}</button></div>
3820
+ </div>
3821
+ </section>
3822
+ </div>`;
3482
3823
  }
3483
3824
 
3484
3825
  async function openCharacterSectionEditor(section = null) {
3485
3826
  await discardPendingCharacterAttachments();
3486
- const host = $("#character-markdown-sections");
3487
- if (!host) return;
3827
+ const host = $("#character-section-editor-host");
3488
3828
  host.innerHTML = characterSectionEditorHtml(section);
3829
+ characterSectionEditorDirty = false;
3830
+ $("#character-editor-form").classList.add("hidden");
3831
+ $("#character-section-editor-view").classList.remove("hidden");
3489
3832
  const textarea = $("#character-section-markdown");
3833
+ host.querySelectorAll("input, textarea, select").forEach((control) => control.addEventListener("input", () => { characterSectionEditorDirty = true; }));
3490
3834
  textarea.addEventListener("input", scheduleCharacterSectionPreview);
3491
3835
  $("#character-section-attachment").addEventListener("change", async (event) => {
3492
3836
  const file = event.target.files?.[0];
@@ -3506,6 +3850,7 @@ async function openCharacterSectionEditor(section = null) {
3506
3850
  const suffix = end < textarea.value.length && !textarea.value.slice(end).startsWith("\n") ? "\n\n" : "";
3507
3851
  textarea.setRangeText(`${prefix}${insertion}${suffix}`, start, end, "end");
3508
3852
  textarea.focus();
3853
+ characterSectionEditorDirty = true;
3509
3854
  scheduleCharacterSectionPreview();
3510
3855
  toast(attachment.storedMimeType === "image/webp" ? "图片已转换为无损 WebP 并插入" : "图片已插入;转换后未变小,因此保留原格式");
3511
3856
  } catch (error) {
@@ -3515,10 +3860,8 @@ async function openCharacterSectionEditor(section = null) {
3515
3860
  input.value = "";
3516
3861
  }
3517
3862
  });
3518
- host.querySelector("[data-character-section-edit-cancel]").addEventListener("click", async () => {
3519
- await discardPendingCharacterAttachments();
3520
- renderCharacterMarkdownSections();
3521
- });
3863
+ host.querySelector("[data-character-section-edit-close]").addEventListener("click", () => void closeCharacterSectionEditor());
3864
+ host.querySelector("[data-character-section-edit-cancel]").addEventListener("click", () => void closeCharacterSectionEditor());
3522
3865
  host.querySelector("[data-character-section-edit-save]").addEventListener("click", async (event) => {
3523
3866
  const button = event.currentTarget;
3524
3867
  const title = $("#character-section-title").value.trim();
@@ -3547,6 +3890,8 @@ async function openCharacterSectionEditor(section = null) {
3547
3890
  characterEditorSections = await api(`/api/characters/${characterEditorItem.id}/sections`);
3548
3891
  renderCharacterMarkdownSections();
3549
3892
  await Promise.all([renderCharacters(), loadAiReferences()]);
3893
+ characterSectionEditorDirty = false;
3894
+ await closeCharacterSectionEditor({ force: true });
3550
3895
  toast(section ? `“${saved.title}”已保存为 v${saved.versionNo}` : `已创建“${saved.title}”`);
3551
3896
  } catch (error) {
3552
3897
  toast(error.message, "error");
@@ -3565,7 +3910,7 @@ async function showCharacterSectionVersions(sectionId) {
3565
3910
  host.querySelectorAll("[data-character-section-restore]").forEach((button) => button.addEventListener("click", async () => {
3566
3911
  button.disabled = true;
3567
3912
  try {
3568
- 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) } });
3569
3914
  characterEditorSections = await api(`/api/characters/${characterEditorItem.id}/sections`);
3570
3915
  renderCharacterMarkdownSections();
3571
3916
  await Promise.all([renderCharacters(), loadAiReferences()]);
@@ -3780,10 +4125,10 @@ async function showCharacterHistory() {
3780
4125
  }
3781
4126
  }
3782
4127
 
3783
- async function openCharacterDialog(item) {
4128
+ async function openCharacterEditor(item = null) {
3784
4129
  [state.races, state.organizations] = await Promise.all([
3785
- api(`/api/works/${state.work.id}/races`),
3786
- api(`/api/works/${state.work.id}/organizations`)
4130
+ apiAllPages(`/api/works/${state.work.id}/races`),
4131
+ apiAllPages(`/api/works/${state.work.id}/organizations`)
3787
4132
  ]);
3788
4133
  characterEditorItem = item ?? null;
3789
4134
  characterEditorVersions = [];
@@ -3805,13 +4150,14 @@ async function openCharacterDialog(item) {
3805
4150
  $("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
3806
4151
  $("#character-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
3807
4152
  }
4153
+ $("#character-change-note").readOnly = viewOnly;
4154
+ $("#character-editor-submit").classList.toggle("hidden", viewOnly);
3808
4155
  document.querySelectorAll("[data-character-editor-tab]").forEach((button) => {
3809
4156
  button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
3810
4157
  });
3811
4158
  const relationshipTab = document.querySelector("[data-character-editor-tab='relationships']");
3812
4159
  relationshipTab.disabled = !item;
3813
4160
  relationshipTab.title = item ? "查看和编辑人物关系" : "创建人物档案后即可维护人物关系";
3814
- const dialog = $("#character-editor-dialog");
3815
4161
  const form = $("#character-editor-form");
3816
4162
  form.onsubmit = async (event) => {
3817
4163
  event.preventDefault();
@@ -3825,8 +4171,9 @@ async function openCharacterDialog(item) {
3825
4171
  const previousVersion = characterEditorItem?.versionNo;
3826
4172
  if (!wasEditing) delete body.changeNote;
3827
4173
  const saved = await api(wasEditing ? `/api/characters/${characterEditorItem.id}` : `/api/works/${state.work.id}/characters`, { method: wasEditing ? "PATCH" : "POST", body });
3828
- dialog.close();
3829
- await Promise.all([renderCharacters(), loadAiReferences()]);
4174
+ entityEditorDirty = false;
4175
+ await loadAiReferences();
4176
+ await closeEntityEditor({ force: true });
3830
4177
  toast(!wasEditing ? "人物档案已创建" : saved.versionNo === previousVersion ? "没有检测到人物档案变更" : `人物档案已保存为 v${saved.versionNo}`);
3831
4178
  } catch (error) {
3832
4179
  toast(error.message, "error");
@@ -3834,7 +4181,7 @@ async function openCharacterDialog(item) {
3834
4181
  submit.disabled = false;
3835
4182
  }
3836
4183
  };
3837
- dialog.showModal();
4184
+ showEntityEditorPage("character");
3838
4185
  if (item) {
3839
4186
  void loadCharacterEditorRelationships(item.id);
3840
4187
  void loadCharacterMarkdownSections(item.id);
@@ -3842,7 +4189,7 @@ async function openCharacterDialog(item) {
3842
4189
  }
3843
4190
 
3844
4191
  async function openRaceDialog(item) {
3845
- state.characters = await api(`/api/works/${state.work.id}/characters`);
4192
+ state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
3846
4193
  const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
3847
4194
  const parentOptions = [["", "无(根种族)"], ...eligibleRaceParents(state.races, item?.id)
3848
4195
  .sort((left, right) => racePathLabel(left).localeCompare(racePathLabel(right), "zh-CN"))
@@ -3863,7 +4210,7 @@ async function openRaceDialog(item) {
3863
4210
  }
3864
4211
 
3865
4212
  async function openOrganizationDialog(item) {
3866
- state.characters = await api(`/api/works/${state.work.id}/characters`);
4213
+ state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
3867
4214
  const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
3868
4215
  openDialog(item ? "编辑组织" : "新建组织",
3869
4216
  field("name", "组织名称", "text", item?.name) +
@@ -3890,7 +4237,7 @@ function openTimelineDialog(item, preferredTrackId = null) {
3890
4237
  const trackOptions = [["", "未分组"], ...state.timelineTracks.map((track) => [track.id, track.name])];
3891
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) => {
3892
4239
  const rawSort = String(form.get("timeSort") ?? "").trim();
3893
- 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 } : {}) };
3894
4241
  await api(item ? `/api/timeline/${item.id}` : `/api/works/${state.work.id}/timeline`, { method: item ? "PATCH" : "POST", body });
3895
4242
  await renderTimeline();
3896
4243
  }, item ? "人工调整" : "作者确认事件");
@@ -3906,7 +4253,7 @@ function openOutlineDialog(item) {
3906
4253
  field("status", "规划状态", "select", item.status ?? "draft", [["draft", "草稿"], ["ready", "可执行"], ["completed", "已完成"]]),
3907
4254
  async (form) => {
3908
4255
  await api(`/api/chapters/${item.chapterId}/outline`, { method: "PUT", body: {
3909
- 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
3910
4257
  } });
3911
4258
  await renderOutlines();
3912
4259
  toast("章节规划已保存");
@@ -3949,7 +4296,8 @@ function openForeshadowDialog(item) {
3949
4296
  const occurrences = [...preservedOccurrences, ...editedOccurrences];
3950
4297
  const body = {
3951
4298
  title: form.get("title"), description: form.get("description"), importance: form.get("importance"), status: form.get("status"),
3952
- 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 } : {})
3953
4301
  };
3954
4302
  await api(item ? `/api/foreshadows/${item.id}` : `/api/works/${state.work.id}/foreshadows`, { method: item ? "PATCH" : "POST", body });
3955
4303
  await renderOutlines();
@@ -3962,20 +4310,20 @@ function openTimelineSplitDialog(item) {
3962
4310
  await api(`/api/timeline/${item.id}/split`, { method: "POST", body: { parts: [
3963
4311
  { name: form.get("firstName"), description: form.get("firstDescription") },
3964
4312
  { name: form.get("secondName"), description: form.get("secondDescription") }
3965
- ] } });
4313
+ ], expectedVersionNo: item.versionNo } });
3966
4314
  await renderTimeline();
3967
4315
  }, "原证据同步保留");
3968
4316
  }
3969
4317
 
3970
4318
  async function openRelationshipDialog(item, options = {}) {
3971
- state.characters = await api(`/api/works/${state.work.id}/characters`);
4319
+ state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
3972
4320
  if (state.characters.length < 2) return toast("至少需要两个角色才能创建关系", "error");
3973
4321
  const characterOptions = state.characters.map((item) => [item.id, item.name]);
3974
4322
  const defaultFrom = options.characterId && state.characters.some((character) => character.id === options.characterId) ? options.characterId : characterOptions[0][0];
3975
4323
  const defaultTo = characterOptions.find(([id]) => id !== defaultFrom)?.[0] ?? characterOptions[1][0];
3976
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) => {
3977
4325
  const keywords = uniqueRelationshipKeywords(form.getAll("keywords").map(String));
3978
- 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 } : {}) } });
3979
4327
  await refreshRelationshipSurfaces(options.characterId ?? null);
3980
4328
  }, item ? "关系档案" : "人工确认关系");
3981
4329
  }
@@ -4278,7 +4626,7 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
4278
4626
  async function showVersions() {
4279
4627
  if (!state.chapter) return;
4280
4628
  const versions = await api(`/api/chapters/${state.chapter.id}/versions`);
4281
- $("#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("");
4282
4630
  $("#versions-list").querySelectorAll("[data-restore-version]").forEach((button) => button.addEventListener("click", async () => {
4283
4631
  if (!window.confirm(`将版本 v${button.dataset.restoreVersion} 恢复为一个新的保存版本?`)) return;
4284
4632
  state.chapter = await api(`/api/chapters/${state.chapter.id}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.restoreVersion) } });
@@ -4581,7 +4929,7 @@ $("#member-invite-form").addEventListener("submit", async (event) => {
4581
4929
  const members = await api(`/api/works/${encodeURIComponent(work.id)}/members`, { method: "POST", body: { userId, role } });
4582
4930
  renderMembers(members);
4583
4931
  await fillMemberCandidates(members);
4584
- toast(role === "viewer" ? "仅查看成员已邀请" : "协作者已邀请");
4932
+ toast(role === "viewer" ? "仅查看成员已邀请" : role === "settings-editor" ? "设定编辑已邀请" : "完整协作者已邀请");
4585
4933
  } catch (error) { toast(error.message, "error"); }
4586
4934
  });
4587
4935
  $("#platform-new-provider").addEventListener("click", () => openProviderDialog());
@@ -4596,9 +4944,16 @@ $("#versions-button").addEventListener("click", showVersions);
4596
4944
  $("#versions-close").addEventListener("click", () => $("#versions-dialog").close());
4597
4945
  $("#entity-history-close").addEventListener("click", () => $("#entity-history-dialog").close());
4598
4946
  $("#ai-tool-call-close").addEventListener("click", () => $("#ai-tool-call-dialog").close());
4599
- $("#character-editor-close").addEventListener("click", () => $("#character-editor-dialog").close());
4600
- $("#character-editor-cancel").addEventListener("click", () => $("#character-editor-dialog").close());
4601
- $("#character-editor-dialog").addEventListener("close", () => { void discardPendingCharacterAttachments(); });
4947
+ $("#setting-editor-back").addEventListener("click", () => { void closeEntityEditor(); });
4948
+ $("#character-editor-close").addEventListener("click", () => { void closeEntityEditor(); });
4949
+ $("#character-editor-cancel").addEventListener("click", () => { void closeEntityEditor(); });
4950
+ $("#setting-editor-form").addEventListener("input", markEntityEditorDirty);
4951
+ $("#setting-editor-form").addEventListener("change", markEntityEditorDirty);
4952
+ $("#character-editor-form").addEventListener("input", markEntityEditorDirty);
4953
+ $("#character-editor-form").addEventListener("change", markEntityEditorDirty);
4954
+ $("#character-editor-fields").addEventListener("click", (event) => {
4955
+ if (event.target.closest("[data-item-list-add], [data-structured-list-add], [data-item-list-remove], [data-structured-list-remove]")) markEntityEditorDirty();
4956
+ });
4602
4957
  $("#character-history-button").addEventListener("click", () => {
4603
4958
  if ($("#character-history-panel").classList.contains("hidden")) void showCharacterHistory();
4604
4959
  else setCharacterHistoryVisible(false);
@@ -4697,7 +5052,7 @@ $("#module-nav").addEventListener("click", (event) => {
4697
5052
  if (button.dataset.module) showModule(button.dataset.module);
4698
5053
  });
4699
5054
  $("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!moduleNavExpanded));
4700
- $("#module-create-button").addEventListener("click", () => ({ settings: openSettingDialog, characters: openCharacterDialog, races: openRaceDialog, organizations: openOrganizationDialog, timeline: openTimelineDialog, outlines: openForeshadowDialog, relationships: openRelationshipDialog, reviews: openReviewDialog, tasks: openTaskDialog })[state.module]?.());
5055
+ $("#module-create-button").addEventListener("click", () => ({ settings: openSettingEditor, characters: openCharacterEditor, races: openRaceDialog, organizations: openOrganizationDialog, timeline: openTimelineDialog, outlines: openForeshadowDialog, relationships: openRelationshipDialog, reviews: openReviewDialog, tasks: openTaskDialog })[state.module]?.());
4701
5056
  $("#ai-prompt").addEventListener("input", async () => {
4702
5057
  updateAiMentionMenu();
4703
5058
  scheduleAiContextUsage();
@@ -4723,22 +5078,35 @@ $("#ai-mention-menu").addEventListener("click", (event) => {
4723
5078
  if (button) selectAiMention(button);
4724
5079
  });
4725
5080
  $("#import-file").addEventListener("change", async (event) => {
4726
- if (!state.work || !event.target.files[0]) return;
4727
- 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) {
4728
5090
  event.target.value = "";
4729
5091
  return;
4730
5092
  }
5093
+ cancelChapterAutoSave();
4731
5094
  const body = new FormData();
4732
- 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));
4733
5098
  try {
4734
5099
  const result = await api(`/api/works/${state.work.id}/import`, { method: "POST", body });
4735
- setSaveState("已导入");
5100
+ setSaveState(mode === "append" ? "已追加" : "已覆盖");
4736
5101
  state.work = result.tree;
4737
5102
  renderTree();
4738
- toast(result.warnings.length ? `导入完成:${result.warnings.join("")}` : "导入完成");
4739
- const first = state.work.volumes.flatMap((volume) => volume.chapters)[0];
4740
- if (first) await selectChapter(first.id);
4741
- } 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
+ }
4742
5110
  event.target.value = "";
4743
5111
  });
4744
5112
  $("#new-import-file").addEventListener("change", async (event) => {
@@ -4769,7 +5137,7 @@ $("#cover-file").addEventListener("change", async (event) => {
4769
5137
  body.append("file", file);
4770
5138
  try {
4771
5139
  await api(`/api/works/${workId}/cover`, { method: "PUT", body });
4772
- state.works = await api("/api/works");
5140
+ state.works = (await apiPage("/api/works")).items;
4773
5141
  const updated = state.works.find((item) => item.id === workId);
4774
5142
  const coverField = $("#dialog-fields")?.querySelector(".work-cover-field");
4775
5143
  if (updated && coverField && $("#form-dialog")?.open) {
@@ -4908,7 +5276,7 @@ $("#search-form").addEventListener("submit", async (event) => {
4908
5276
  $("#export-button").addEventListener("click", () => {
4909
5277
  if (state.work) window.location.href = `/api/works/${state.work.id}/export?format=markdown`;
4910
5278
  });
4911
- window.addEventListener("beforeunload", (event) => { if (state.dirty) event.preventDefault(); });
5279
+ window.addEventListener("beforeunload", (event) => { if (state.dirty || entityEditorDirty || characterSectionEditorDirty) event.preventDefault(); });
4912
5280
 
4913
5281
  initializePage().catch((error) => {
4914
5282
  restoringPageRoute = false;