@musnows/scriverse 0.3.7 → 0.3.8

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";
@@ -352,6 +352,10 @@ function replacePageRoute(route) {
352
352
 
353
353
  function currentPageRoute() {
354
354
  const workId = state.work?.id ?? null;
355
+ if (!$("#entity-editor-view").classList.contains("hidden") && workId && entityEditorType) {
356
+ const entityId = entityEditorType === "setting" ? settingEditorItem?.id : characterEditorItem?.id;
357
+ return { view: "entity-editor", workId, entity: entityEditorType, entityId: entityId ?? null };
358
+ }
355
359
  if (!$("#settings-hub-view").classList.contains("hidden")) return { view: "settings", workId, ...settingsRouteContext() };
356
360
  if (!$("#platform-ai-view").classList.contains("hidden")) return { view: "platform-ai", workId, ...settingsRouteContext() };
357
361
  if (!$("#shelf-view").classList.contains("hidden")) return { view: "shelf" };
@@ -461,6 +465,9 @@ const chapterAutoSaveDelay = 800;
461
465
  let aiMentionMatch = null;
462
466
  let aiMentionRange = null;
463
467
  let settingsReturnContext = null;
468
+ let entityEditorType = null;
469
+ let entityEditorDirty = false;
470
+ let settingEditorItem = null;
464
471
  let characterEditorItem = null;
465
472
  let characterEditorVersions = [];
466
473
  let characterEditorRelationships = [];
@@ -468,8 +475,49 @@ let characterEditorRelationshipsLoading = false;
468
475
  let characterEditorSections = [];
469
476
  let characterSectionPreviewTimer = null;
470
477
  let characterSectionPendingAttachments = [];
478
+ let characterSectionEditorDirty = false;
471
479
  let entityHistoryContext = null;
472
480
 
481
+ function showEntityEditorPage(type) {
482
+ entityEditorType = type;
483
+ entityEditorDirty = false;
484
+ characterSectionEditorDirty = false;
485
+ $("#entity-editor-view").classList.remove("hidden");
486
+ $("#setting-editor-form").classList.toggle("hidden", type !== "setting");
487
+ $("#character-editor-form").classList.toggle("hidden", type !== "character");
488
+ $("#character-section-editor-view").classList.add("hidden");
489
+ $("#app").inert = true;
490
+ document.body.classList.add("entity-editor-open");
491
+ replacePageRoute(currentPageRoute());
492
+ }
493
+
494
+ function markEntityEditorDirty() {
495
+ if (entityEditorType && canEditWork()) entityEditorDirty = true;
496
+ }
497
+
498
+ function confirmEntityEditorDiscard(message) {
499
+ if (!entityEditorDirty) return true;
500
+ return window.confirm(message ?? "当前资料有未保存修改,返回列表将丢弃这些修改。是否继续?");
501
+ }
502
+
503
+ async function closeEntityEditor({ force = false } = {}) {
504
+ if (!$("#character-section-editor-view").classList.contains("hidden")) return closeCharacterSectionEditor({ force });
505
+ if (!force && !confirmEntityEditorDiscard()) return false;
506
+ await discardPendingCharacterAttachments();
507
+ const module = entityEditorType === "setting" ? "settings" : "characters";
508
+ entityEditorType = null;
509
+ entityEditorDirty = false;
510
+ settingEditorItem = null;
511
+ characterEditorItem = null;
512
+ $("#entity-editor-view").classList.add("hidden");
513
+ $("#setting-editor-form").classList.add("hidden");
514
+ $("#character-editor-form").classList.add("hidden");
515
+ $("#app").inert = false;
516
+ document.body.classList.remove("entity-editor-open");
517
+ await showModule(module);
518
+ return true;
519
+ }
520
+
473
521
  function setModuleNavExpanded(expanded) {
474
522
  moduleNavExpanded = expanded;
475
523
  $("#module-more-button .nav-label").textContent = expanded ? "收起" : "更多";
@@ -1016,7 +1064,7 @@ function renderAiConversationHistory() {
1016
1064
  async function loadAiConversations(openLatest = true) {
1017
1065
  const workId = state.work?.id;
1018
1066
  if (!workId) return;
1019
- const conversations = await api(`/api/works/${workId}/ai-conversations`);
1067
+ const conversations = (await apiPage(`/api/works/${workId}/ai-conversations`)).items;
1020
1068
  if (state.work?.id !== workId) return;
1021
1069
  state.aiConversations = conversations;
1022
1070
  loadedAiConversationsWorkId = workId;
@@ -1045,7 +1093,7 @@ async function ensureAiConversationsLoaded() {
1045
1093
  }
1046
1094
 
1047
1095
  async function openAiConversation(conversationId, hideHistory = true) {
1048
- const conversation = await api(`/api/ai-conversations/${conversationId}`);
1096
+ const conversation = await api(`/api/ai-conversations/${conversationId}?page=1&limit=100`);
1049
1097
  state.aiConversationId = conversation.id;
1050
1098
  state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
1051
1099
  $("#ai-conversation-title").textContent = conversation.title;
@@ -1396,6 +1444,24 @@ async function api(path, options = {}) {
1396
1444
  return payload.data;
1397
1445
  }
1398
1446
 
1447
+ async function apiPage(path, page = 1, limit = 50) {
1448
+ const separator = path.includes("?") ? "&" : "?";
1449
+ const result = await api(`${path}${separator}page=${page}&limit=${limit}`);
1450
+ if (Array.isArray(result)) return { items: result, page, limit, hasMore: false, nextPage: null };
1451
+ return result;
1452
+ }
1453
+
1454
+ async function apiAllPages(path, limit = 100) {
1455
+ const items = [];
1456
+ let page = 1;
1457
+ while (true) {
1458
+ const result = await apiPage(path, page, limit);
1459
+ items.push(...(result.items ?? []));
1460
+ if (!result.hasMore || !result.nextPage) return items;
1461
+ page = result.nextPage;
1462
+ }
1463
+ }
1464
+
1399
1465
  function selectAuthMode(mode) {
1400
1466
  const registerTab = $("#auth-register-tab");
1401
1467
  const login = mode === "login" || registerTab.disabled;
@@ -1634,7 +1700,7 @@ function updateDocumentTitle(work = null) {
1634
1700
  }
1635
1701
 
1636
1702
  async function loadWorks(preferredId) {
1637
- state.works = await api("/api/works");
1703
+ state.works = (await apiPage("/api/works")).items;
1638
1704
  if (preferredId) {
1639
1705
  await selectWork(preferredId);
1640
1706
  return;
@@ -1658,7 +1724,7 @@ async function initializePage() {
1658
1724
  return;
1659
1725
  }
1660
1726
  const route = parsePageRoute(window.location.hash);
1661
- state.works = await api("/api/works");
1727
+ state.works = (await apiPage("/api/works")).items;
1662
1728
  try {
1663
1729
  if (route.view === "shelf") {
1664
1730
  showShelf();
@@ -1672,16 +1738,30 @@ async function initializePage() {
1672
1738
  }
1673
1739
 
1674
1740
  if (requestedWork) {
1675
- state.module = route.view === "module" ? route.module : "editor";
1741
+ state.module = route.view === "module"
1742
+ ? route.module
1743
+ : route.view === "entity-editor"
1744
+ ? (route.entity === "setting" ? "settings" : "characters")
1745
+ : "editor";
1676
1746
  await selectWork(requestedWork.id, route.view === "editor" ? route.chapterId : null);
1677
1747
  }
1678
1748
 
1679
1749
  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);
1750
+ if (route.chapterId && state.chapter?.id !== route.chapterId) await selectChapter(route.chapterId);
1682
1751
  return;
1683
1752
  }
1684
1753
  if (route.view === "module") return;
1754
+ if (route.view === "entity-editor") {
1755
+ const records = route.entity === "setting" ? state.settings : state.characters;
1756
+ const item = route.entityId ? records.find((record) => record.id === route.entityId) : null;
1757
+ if (route.entityId && !item) {
1758
+ toast(route.entity === "setting" ? "未找到要编辑的设定" : "未找到要编辑的角色", "error");
1759
+ return;
1760
+ }
1761
+ if (route.entity === "setting") openSettingEditor(item);
1762
+ else await openCharacterEditor(item);
1763
+ return;
1764
+ }
1685
1765
  if (route.view === "welcome") {
1686
1766
  showWelcome(true);
1687
1767
  return;
@@ -1784,7 +1864,7 @@ async function openUsersDialog() {
1784
1864
  }
1785
1865
  $("#users-list").innerHTML = '<p class="empty-state">正在读取用户……</p>';
1786
1866
  $("#users-dialog").showModal();
1787
- try { renderUsers(await api("/api/users")); }
1867
+ try { renderUsers((await apiPage("/api/users")).items); }
1788
1868
  catch (error) { $("#users-dialog").close(); toast(error.message, "error"); }
1789
1869
  }
1790
1870
 
@@ -1921,13 +2001,13 @@ async function openSearchResult(result) {
1921
2001
  if (result.type === "character") {
1922
2002
  await showModule("characters");
1923
2003
  const character = state.characters.find((item) => item.id === result.id);
1924
- if (character) openCharacterDialog(character);
2004
+ if (character) openCharacterEditor(character);
1925
2005
  return;
1926
2006
  }
1927
2007
  if (result.type === "setting") {
1928
2008
  await showModule("settings");
1929
2009
  const setting = await api(`/api/settings/${encodeURIComponent(result.id)}`);
1930
- openSettingDialog(setting);
2010
+ openSettingEditor(setting);
1931
2011
  return;
1932
2012
  }
1933
2013
  if (result.type === "race") {
@@ -2026,7 +2106,7 @@ function renderShelf() {
2026
2106
  async function selectWork(workId, preferredChapterId = null) {
2027
2107
  const discarding = state.work?.id !== workId && state.dirty;
2028
2108
  if (discarding && !confirmDiscardChanges()) return false;
2029
- const nextWork = await api(`/api/works/${workId}`);
2109
+ const nextWork = await api(`/api/works/${workId}?page=1&limit=100`);
2030
2110
  if (state.work?.id !== nextWork.id) {
2031
2111
  loadedAiModelsWorkId = null;
2032
2112
  loadedAiReferencesWorkId = null;
@@ -2067,7 +2147,8 @@ async function selectWork(workId, preferredChapterId = null) {
2067
2147
  renderTree();
2068
2148
  const chapters = state.work.volumes.flatMap((volume) => volume.chapters);
2069
2149
  const targetChapter = chapters.find((chapter) => chapter.id === preferredChapterId) ?? chapters[0];
2070
- if (state.module === "editor" && targetChapter) await selectChapter(targetChapter.id);
2150
+ if (state.module === "editor" && preferredChapterId) await selectChapter(preferredChapterId);
2151
+ else if (state.module === "editor" && targetChapter) await selectChapter(targetChapter.id);
2071
2152
  else if (state.module === "editor") showWelcome(true);
2072
2153
  else await showModule(state.module);
2073
2154
  return true;
@@ -2324,7 +2405,7 @@ function bindEntityHistoryButtons(refresh) {
2324
2405
  }
2325
2406
 
2326
2407
  async function renderSettings() {
2327
- const records = await api(`/api/works/${state.work.id}/settings`);
2408
+ const records = (await apiPage(`/api/works/${state.work.id}/settings`)).items;
2328
2409
  state.settings = records;
2329
2410
  $("#module-content").innerHTML = records.length ? `<div class="card-grid">${records.map((item) => `
2330
2411
  <article class="record-card"><small>${esc(item.category)} · ${item.locked ? "已锁定" : esc(item.status)}</small>
@@ -2336,15 +2417,15 @@ async function renderSettings() {
2336
2417
  await renderSettings();
2337
2418
  await loadAiReferences();
2338
2419
  }));
2339
- $("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => openSettingDialog(records.find((item) => item.id === button.dataset.editSetting))));
2420
+ $("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => openSettingEditor(records.find((item) => item.id === button.dataset.editSetting))));
2340
2421
  bindEntityHistoryButtons(async () => { await renderSettings(); await loadAiReferences(); });
2341
2422
  }
2342
2423
 
2343
2424
  async function renderCharacters() {
2344
2425
  [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`)
2426
+ apiPage(`/api/works/${state.work.id}/characters`).then((result) => result.items),
2427
+ apiAllPages(`/api/works/${state.work.id}/races`),
2428
+ apiAllPages(`/api/works/${state.work.id}/organizations`)
2348
2429
  ]);
2349
2430
  const auditPanel = `<section class="character-audit-panel"><div><strong>角色身份确认</strong><small>让 AI 查询角色档案并搜索正文,找出可能被误建成两个档案的同一角色。AI 只提交审核建议,不会自动合并。</small></div><button id="create-character-audit-task" class="ghost-button" type="button" ${state.characters.length < 2 ? "disabled" : ""}>AI 角色查重</button></section>`;
2350
2431
  $("#module-content").innerHTML = auditPanel + (state.characters.length ? `<div class="card-grid">${state.characters.map((item) => {
@@ -2374,7 +2455,7 @@ async function renderCharacters() {
2374
2455
  }
2375
2456
  });
2376
2457
  $("#module-content").querySelectorAll("[data-open-character]").forEach((card) => {
2377
- const open = () => openCharacterDialog(state.characters.find((item) => item.id === card.dataset.openCharacter));
2458
+ const open = () => openCharacterEditor(state.characters.find((item) => item.id === card.dataset.openCharacter));
2378
2459
  card.addEventListener("click", (event) => { if (!event.target.closest("button")) open(); });
2379
2460
  card.addEventListener("keydown", (event) => {
2380
2461
  if (event.key !== "Enter" && event.key !== " ") return;
@@ -2382,13 +2463,13 @@ async function renderCharacters() {
2382
2463
  open();
2383
2464
  });
2384
2465
  });
2385
- $("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterDialog(state.characters.find((item) => item.id === button.dataset.editCharacter))));
2466
+ $("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterEditor(state.characters.find((item) => item.id === button.dataset.editCharacter))));
2386
2467
  }
2387
2468
 
2388
2469
  async function renderRaces() {
2389
2470
  [state.races, state.characters] = await Promise.all([
2390
- api(`/api/works/${state.work.id}/races`),
2391
- api(`/api/works/${state.work.id}/characters`)
2471
+ apiAllPages(`/api/works/${state.work.id}/races`),
2472
+ apiAllPages(`/api/works/${state.work.id}/characters`)
2392
2473
  ]);
2393
2474
  const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
2394
2475
  <summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
@@ -2410,8 +2491,8 @@ async function renderRaces() {
2410
2491
 
2411
2492
  async function renderOrganizations() {
2412
2493
  [state.organizations, state.characters] = await Promise.all([
2413
- api(`/api/works/${state.work.id}/organizations`),
2414
- api(`/api/works/${state.work.id}/characters`)
2494
+ apiAllPages(`/api/works/${state.work.id}/organizations`),
2495
+ apiAllPages(`/api/works/${state.work.id}/characters`)
2415
2496
  ]);
2416
2497
  $("#module-content").innerHTML = state.organizations.length ? `<div class="card-grid organization-grid">${state.organizations.map((item) => `
2417
2498
  <article class="record-card organization-card"><small>${item.memberIds.length} 位成员 · ${item.settings.length} 条设定</small>
@@ -2426,8 +2507,8 @@ async function renderOrganizations() {
2426
2507
 
2427
2508
  async function renderTimeline() {
2428
2509
  const [events, tracks] = await Promise.all([
2429
- api(`/api/works/${state.work.id}/timeline`),
2430
- api(`/api/works/${state.work.id}/timeline-tracks`)
2510
+ apiPage(`/api/works/${state.work.id}/timeline`).then((result) => result.items),
2511
+ apiAllPages(`/api/works/${state.work.id}/timeline-tracks`)
2431
2512
  ]);
2432
2513
  state.timelineTracks = tracks;
2433
2514
  const lanes = [...tracks, { id: "", name: "未分组时间轴", description: "尚未归入独立大事件的时间节点。", sortOrder: Number.MAX_SAFE_INTEGER }];
@@ -2455,8 +2536,8 @@ async function renderTimeline() {
2455
2536
  async function renderOutlines() {
2456
2537
  const currentChapterId = state.chapter?.id;
2457
2538
  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)}` : ""}`)
2539
+ apiPage(`/api/works/${state.work.id}/outlines`).then((result) => result.items),
2540
+ apiPage(`/api/works/${state.work.id}/foreshadows?status=all${currentChapterId ? `&currentChapterId=${encodeURIComponent(currentChapterId)}` : ""}`).then((result) => result.items)
2460
2541
  ]);
2461
2542
  const unresolved = foreshadows.filter((item) => item.unresolved);
2462
2543
  const overdue = unresolved.filter((item) => item.overdue);
@@ -2484,8 +2565,8 @@ async function renderOutlines() {
2484
2565
  }
2485
2566
 
2486
2567
  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`);
2568
+ state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
2569
+ const relationships = (await apiPage(`/api/works/${state.work.id}/relationships`)).items;
2489
2570
  const nameOf = (id) => state.characters.find((item) => item.id === id)?.name ?? "未知角色";
2490
2571
  state.galaxy?.destroy();
2491
2572
  state.relationshipExpandedMap?.destroy?.();
@@ -2516,8 +2597,8 @@ async function renderRelationships() {
2516
2597
 
2517
2598
  async function renderReviews() {
2518
2599
  const [reviews, characters] = await Promise.all([
2519
- api(`/api/works/${state.work.id}/reviews`),
2520
- api(`/api/works/${state.work.id}/characters?includeMerged=1`)
2600
+ apiPage(`/api/works/${state.work.id}/reviews`).then((result) => result.items),
2601
+ apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`)
2521
2602
  ]);
2522
2603
  const characterById = new Map(characters.map((character) => [character.id, character]));
2523
2604
  const duplicateCard = (item) => {
@@ -2577,7 +2658,7 @@ async function renderReviews() {
2577
2658
 
2578
2659
  async function renderTasks() {
2579
2660
  const [tasks, settings] = await Promise.all([
2580
- api(`/api/works/${state.work.id}/tasks`),
2661
+ apiPage(`/api/works/${state.work.id}/tasks`).then((result) => result.items),
2581
2662
  api(`/api/works/${state.work.id}/ai-settings`)
2582
2663
  ]);
2583
2664
  const pendingCount = tasks.filter((item) => item.status === "pending").length;
@@ -3012,8 +3093,8 @@ async function loadAiReferences() {
3012
3093
  const workId = state.work?.id;
3013
3094
  if (!workId) return;
3014
3095
  const [characters, settings] = await Promise.all([
3015
- api(`/api/works/${workId}/characters`),
3016
- api(`/api/works/${workId}/settings`)
3096
+ apiAllPages(`/api/works/${workId}/characters`),
3097
+ apiAllPages(`/api/works/${workId}/settings`)
3017
3098
  ]);
3018
3099
  if (state.work?.id !== workId) return;
3019
3100
  state.characters = characters;
@@ -3230,7 +3311,7 @@ function bindWorkCoverControls(work) {
3230
3311
  $("#work-cover-remove")?.addEventListener("click", async () => {
3231
3312
  try {
3232
3313
  await api(`/api/works/${work.id}/cover`, { method: "DELETE" });
3233
- state.works = await api("/api/works");
3314
+ state.works = (await apiPage("/api/works")).items;
3234
3315
  const updated = state.works.find((item) => item.id === work.id) ?? { ...work, coverUrl: null };
3235
3316
  Object.assign(work, updated);
3236
3317
  const coverField = $("#dialog-fields")?.querySelector(".work-cover-field");
@@ -3257,7 +3338,7 @@ function openWorkSettingsDialog(work) {
3257
3338
  workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + accessField,
3258
3339
  async (form) => {
3259
3340
  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");
3341
+ state.works = (await apiPage("/api/works")).items;
3261
3342
  const updated = state.works.find((item) => item.id === work.id);
3262
3343
  if (updated) Object.assign(work, updated);
3263
3344
  if (state.work?.id === work.id) {
@@ -3314,17 +3395,50 @@ function openVolumeDialog(item) {
3314
3395
  }, "分卷设置");
3315
3396
  }
3316
3397
 
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") };
3398
+ function openSettingEditor(item = null) {
3399
+ settingEditorItem = item;
3400
+ $("#setting-editor-eyebrow").textContent = item ? "人工修正" : "作者事实";
3401
+ $("#setting-editor-title").textContent = item ? `编辑“${item.title}”` : "新建设定";
3402
+ $("#setting-editor-name").value = item?.title ?? "";
3403
+ $("#setting-editor-category").value = item?.category ?? "世界规则";
3404
+ $("#setting-editor-locked").checked = Boolean(item?.locked);
3405
+ $("#setting-editor-body").value = item?.content ?? "";
3406
+ $("#setting-change-note").value = "";
3407
+ $("#setting-change-note-field").classList.toggle("hidden", !item);
3408
+ $("#setting-editor-submit").textContent = item ? "保存新版本" : "创建设定";
3409
+ const viewOnly = !canEditWork();
3410
+ $("#setting-editor-form").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = viewOnly; });
3411
+ $("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
3412
+ $("#setting-editor-submit").classList.toggle("hidden", viewOnly);
3413
+ $("#setting-editor-form").onsubmit = async (event) => {
3414
+ event.preventDefault();
3415
+ if (!canEditWork()) return;
3416
+ const form = new FormData(event.currentTarget);
3417
+ const submit = $("#setting-editor-submit");
3418
+ submit.disabled = true;
3419
+ try {
3420
+ const locked = form.get("locked") === "on";
3421
+ const body = {
3422
+ title: String(form.get("title") ?? "").trim(),
3423
+ category: String(form.get("category") ?? "世界规则"),
3424
+ content: String(form.get("content") ?? ""),
3425
+ locked,
3426
+ status: locked ? "confirmed" : (item?.status ?? "draft"),
3427
+ ...(item ? { changeNote: String(form.get("changeNote") ?? "").trim() } : {})
3428
+ };
3324
3429
  await api(item ? `/api/settings/${item.id}` : `/api/works/${state.work.id}/settings`, { method: item ? "PATCH" : "POST", body });
3325
- await renderSettings();
3430
+ entityEditorDirty = false;
3326
3431
  await loadAiReferences();
3327
- }, item ? "人工修正" : "作者事实");
3432
+ await closeEntityEditor({ force: true });
3433
+ toast(item ? "设定新版本已保存" : "设定已创建");
3434
+ } catch (error) {
3435
+ toast(error.message, "error");
3436
+ } finally {
3437
+ submit.disabled = false;
3438
+ }
3439
+ };
3440
+ showEntityEditorPage("setting");
3441
+ $("#setting-editor-name").focus();
3328
3442
  }
3329
3443
 
3330
3444
  function characterEditorSection(key, title, description, content) {
@@ -3400,8 +3514,8 @@ async function loadCharacterEditorRelationships(characterId) {
3400
3514
  let loaded = false;
3401
3515
  try {
3402
3516
  const [characters, relationships] = await Promise.all([
3403
- api(`/api/works/${workId}/characters`),
3404
- api(`/api/works/${workId}/relationships`)
3517
+ apiAllPages(`/api/works/${workId}/characters`),
3518
+ apiAllPages(`/api/works/${workId}/relationships`)
3405
3519
  ]);
3406
3520
  if (state.work?.id !== workId || characterEditorItem?.id !== characterId) return;
3407
3521
  state.characters = characters;
@@ -3422,7 +3536,7 @@ async function loadCharacterEditorRelationships(characterId) {
3422
3536
  async function refreshRelationshipSurfaces(characterId = null) {
3423
3537
  const tasks = [];
3424
3538
  if (state.module === "relationships") tasks.push(renderRelationships());
3425
- if (characterId && $("#character-editor-dialog").open && characterEditorItem?.id === characterId) {
3539
+ if (characterId && entityEditorType === "character" && !$("#entity-editor-view").classList.contains("hidden") && characterEditorItem?.id === characterId) {
3426
3540
  tasks.push(loadCharacterEditorRelationships(characterId));
3427
3541
  }
3428
3542
  await Promise.all(tasks);
@@ -3459,9 +3573,29 @@ function scheduleCharacterSectionPreview() {
3459
3573
  }, 260);
3460
3574
  }
3461
3575
 
3576
+ async function closeCharacterSectionEditor({ force = false } = {}) {
3577
+ if (!force && characterSectionEditorDirty && !window.confirm("当前 Markdown 章节有未保存修改,返回人物档案将丢弃这些修改。是否继续?")) return false;
3578
+ if (characterSectionPreviewTimer !== null) {
3579
+ clearTimeout(characterSectionPreviewTimer);
3580
+ characterSectionPreviewTimer = null;
3581
+ }
3582
+ await discardPendingCharacterAttachments();
3583
+ characterSectionEditorDirty = false;
3584
+ $("#character-section-editor-view").classList.add("hidden");
3585
+ $("#character-editor-form").classList.remove("hidden");
3586
+ $("#character-section-editor-host").innerHTML = "";
3587
+ replacePageRoute(currentPageRoute());
3588
+ return true;
3589
+ }
3590
+
3462
3591
  function characterSectionEditorHtml(section = null) {
3463
3592
  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 章节">
3593
+ return `<div class="character-section-editor-shell">
3594
+ <header class="character-section-editor-header">
3595
+ <div><span class="eyebrow">人物 Markdown 档案</span><h2 id="character-section-editor-title">${section ? `编辑“${esc(section.title)}”` : "新建档案章节"}</h2></div>
3596
+ <button class="entity-editor-back" type="button" data-character-section-edit-close>返回人物档案</button>
3597
+ </header>
3598
+ <section class="character-markdown-editor" aria-label="${section ? "编辑" : "新建"}人物 Markdown 章节">
3465
3599
  <div class="character-markdown-editor-meta">
3466
3600
  <label>章节类型<select id="character-section-type">${options}</select></label>
3467
3601
  <label>章节标题<input id="character-section-title" maxlength="200" value="${esc(section?.title ?? "")}" placeholder="例如:背景故事" required></label>
@@ -3476,17 +3610,23 @@ function characterSectionEditorHtml(section = null) {
3476
3610
  <label>Markdown 原文<textarea id="character-section-markdown" maxlength="500000" spellcheck="true" placeholder="支持标题、列表、引用、表格、链接和图片">${esc(section?.contentMarkdown ?? "")}</textarea></label>
3477
3611
  <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
3612
  </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>`;
3613
+ <div class="character-markdown-editor-footer">
3614
+ <label class="character-markdown-change-note">版本说明<input id="character-section-change-note" maxlength="500" placeholder="可选,例如:补充远古时期经历"></label>
3615
+ <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>
3616
+ </div>
3617
+ </section>
3618
+ </div>`;
3482
3619
  }
3483
3620
 
3484
3621
  async function openCharacterSectionEditor(section = null) {
3485
3622
  await discardPendingCharacterAttachments();
3486
- const host = $("#character-markdown-sections");
3487
- if (!host) return;
3623
+ const host = $("#character-section-editor-host");
3488
3624
  host.innerHTML = characterSectionEditorHtml(section);
3625
+ characterSectionEditorDirty = false;
3626
+ $("#character-editor-form").classList.add("hidden");
3627
+ $("#character-section-editor-view").classList.remove("hidden");
3489
3628
  const textarea = $("#character-section-markdown");
3629
+ host.querySelectorAll("input, textarea, select").forEach((control) => control.addEventListener("input", () => { characterSectionEditorDirty = true; }));
3490
3630
  textarea.addEventListener("input", scheduleCharacterSectionPreview);
3491
3631
  $("#character-section-attachment").addEventListener("change", async (event) => {
3492
3632
  const file = event.target.files?.[0];
@@ -3506,6 +3646,7 @@ async function openCharacterSectionEditor(section = null) {
3506
3646
  const suffix = end < textarea.value.length && !textarea.value.slice(end).startsWith("\n") ? "\n\n" : "";
3507
3647
  textarea.setRangeText(`${prefix}${insertion}${suffix}`, start, end, "end");
3508
3648
  textarea.focus();
3649
+ characterSectionEditorDirty = true;
3509
3650
  scheduleCharacterSectionPreview();
3510
3651
  toast(attachment.storedMimeType === "image/webp" ? "图片已转换为无损 WebP 并插入" : "图片已插入;转换后未变小,因此保留原格式");
3511
3652
  } catch (error) {
@@ -3515,10 +3656,8 @@ async function openCharacterSectionEditor(section = null) {
3515
3656
  input.value = "";
3516
3657
  }
3517
3658
  });
3518
- host.querySelector("[data-character-section-edit-cancel]").addEventListener("click", async () => {
3519
- await discardPendingCharacterAttachments();
3520
- renderCharacterMarkdownSections();
3521
- });
3659
+ host.querySelector("[data-character-section-edit-close]").addEventListener("click", () => void closeCharacterSectionEditor());
3660
+ host.querySelector("[data-character-section-edit-cancel]").addEventListener("click", () => void closeCharacterSectionEditor());
3522
3661
  host.querySelector("[data-character-section-edit-save]").addEventListener("click", async (event) => {
3523
3662
  const button = event.currentTarget;
3524
3663
  const title = $("#character-section-title").value.trim();
@@ -3547,6 +3686,8 @@ async function openCharacterSectionEditor(section = null) {
3547
3686
  characterEditorSections = await api(`/api/characters/${characterEditorItem.id}/sections`);
3548
3687
  renderCharacterMarkdownSections();
3549
3688
  await Promise.all([renderCharacters(), loadAiReferences()]);
3689
+ characterSectionEditorDirty = false;
3690
+ await closeCharacterSectionEditor({ force: true });
3550
3691
  toast(section ? `“${saved.title}”已保存为 v${saved.versionNo}` : `已创建“${saved.title}”`);
3551
3692
  } catch (error) {
3552
3693
  toast(error.message, "error");
@@ -3780,10 +3921,10 @@ async function showCharacterHistory() {
3780
3921
  }
3781
3922
  }
3782
3923
 
3783
- async function openCharacterDialog(item) {
3924
+ async function openCharacterEditor(item = null) {
3784
3925
  [state.races, state.organizations] = await Promise.all([
3785
- api(`/api/works/${state.work.id}/races`),
3786
- api(`/api/works/${state.work.id}/organizations`)
3926
+ apiAllPages(`/api/works/${state.work.id}/races`),
3927
+ apiAllPages(`/api/works/${state.work.id}/organizations`)
3787
3928
  ]);
3788
3929
  characterEditorItem = item ?? null;
3789
3930
  characterEditorVersions = [];
@@ -3805,13 +3946,14 @@ async function openCharacterDialog(item) {
3805
3946
  $("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
3806
3947
  $("#character-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
3807
3948
  }
3949
+ $("#character-change-note").readOnly = viewOnly;
3950
+ $("#character-editor-submit").classList.toggle("hidden", viewOnly);
3808
3951
  document.querySelectorAll("[data-character-editor-tab]").forEach((button) => {
3809
3952
  button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
3810
3953
  });
3811
3954
  const relationshipTab = document.querySelector("[data-character-editor-tab='relationships']");
3812
3955
  relationshipTab.disabled = !item;
3813
3956
  relationshipTab.title = item ? "查看和编辑人物关系" : "创建人物档案后即可维护人物关系";
3814
- const dialog = $("#character-editor-dialog");
3815
3957
  const form = $("#character-editor-form");
3816
3958
  form.onsubmit = async (event) => {
3817
3959
  event.preventDefault();
@@ -3825,8 +3967,9 @@ async function openCharacterDialog(item) {
3825
3967
  const previousVersion = characterEditorItem?.versionNo;
3826
3968
  if (!wasEditing) delete body.changeNote;
3827
3969
  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()]);
3970
+ entityEditorDirty = false;
3971
+ await loadAiReferences();
3972
+ await closeEntityEditor({ force: true });
3830
3973
  toast(!wasEditing ? "人物档案已创建" : saved.versionNo === previousVersion ? "没有检测到人物档案变更" : `人物档案已保存为 v${saved.versionNo}`);
3831
3974
  } catch (error) {
3832
3975
  toast(error.message, "error");
@@ -3834,7 +3977,7 @@ async function openCharacterDialog(item) {
3834
3977
  submit.disabled = false;
3835
3978
  }
3836
3979
  };
3837
- dialog.showModal();
3980
+ showEntityEditorPage("character");
3838
3981
  if (item) {
3839
3982
  void loadCharacterEditorRelationships(item.id);
3840
3983
  void loadCharacterMarkdownSections(item.id);
@@ -3842,7 +3985,7 @@ async function openCharacterDialog(item) {
3842
3985
  }
3843
3986
 
3844
3987
  async function openRaceDialog(item) {
3845
- state.characters = await api(`/api/works/${state.work.id}/characters`);
3988
+ state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
3846
3989
  const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
3847
3990
  const parentOptions = [["", "无(根种族)"], ...eligibleRaceParents(state.races, item?.id)
3848
3991
  .sort((left, right) => racePathLabel(left).localeCompare(racePathLabel(right), "zh-CN"))
@@ -3863,7 +4006,7 @@ async function openRaceDialog(item) {
3863
4006
  }
3864
4007
 
3865
4008
  async function openOrganizationDialog(item) {
3866
- state.characters = await api(`/api/works/${state.work.id}/characters`);
4009
+ state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
3867
4010
  const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
3868
4011
  openDialog(item ? "编辑组织" : "新建组织",
3869
4012
  field("name", "组织名称", "text", item?.name) +
@@ -3968,7 +4111,7 @@ function openTimelineSplitDialog(item) {
3968
4111
  }
3969
4112
 
3970
4113
  async function openRelationshipDialog(item, options = {}) {
3971
- state.characters = await api(`/api/works/${state.work.id}/characters`);
4114
+ state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
3972
4115
  if (state.characters.length < 2) return toast("至少需要两个角色才能创建关系", "error");
3973
4116
  const characterOptions = state.characters.map((item) => [item.id, item.name]);
3974
4117
  const defaultFrom = options.characterId && state.characters.some((character) => character.id === options.characterId) ? options.characterId : characterOptions[0][0];
@@ -4596,9 +4739,16 @@ $("#versions-button").addEventListener("click", showVersions);
4596
4739
  $("#versions-close").addEventListener("click", () => $("#versions-dialog").close());
4597
4740
  $("#entity-history-close").addEventListener("click", () => $("#entity-history-dialog").close());
4598
4741
  $("#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(); });
4742
+ $("#setting-editor-back").addEventListener("click", () => { void closeEntityEditor(); });
4743
+ $("#character-editor-close").addEventListener("click", () => { void closeEntityEditor(); });
4744
+ $("#character-editor-cancel").addEventListener("click", () => { void closeEntityEditor(); });
4745
+ $("#setting-editor-form").addEventListener("input", markEntityEditorDirty);
4746
+ $("#setting-editor-form").addEventListener("change", markEntityEditorDirty);
4747
+ $("#character-editor-form").addEventListener("input", markEntityEditorDirty);
4748
+ $("#character-editor-form").addEventListener("change", markEntityEditorDirty);
4749
+ $("#character-editor-fields").addEventListener("click", (event) => {
4750
+ if (event.target.closest("[data-item-list-add], [data-structured-list-add], [data-item-list-remove], [data-structured-list-remove]")) markEntityEditorDirty();
4751
+ });
4602
4752
  $("#character-history-button").addEventListener("click", () => {
4603
4753
  if ($("#character-history-panel").classList.contains("hidden")) void showCharacterHistory();
4604
4754
  else setCharacterHistoryVisible(false);
@@ -4697,7 +4847,7 @@ $("#module-nav").addEventListener("click", (event) => {
4697
4847
  if (button.dataset.module) showModule(button.dataset.module);
4698
4848
  });
4699
4849
  $("#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]?.());
4850
+ $("#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
4851
  $("#ai-prompt").addEventListener("input", async () => {
4702
4852
  updateAiMentionMenu();
4703
4853
  scheduleAiContextUsage();
@@ -4769,7 +4919,7 @@ $("#cover-file").addEventListener("change", async (event) => {
4769
4919
  body.append("file", file);
4770
4920
  try {
4771
4921
  await api(`/api/works/${workId}/cover`, { method: "PUT", body });
4772
- state.works = await api("/api/works");
4922
+ state.works = (await apiPage("/api/works")).items;
4773
4923
  const updated = state.works.find((item) => item.id === workId);
4774
4924
  const coverField = $("#dialog-fields")?.querySelector(".work-cover-field");
4775
4925
  if (updated && coverField && $("#form-dialog")?.open) {
@@ -4908,7 +5058,7 @@ $("#search-form").addEventListener("submit", async (event) => {
4908
5058
  $("#export-button").addEventListener("click", () => {
4909
5059
  if (state.work) window.location.href = `/api/works/${state.work.id}/export?format=markdown`;
4910
5060
  });
4911
- window.addEventListener("beforeunload", (event) => { if (state.dirty) event.preventDefault(); });
5061
+ window.addEventListener("beforeunload", (event) => { if (state.dirty || entityEditorDirty || characterSectionEditorDirty) event.preventDefault(); });
4912
5062
 
4913
5063
  initializePage().catch((error) => {
4914
5064
  restoringPageRoute = false;