@musnows/scriverse 0.3.6 → 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,10 +14,11 @@ 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";
21
+ import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
21
22
 
22
23
  const state = {
23
24
  user: null,
@@ -351,6 +352,10 @@ function replacePageRoute(route) {
351
352
 
352
353
  function currentPageRoute() {
353
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
+ }
354
359
  if (!$("#settings-hub-view").classList.contains("hidden")) return { view: "settings", workId, ...settingsRouteContext() };
355
360
  if (!$("#platform-ai-view").classList.contains("hidden")) return { view: "platform-ai", workId, ...settingsRouteContext() };
356
361
  if (!$("#shelf-view").classList.contains("hidden")) return { view: "shelf" };
@@ -460,6 +465,9 @@ const chapterAutoSaveDelay = 800;
460
465
  let aiMentionMatch = null;
461
466
  let aiMentionRange = null;
462
467
  let settingsReturnContext = null;
468
+ let entityEditorType = null;
469
+ let entityEditorDirty = false;
470
+ let settingEditorItem = null;
463
471
  let characterEditorItem = null;
464
472
  let characterEditorVersions = [];
465
473
  let characterEditorRelationships = [];
@@ -467,8 +475,49 @@ let characterEditorRelationshipsLoading = false;
467
475
  let characterEditorSections = [];
468
476
  let characterSectionPreviewTimer = null;
469
477
  let characterSectionPendingAttachments = [];
478
+ let characterSectionEditorDirty = false;
470
479
  let entityHistoryContext = null;
471
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
+
472
521
  function setModuleNavExpanded(expanded) {
473
522
  moduleNavExpanded = expanded;
474
523
  $("#module-more-button .nav-label").textContent = expanded ? "收起" : "更多";
@@ -1015,7 +1064,7 @@ function renderAiConversationHistory() {
1015
1064
  async function loadAiConversations(openLatest = true) {
1016
1065
  const workId = state.work?.id;
1017
1066
  if (!workId) return;
1018
- const conversations = await api(`/api/works/${workId}/ai-conversations`);
1067
+ const conversations = (await apiPage(`/api/works/${workId}/ai-conversations`)).items;
1019
1068
  if (state.work?.id !== workId) return;
1020
1069
  state.aiConversations = conversations;
1021
1070
  loadedAiConversationsWorkId = workId;
@@ -1044,7 +1093,7 @@ async function ensureAiConversationsLoaded() {
1044
1093
  }
1045
1094
 
1046
1095
  async function openAiConversation(conversationId, hideHistory = true) {
1047
- const conversation = await api(`/api/ai-conversations/${conversationId}`);
1096
+ const conversation = await api(`/api/ai-conversations/${conversationId}?page=1&limit=100`);
1048
1097
  state.aiConversationId = conversation.id;
1049
1098
  state.aiPromptSent = conversation.messages.some((message) => message.role === "user");
1050
1099
  $("#ai-conversation-title").textContent = conversation.title;
@@ -1395,6 +1444,24 @@ async function api(path, options = {}) {
1395
1444
  return payload.data;
1396
1445
  }
1397
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
+
1398
1465
  function selectAuthMode(mode) {
1399
1466
  const registerTab = $("#auth-register-tab");
1400
1467
  const login = mode === "login" || registerTab.disabled;
@@ -1633,7 +1700,7 @@ function updateDocumentTitle(work = null) {
1633
1700
  }
1634
1701
 
1635
1702
  async function loadWorks(preferredId) {
1636
- state.works = await api("/api/works");
1703
+ state.works = (await apiPage("/api/works")).items;
1637
1704
  if (preferredId) {
1638
1705
  await selectWork(preferredId);
1639
1706
  return;
@@ -1657,7 +1724,7 @@ async function initializePage() {
1657
1724
  return;
1658
1725
  }
1659
1726
  const route = parsePageRoute(window.location.hash);
1660
- state.works = await api("/api/works");
1727
+ state.works = (await apiPage("/api/works")).items;
1661
1728
  try {
1662
1729
  if (route.view === "shelf") {
1663
1730
  showShelf();
@@ -1671,16 +1738,30 @@ async function initializePage() {
1671
1738
  }
1672
1739
 
1673
1740
  if (requestedWork) {
1674
- 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";
1675
1746
  await selectWork(requestedWork.id, route.view === "editor" ? route.chapterId : null);
1676
1747
  }
1677
1748
 
1678
1749
  if (route.view === "editor") {
1679
- const chapterExists = state.work?.volumes.some((volume) => volume.chapters.some((chapter) => chapter.id === route.chapterId));
1680
- 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);
1681
1751
  return;
1682
1752
  }
1683
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
+ }
1684
1765
  if (route.view === "welcome") {
1685
1766
  showWelcome(true);
1686
1767
  return;
@@ -1783,7 +1864,7 @@ async function openUsersDialog() {
1783
1864
  }
1784
1865
  $("#users-list").innerHTML = '<p class="empty-state">正在读取用户……</p>';
1785
1866
  $("#users-dialog").showModal();
1786
- try { renderUsers(await api("/api/users")); }
1867
+ try { renderUsers((await apiPage("/api/users")).items); }
1787
1868
  catch (error) { $("#users-dialog").close(); toast(error.message, "error"); }
1788
1869
  }
1789
1870
 
@@ -1920,13 +2001,13 @@ async function openSearchResult(result) {
1920
2001
  if (result.type === "character") {
1921
2002
  await showModule("characters");
1922
2003
  const character = state.characters.find((item) => item.id === result.id);
1923
- if (character) openCharacterDialog(character);
2004
+ if (character) openCharacterEditor(character);
1924
2005
  return;
1925
2006
  }
1926
2007
  if (result.type === "setting") {
1927
2008
  await showModule("settings");
1928
2009
  const setting = await api(`/api/settings/${encodeURIComponent(result.id)}`);
1929
- openSettingDialog(setting);
2010
+ openSettingEditor(setting);
1930
2011
  return;
1931
2012
  }
1932
2013
  if (result.type === "race") {
@@ -2025,7 +2106,7 @@ function renderShelf() {
2025
2106
  async function selectWork(workId, preferredChapterId = null) {
2026
2107
  const discarding = state.work?.id !== workId && state.dirty;
2027
2108
  if (discarding && !confirmDiscardChanges()) return false;
2028
- const nextWork = await api(`/api/works/${workId}`);
2109
+ const nextWork = await api(`/api/works/${workId}?page=1&limit=100`);
2029
2110
  if (state.work?.id !== nextWork.id) {
2030
2111
  loadedAiModelsWorkId = null;
2031
2112
  loadedAiReferencesWorkId = null;
@@ -2066,7 +2147,8 @@ async function selectWork(workId, preferredChapterId = null) {
2066
2147
  renderTree();
2067
2148
  const chapters = state.work.volumes.flatMap((volume) => volume.chapters);
2068
2149
  const targetChapter = chapters.find((chapter) => chapter.id === preferredChapterId) ?? chapters[0];
2069
- 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);
2070
2152
  else if (state.module === "editor") showWelcome(true);
2071
2153
  else await showModule(state.module);
2072
2154
  return true;
@@ -2323,7 +2405,7 @@ function bindEntityHistoryButtons(refresh) {
2323
2405
  }
2324
2406
 
2325
2407
  async function renderSettings() {
2326
- const records = await api(`/api/works/${state.work.id}/settings`);
2408
+ const records = (await apiPage(`/api/works/${state.work.id}/settings`)).items;
2327
2409
  state.settings = records;
2328
2410
  $("#module-content").innerHTML = records.length ? `<div class="card-grid">${records.map((item) => `
2329
2411
  <article class="record-card"><small>${esc(item.category)} · ${item.locked ? "已锁定" : esc(item.status)}</small>
@@ -2335,15 +2417,15 @@ async function renderSettings() {
2335
2417
  await renderSettings();
2336
2418
  await loadAiReferences();
2337
2419
  }));
2338
- $("#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))));
2339
2421
  bindEntityHistoryButtons(async () => { await renderSettings(); await loadAiReferences(); });
2340
2422
  }
2341
2423
 
2342
2424
  async function renderCharacters() {
2343
2425
  [state.characters, state.races, state.organizations] = await Promise.all([
2344
- api(`/api/works/${state.work.id}/characters`),
2345
- api(`/api/works/${state.work.id}/races`),
2346
- 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`)
2347
2429
  ]);
2348
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>`;
2349
2431
  $("#module-content").innerHTML = auditPanel + (state.characters.length ? `<div class="card-grid">${state.characters.map((item) => {
@@ -2373,7 +2455,7 @@ async function renderCharacters() {
2373
2455
  }
2374
2456
  });
2375
2457
  $("#module-content").querySelectorAll("[data-open-character]").forEach((card) => {
2376
- 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));
2377
2459
  card.addEventListener("click", (event) => { if (!event.target.closest("button")) open(); });
2378
2460
  card.addEventListener("keydown", (event) => {
2379
2461
  if (event.key !== "Enter" && event.key !== " ") return;
@@ -2381,13 +2463,13 @@ async function renderCharacters() {
2381
2463
  open();
2382
2464
  });
2383
2465
  });
2384
- $("#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))));
2385
2467
  }
2386
2468
 
2387
2469
  async function renderRaces() {
2388
2470
  [state.races, state.characters] = await Promise.all([
2389
- api(`/api/works/${state.work.id}/races`),
2390
- api(`/api/works/${state.work.id}/characters`)
2471
+ apiAllPages(`/api/works/${state.work.id}/races`),
2472
+ apiAllPages(`/api/works/${state.work.id}/characters`)
2391
2473
  ]);
2392
2474
  const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
2393
2475
  <summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
@@ -2409,8 +2491,8 @@ async function renderRaces() {
2409
2491
 
2410
2492
  async function renderOrganizations() {
2411
2493
  [state.organizations, state.characters] = await Promise.all([
2412
- api(`/api/works/${state.work.id}/organizations`),
2413
- api(`/api/works/${state.work.id}/characters`)
2494
+ apiAllPages(`/api/works/${state.work.id}/organizations`),
2495
+ apiAllPages(`/api/works/${state.work.id}/characters`)
2414
2496
  ]);
2415
2497
  $("#module-content").innerHTML = state.organizations.length ? `<div class="card-grid organization-grid">${state.organizations.map((item) => `
2416
2498
  <article class="record-card organization-card"><small>${item.memberIds.length} 位成员 · ${item.settings.length} 条设定</small>
@@ -2425,8 +2507,8 @@ async function renderOrganizations() {
2425
2507
 
2426
2508
  async function renderTimeline() {
2427
2509
  const [events, tracks] = await Promise.all([
2428
- api(`/api/works/${state.work.id}/timeline`),
2429
- 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`)
2430
2512
  ]);
2431
2513
  state.timelineTracks = tracks;
2432
2514
  const lanes = [...tracks, { id: "", name: "未分组时间轴", description: "尚未归入独立大事件的时间节点。", sortOrder: Number.MAX_SAFE_INTEGER }];
@@ -2454,8 +2536,8 @@ async function renderTimeline() {
2454
2536
  async function renderOutlines() {
2455
2537
  const currentChapterId = state.chapter?.id;
2456
2538
  const [outlines, foreshadows] = await Promise.all([
2457
- api(`/api/works/${state.work.id}/outlines`),
2458
- 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)
2459
2541
  ]);
2460
2542
  const unresolved = foreshadows.filter((item) => item.unresolved);
2461
2543
  const overdue = unresolved.filter((item) => item.overdue);
@@ -2483,8 +2565,8 @@ async function renderOutlines() {
2483
2565
  }
2484
2566
 
2485
2567
  async function renderRelationships() {
2486
- state.characters = await api(`/api/works/${state.work.id}/characters`);
2487
- 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;
2488
2570
  const nameOf = (id) => state.characters.find((item) => item.id === id)?.name ?? "未知角色";
2489
2571
  state.galaxy?.destroy();
2490
2572
  state.relationshipExpandedMap?.destroy?.();
@@ -2515,8 +2597,8 @@ async function renderRelationships() {
2515
2597
 
2516
2598
  async function renderReviews() {
2517
2599
  const [reviews, characters] = await Promise.all([
2518
- api(`/api/works/${state.work.id}/reviews`),
2519
- 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`)
2520
2602
  ]);
2521
2603
  const characterById = new Map(characters.map((character) => [character.id, character]));
2522
2604
  const duplicateCard = (item) => {
@@ -2576,7 +2658,7 @@ async function renderReviews() {
2576
2658
 
2577
2659
  async function renderTasks() {
2578
2660
  const [tasks, settings] = await Promise.all([
2579
- api(`/api/works/${state.work.id}/tasks`),
2661
+ apiPage(`/api/works/${state.work.id}/tasks`).then((result) => result.items),
2580
2662
  api(`/api/works/${state.work.id}/ai-settings`)
2581
2663
  ]);
2582
2664
  const pendingCount = tasks.filter((item) => item.status === "pending").length;
@@ -3011,8 +3093,8 @@ async function loadAiReferences() {
3011
3093
  const workId = state.work?.id;
3012
3094
  if (!workId) return;
3013
3095
  const [characters, settings] = await Promise.all([
3014
- api(`/api/works/${workId}/characters`),
3015
- api(`/api/works/${workId}/settings`)
3096
+ apiAllPages(`/api/works/${workId}/characters`),
3097
+ apiAllPages(`/api/works/${workId}/settings`)
3016
3098
  ]);
3017
3099
  if (state.work?.id !== workId) return;
3018
3100
  state.characters = characters;
@@ -3229,7 +3311,7 @@ function bindWorkCoverControls(work) {
3229
3311
  $("#work-cover-remove")?.addEventListener("click", async () => {
3230
3312
  try {
3231
3313
  await api(`/api/works/${work.id}/cover`, { method: "DELETE" });
3232
- state.works = await api("/api/works");
3314
+ state.works = (await apiPage("/api/works")).items;
3233
3315
  const updated = state.works.find((item) => item.id === work.id) ?? { ...work, coverUrl: null };
3234
3316
  Object.assign(work, updated);
3235
3317
  const coverField = $("#dialog-fields")?.querySelector(".work-cover-field");
@@ -3256,7 +3338,7 @@ function openWorkSettingsDialog(work) {
3256
3338
  workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + accessField,
3257
3339
  async (form) => {
3258
3340
  await api(`/api/works/${work.id}`, { method: "PATCH", body: { title: form.get("title"), author: form.get("author"), description: form.get("description") } });
3259
- state.works = await api("/api/works");
3341
+ state.works = (await apiPage("/api/works")).items;
3260
3342
  const updated = state.works.find((item) => item.id === work.id);
3261
3343
  if (updated) Object.assign(work, updated);
3262
3344
  if (state.work?.id === work.id) {
@@ -3313,17 +3395,50 @@ function openVolumeDialog(item) {
3313
3395
  }, "分卷设置");
3314
3396
  }
3315
3397
 
3316
- function openSettingDialog(item) {
3317
- openDialog(item ? "编辑设定" : "新建设定",
3318
- field("title", "标题", "text", item?.title) +
3319
- field("category", "分类", "select", item?.category ?? "世界规则", [["世界规则", "世界规则"], ["历史与年代", "历史与年代"], ["地点与地图", "地点与地图"], ["组织与阵营", "组织与阵营"], ["物种与族群", "物种与族群"], ["科技与物品", "科技与物品"], ["术语与称谓", "术语与称谓"], ["创作约束", "创作约束"]]) +
3320
- field("content", "设定说明", "textarea", item?.content) + field("locked", "锁定为 AI 硬约束", "checkbox", item?.locked),
3321
- async (form) => {
3322
- 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
+ };
3323
3429
  await api(item ? `/api/settings/${item.id}` : `/api/works/${state.work.id}/settings`, { method: item ? "PATCH" : "POST", body });
3324
- await renderSettings();
3430
+ entityEditorDirty = false;
3325
3431
  await loadAiReferences();
3326
- }, 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();
3327
3442
  }
3328
3443
 
3329
3444
  function characterEditorSection(key, title, description, content) {
@@ -3399,8 +3514,8 @@ async function loadCharacterEditorRelationships(characterId) {
3399
3514
  let loaded = false;
3400
3515
  try {
3401
3516
  const [characters, relationships] = await Promise.all([
3402
- api(`/api/works/${workId}/characters`),
3403
- api(`/api/works/${workId}/relationships`)
3517
+ apiAllPages(`/api/works/${workId}/characters`),
3518
+ apiAllPages(`/api/works/${workId}/relationships`)
3404
3519
  ]);
3405
3520
  if (state.work?.id !== workId || characterEditorItem?.id !== characterId) return;
3406
3521
  state.characters = characters;
@@ -3421,7 +3536,7 @@ async function loadCharacterEditorRelationships(characterId) {
3421
3536
  async function refreshRelationshipSurfaces(characterId = null) {
3422
3537
  const tasks = [];
3423
3538
  if (state.module === "relationships") tasks.push(renderRelationships());
3424
- if (characterId && $("#character-editor-dialog").open && characterEditorItem?.id === characterId) {
3539
+ if (characterId && entityEditorType === "character" && !$("#entity-editor-view").classList.contains("hidden") && characterEditorItem?.id === characterId) {
3425
3540
  tasks.push(loadCharacterEditorRelationships(characterId));
3426
3541
  }
3427
3542
  await Promise.all(tasks);
@@ -3458,9 +3573,29 @@ function scheduleCharacterSectionPreview() {
3458
3573
  }, 260);
3459
3574
  }
3460
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
+
3461
3591
  function characterSectionEditorHtml(section = null) {
3462
3592
  const options = Object.entries(characterSectionTypeLabels).map(([value, label]) => `<option value="${value}" ${section?.sectionType === value ? "selected" : ""}>${esc(label)}</option>`).join("");
3463
- 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 章节">
3464
3599
  <div class="character-markdown-editor-meta">
3465
3600
  <label>章节类型<select id="character-section-type">${options}</select></label>
3466
3601
  <label>章节标题<input id="character-section-title" maxlength="200" value="${esc(section?.title ?? "")}" placeholder="例如:背景故事" required></label>
@@ -3475,17 +3610,23 @@ function characterSectionEditorHtml(section = null) {
3475
3610
  <label>Markdown 原文<textarea id="character-section-markdown" maxlength="500000" spellcheck="true" placeholder="支持标题、列表、引用、表格、链接和图片">${esc(section?.contentMarkdown ?? "")}</textarea></label>
3476
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>
3477
3612
  </div>
3478
- <label class="character-markdown-change-note">版本说明<input id="character-section-change-note" maxlength="500" placeholder="可选,例如:补充远古时期经历"></label>
3479
- <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>
3480
- </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>`;
3481
3619
  }
3482
3620
 
3483
3621
  async function openCharacterSectionEditor(section = null) {
3484
3622
  await discardPendingCharacterAttachments();
3485
- const host = $("#character-markdown-sections");
3486
- if (!host) return;
3623
+ const host = $("#character-section-editor-host");
3487
3624
  host.innerHTML = characterSectionEditorHtml(section);
3625
+ characterSectionEditorDirty = false;
3626
+ $("#character-editor-form").classList.add("hidden");
3627
+ $("#character-section-editor-view").classList.remove("hidden");
3488
3628
  const textarea = $("#character-section-markdown");
3629
+ host.querySelectorAll("input, textarea, select").forEach((control) => control.addEventListener("input", () => { characterSectionEditorDirty = true; }));
3489
3630
  textarea.addEventListener("input", scheduleCharacterSectionPreview);
3490
3631
  $("#character-section-attachment").addEventListener("change", async (event) => {
3491
3632
  const file = event.target.files?.[0];
@@ -3505,6 +3646,7 @@ async function openCharacterSectionEditor(section = null) {
3505
3646
  const suffix = end < textarea.value.length && !textarea.value.slice(end).startsWith("\n") ? "\n\n" : "";
3506
3647
  textarea.setRangeText(`${prefix}${insertion}${suffix}`, start, end, "end");
3507
3648
  textarea.focus();
3649
+ characterSectionEditorDirty = true;
3508
3650
  scheduleCharacterSectionPreview();
3509
3651
  toast(attachment.storedMimeType === "image/webp" ? "图片已转换为无损 WebP 并插入" : "图片已插入;转换后未变小,因此保留原格式");
3510
3652
  } catch (error) {
@@ -3514,10 +3656,8 @@ async function openCharacterSectionEditor(section = null) {
3514
3656
  input.value = "";
3515
3657
  }
3516
3658
  });
3517
- host.querySelector("[data-character-section-edit-cancel]").addEventListener("click", async () => {
3518
- await discardPendingCharacterAttachments();
3519
- renderCharacterMarkdownSections();
3520
- });
3659
+ host.querySelector("[data-character-section-edit-close]").addEventListener("click", () => void closeCharacterSectionEditor());
3660
+ host.querySelector("[data-character-section-edit-cancel]").addEventListener("click", () => void closeCharacterSectionEditor());
3521
3661
  host.querySelector("[data-character-section-edit-save]").addEventListener("click", async (event) => {
3522
3662
  const button = event.currentTarget;
3523
3663
  const title = $("#character-section-title").value.trim();
@@ -3546,6 +3686,8 @@ async function openCharacterSectionEditor(section = null) {
3546
3686
  characterEditorSections = await api(`/api/characters/${characterEditorItem.id}/sections`);
3547
3687
  renderCharacterMarkdownSections();
3548
3688
  await Promise.all([renderCharacters(), loadAiReferences()]);
3689
+ characterSectionEditorDirty = false;
3690
+ await closeCharacterSectionEditor({ force: true });
3549
3691
  toast(section ? `“${saved.title}”已保存为 v${saved.versionNo}` : `已创建“${saved.title}”`);
3550
3692
  } catch (error) {
3551
3693
  toast(error.message, "error");
@@ -3779,10 +3921,10 @@ async function showCharacterHistory() {
3779
3921
  }
3780
3922
  }
3781
3923
 
3782
- async function openCharacterDialog(item) {
3924
+ async function openCharacterEditor(item = null) {
3783
3925
  [state.races, state.organizations] = await Promise.all([
3784
- api(`/api/works/${state.work.id}/races`),
3785
- api(`/api/works/${state.work.id}/organizations`)
3926
+ apiAllPages(`/api/works/${state.work.id}/races`),
3927
+ apiAllPages(`/api/works/${state.work.id}/organizations`)
3786
3928
  ]);
3787
3929
  characterEditorItem = item ?? null;
3788
3930
  characterEditorVersions = [];
@@ -3804,13 +3946,14 @@ async function openCharacterDialog(item) {
3804
3946
  $("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
3805
3947
  $("#character-editor-fields").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = true; });
3806
3948
  }
3949
+ $("#character-change-note").readOnly = viewOnly;
3950
+ $("#character-editor-submit").classList.toggle("hidden", viewOnly);
3807
3951
  document.querySelectorAll("[data-character-editor-tab]").forEach((button) => {
3808
3952
  button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
3809
3953
  });
3810
3954
  const relationshipTab = document.querySelector("[data-character-editor-tab='relationships']");
3811
3955
  relationshipTab.disabled = !item;
3812
3956
  relationshipTab.title = item ? "查看和编辑人物关系" : "创建人物档案后即可维护人物关系";
3813
- const dialog = $("#character-editor-dialog");
3814
3957
  const form = $("#character-editor-form");
3815
3958
  form.onsubmit = async (event) => {
3816
3959
  event.preventDefault();
@@ -3824,8 +3967,9 @@ async function openCharacterDialog(item) {
3824
3967
  const previousVersion = characterEditorItem?.versionNo;
3825
3968
  if (!wasEditing) delete body.changeNote;
3826
3969
  const saved = await api(wasEditing ? `/api/characters/${characterEditorItem.id}` : `/api/works/${state.work.id}/characters`, { method: wasEditing ? "PATCH" : "POST", body });
3827
- dialog.close();
3828
- await Promise.all([renderCharacters(), loadAiReferences()]);
3970
+ entityEditorDirty = false;
3971
+ await loadAiReferences();
3972
+ await closeEntityEditor({ force: true });
3829
3973
  toast(!wasEditing ? "人物档案已创建" : saved.versionNo === previousVersion ? "没有检测到人物档案变更" : `人物档案已保存为 v${saved.versionNo}`);
3830
3974
  } catch (error) {
3831
3975
  toast(error.message, "error");
@@ -3833,7 +3977,7 @@ async function openCharacterDialog(item) {
3833
3977
  submit.disabled = false;
3834
3978
  }
3835
3979
  };
3836
- dialog.showModal();
3980
+ showEntityEditorPage("character");
3837
3981
  if (item) {
3838
3982
  void loadCharacterEditorRelationships(item.id);
3839
3983
  void loadCharacterMarkdownSections(item.id);
@@ -3841,7 +3985,7 @@ async function openCharacterDialog(item) {
3841
3985
  }
3842
3986
 
3843
3987
  async function openRaceDialog(item) {
3844
- state.characters = await api(`/api/works/${state.work.id}/characters`);
3988
+ state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
3845
3989
  const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
3846
3990
  const parentOptions = [["", "无(根种族)"], ...eligibleRaceParents(state.races, item?.id)
3847
3991
  .sort((left, right) => racePathLabel(left).localeCompare(racePathLabel(right), "zh-CN"))
@@ -3862,7 +4006,7 @@ async function openRaceDialog(item) {
3862
4006
  }
3863
4007
 
3864
4008
  async function openOrganizationDialog(item) {
3865
- state.characters = await api(`/api/works/${state.work.id}/characters`);
4009
+ state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
3866
4010
  const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
3867
4011
  openDialog(item ? "编辑组织" : "新建组织",
3868
4012
  field("name", "组织名称", "text", item?.name) +
@@ -3967,7 +4111,7 @@ function openTimelineSplitDialog(item) {
3967
4111
  }
3968
4112
 
3969
4113
  async function openRelationshipDialog(item, options = {}) {
3970
- state.characters = await api(`/api/works/${state.work.id}/characters`);
4114
+ state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
3971
4115
  if (state.characters.length < 2) return toast("至少需要两个角色才能创建关系", "error");
3972
4116
  const characterOptions = state.characters.map((item) => [item.id, item.name]);
3973
4117
  const defaultFrom = options.characterId && state.characters.some((character) => character.id === options.characterId) ? options.characterId : characterOptions[0][0];
@@ -3988,11 +4132,18 @@ function openReviewDialog() {
3988
4132
 
3989
4133
  function openTaskDialog() {
3990
4134
  const chapterOptions = state.work.volumes.flatMap((volume) => volume.chapters.map((chapter) => [chapter.id, `${volume.title} / ${chapter.title}`]));
3991
- openDialog("开始 AI 分析", field("taskType", "分析类型", "select", "chapter-analysis", [["chapter-analysis", "章节理解"], ["character-extraction", "全书角色抽取"], ["character-identity-audit", "AI 角色查重"], ["timeline-analysis", "时间轴与事件抽取"], ["relationship-analysis", "全书人物关系分析"], ["worldview-analysis", "世界观分析"], ["setting-extraction", "设定抽取"], ["consistency-check", "一致性校对"], ["book-analysis", "全书综合分析"]]) + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + field("chapterId", "章节", "select", chapterOptions[0]?.[0] ?? "", chapterOptions), async (form) => {
4135
+ const defaultTaskType = ANALYSIS_TYPES[0].value;
4136
+ const taskTypeField = `<div class="form-field analysis-type-field"><label>分析类型<select name="taskType" aria-describedby="analysis-type-description">${ANALYSIS_TYPES.map(({ value, label }) => `<option value="${esc(value)}" ${value === defaultTaskType ? "selected" : ""}>${esc(label)}</option>`).join("")}</select></label><p id="analysis-type-description" class="analysis-type-description" aria-live="polite">${esc(analysisTypeDescription(defaultTaskType))}</p></div>`;
4137
+ openDialog("开始 AI 分析", taskTypeField + field("scopeType", "分析范围", "select", "chapter", [["chapter", "指定章节"], ["book", "全书"]]) + field("chapterId", "章节", "select", chapterOptions[0]?.[0] ?? "", chapterOptions), async (form) => {
3992
4138
  const scope = form.get("taskType") === "character-identity-audit" || form.get("scopeType") === "book" ? { type: "book" } : { type: "chapter", chapterId: form.get("chapterId") };
3993
4139
  await api(`/api/works/${state.work.id}/tasks`, { method: "POST", body: { taskType: form.get("taskType"), scope } });
3994
4140
  await renderTasks();
3995
4141
  });
4142
+ const taskTypeSelect = $("#dialog-fields").querySelector('select[name="taskType"]');
4143
+ const description = $("#analysis-type-description");
4144
+ taskTypeSelect.addEventListener("change", () => {
4145
+ description.textContent = analysisTypeDescription(taskTypeSelect.value);
4146
+ });
3996
4147
  }
3997
4148
 
3998
4149
  function openProviderDialog(item) {
@@ -4588,9 +4739,16 @@ $("#versions-button").addEventListener("click", showVersions);
4588
4739
  $("#versions-close").addEventListener("click", () => $("#versions-dialog").close());
4589
4740
  $("#entity-history-close").addEventListener("click", () => $("#entity-history-dialog").close());
4590
4741
  $("#ai-tool-call-close").addEventListener("click", () => $("#ai-tool-call-dialog").close());
4591
- $("#character-editor-close").addEventListener("click", () => $("#character-editor-dialog").close());
4592
- $("#character-editor-cancel").addEventListener("click", () => $("#character-editor-dialog").close());
4593
- $("#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
+ });
4594
4752
  $("#character-history-button").addEventListener("click", () => {
4595
4753
  if ($("#character-history-panel").classList.contains("hidden")) void showCharacterHistory();
4596
4754
  else setCharacterHistoryVisible(false);
@@ -4689,7 +4847,7 @@ $("#module-nav").addEventListener("click", (event) => {
4689
4847
  if (button.dataset.module) showModule(button.dataset.module);
4690
4848
  });
4691
4849
  $("#module-more-button").addEventListener("click", () => setModuleNavExpanded(!moduleNavExpanded));
4692
- $("#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]?.());
4693
4851
  $("#ai-prompt").addEventListener("input", async () => {
4694
4852
  updateAiMentionMenu();
4695
4853
  scheduleAiContextUsage();
@@ -4761,7 +4919,7 @@ $("#cover-file").addEventListener("change", async (event) => {
4761
4919
  body.append("file", file);
4762
4920
  try {
4763
4921
  await api(`/api/works/${workId}/cover`, { method: "PUT", body });
4764
- state.works = await api("/api/works");
4922
+ state.works = (await apiPage("/api/works")).items;
4765
4923
  const updated = state.works.find((item) => item.id === workId);
4766
4924
  const coverField = $("#dialog-fields")?.querySelector(".work-cover-field");
4767
4925
  if (updated && coverField && $("#form-dialog")?.open) {
@@ -4900,7 +5058,7 @@ $("#search-form").addEventListener("submit", async (event) => {
4900
5058
  $("#export-button").addEventListener("click", () => {
4901
5059
  if (state.work) window.location.href = `/api/works/${state.work.id}/export?format=markdown`;
4902
5060
  });
4903
- window.addEventListener("beforeunload", (event) => { if (state.dirty) event.preventDefault(); });
5061
+ window.addEventListener("beforeunload", (event) => { if (state.dirty || entityEditorDirty || characterSectionEditorDirty) event.preventDefault(); });
4904
5062
 
4905
5063
  initializePage().catch((error) => {
4906
5064
  restoringPageRoute = false;