@musnows/scriverse 0.4.0 → 0.4.1

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,4 +1,4 @@
1
- import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260721-release-0.3.6";
1
+ import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260724-relationship-density";
2
2
  import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
3
3
  import { renderMarkdown } from "/markdown.js?v=20260722-inline-code";
4
4
  import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
@@ -21,6 +21,7 @@ import { buildRaceForest, eligibleRaceParents, racePathLabel } from "/race-hiera
21
21
  import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
22
22
  import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260723-ai-analysis-permission";
23
23
  import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout, moduleLayoutLabel } from "/module-layout.js?v=20260723-module-layout-toggle";
24
+ import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
24
25
 
25
26
  const state = {
26
27
  user: null,
@@ -52,6 +53,8 @@ const state = {
52
53
  contextChapterId: null
53
54
  };
54
55
 
56
+ let timelineMultiSelectEnabled = false;
57
+
55
58
  const chapterTypes = ["正文", "设定", "作者的话", "其他"];
56
59
 
57
60
  const taskTypeLabels = MODEL_PURPOSE_OPTIONS;
@@ -623,14 +626,25 @@ function syncChapterLineNumberScroll() {
623
626
  }
624
627
  }
625
628
 
629
+ function syncChapterWhitespaceControls() {
630
+ document.querySelectorAll("[data-toggle-whitespace]").forEach((button) => {
631
+ button.setAttribute("aria-pressed", String(chapterWhitespaceVisible));
632
+ button.textContent = chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符";
633
+ });
634
+ }
635
+
636
+ function toggleChapterWhitespaceVisibility() {
637
+ chapterWhitespaceVisible = !chapterWhitespaceVisible;
638
+ syncChapterWhitespaceControls();
639
+ scheduleChapterLineNumbers();
640
+ }
641
+
626
642
  function renderChapterWhitespaceMarkers(input, style) {
627
643
  const overlay = $("#chapter-whitespace-overlay");
628
644
  const inner = $("#chapter-whitespace-inner");
629
- const button = $("#toggle-whitespace-button");
630
- if (!overlay || !inner || !button) return;
645
+ syncChapterWhitespaceControls();
646
+ if (!overlay || !inner) return;
631
647
  overlay.classList.toggle("is-visible", chapterWhitespaceVisible);
632
- button.setAttribute("aria-pressed", String(chapterWhitespaceVisible));
633
- button.textContent = chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符";
634
648
  if (!chapterWhitespaceVisible) {
635
649
  inner.replaceChildren();
636
650
  return;
@@ -1504,6 +1518,7 @@ function renderTypographyPreview() {
1504
1518
  function openAppearanceDialog() {
1505
1519
  fillAppearanceForm(typographySettings);
1506
1520
  renderTypographyPreview();
1521
+ syncChapterWhitespaceControls();
1507
1522
  $("#appearance-dialog").showModal();
1508
1523
  }
1509
1524
 
@@ -2209,6 +2224,11 @@ async function openSearchDialog() {
2209
2224
  toast("请先打开一部作品", "error");
2210
2225
  return;
2211
2226
  }
2227
+ if ($("#search-dialog").open) {
2228
+ $("#search-query").focus();
2229
+ $("#search-query").select();
2230
+ return;
2231
+ }
2212
2232
  $("#search-dialog .eyebrow").textContent = `当前作品 · 《${state.work.title}》`;
2213
2233
  $("#search-query").value = "";
2214
2234
  $("#search-results").innerHTML = '<p class="search-results-empty">输入关键词后开始检索。</p>';
@@ -2312,7 +2332,7 @@ async function returnFromSettings() {
2312
2332
  $("#app").classList.remove("shelf-mode");
2313
2333
  $("#shelf-view").classList.add("hidden");
2314
2334
  updateDocumentTitle(state.work);
2315
- $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
2335
+ $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
2316
2336
  if (context.view === "module") return showModule(context.module);
2317
2337
  if (context.view === "editor" && context.chapterId) return selectChapter(context.chapterId);
2318
2338
  return showWelcome(true);
@@ -2346,7 +2366,7 @@ function renderShelf() {
2346
2366
  <span class="book-cover-fallback">${esc(Array.from(work.title)[0] ?? "书")}</span>
2347
2367
  ${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面">` : ""}
2348
2368
  </span>
2349
- <span class="book-info"><strong>${esc(work.title)}</strong><small>${esc(work.author || "未署名")} · ${work.chapterCount} 章 · ${work.wordCount} 字</small><span>${esc(work.description || "尚未填写作品简介")}</span><em class="book-access-badge">${work.accessRole === "viewer" ? "全部只读" : work.accessRole === "settings-editor" ? "设定协作" : work.accessRole === "editor" ? "全部可编辑" : work.accessRole === "custom" ? "自定义权限" : work.accessRole === "admin" ? "管理员访问" : "我的作品"}</em></span>
2369
+ <span class="book-info"><strong>${esc(work.title)}</strong><small>${esc(work.author || "未署名")} · ${work.chapterCount} 章 · ${Number(work.wordCount ?? 0).toLocaleString("zh-CN")} 字</small><span>${esc(work.description || "尚未填写作品简介")}</span><em class="book-access-badge">${work.accessRole === "viewer" ? "全部只读" : work.accessRole === "settings-editor" ? "设定协作" : work.accessRole === "editor" ? "全部可编辑" : work.accessRole === "custom" ? "自定义权限" : work.accessRole === "admin" ? "管理员访问" : "我的作品"}</em></span>
2350
2370
  </button>
2351
2371
  ${canManageWork(work) ? `<button class="book-card-settings" type="button" data-edit-work="${esc(work.id)}" aria-label="作品设置" title="作品设置">设置</button>` : ""}
2352
2372
  </article>`).join("")}
@@ -2410,7 +2430,7 @@ async function selectWork(workId, preferredChapterId = null) {
2410
2430
  if (!canReadModule(state.module)) state.module = firstReadableUiModule(state.work) ?? "editor";
2411
2431
  applyWorkAccessMode();
2412
2432
  updateDocumentTitle(state.work);
2413
- $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
2433
+ $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
2414
2434
  $("#top-search-button").disabled = !canReadAggregateContent();
2415
2435
  renderTree();
2416
2436
  const chapters = state.work.volumes.flatMap((volume) => volume.chapters);
@@ -2599,6 +2619,7 @@ async function showModule(module) {
2599
2619
  $("#module-eyebrow").textContent = meta[0];
2600
2620
  $("#module-title").textContent = meta[1];
2601
2621
  $("#module-description").textContent = meta[2];
2622
+ $("#module-header-actions").querySelectorAll("[data-module-header-action]").forEach((action) => action.remove());
2602
2623
  $("#module-create-button").textContent = meta[3];
2603
2624
  $("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditModule(module));
2604
2625
  $("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
@@ -2689,30 +2710,82 @@ function bindEntityHistoryButtons(refresh) {
2689
2710
  }));
2690
2711
  }
2691
2712
 
2713
+ function pencilIconMarkup() {
2714
+ return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20h9"></path><path d="m16.5 3.5 1.4-1.4a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4L16.5 3.5Z"></path></svg>';
2715
+ }
2716
+
2717
+ function recordCardEditButton(attribute, id, label) {
2718
+ return `<button class="record-card-edit" type="button" data-${attribute}="${esc(id)}" aria-label="编辑${esc(label)}" title="编辑">${pencilIconMarkup()}</button>`;
2719
+ }
2720
+
2721
+ function recordHistoryButton(type, id, title) {
2722
+ return `<button type="button" data-entity-history="${esc(type)}" data-entity-id="${esc(id)}" data-entity-title="${esc(title)}">版本历史</button>`;
2723
+ }
2724
+
2725
+ function entityDialogManagementHtml({ typeLabel, canMerge, canDelete }) {
2726
+ return `<section class="entity-dialog-management" aria-label="${esc(typeLabel)}档案操作">
2727
+ <div><strong>档案操作</strong><small>版本历史和高风险操作集中在编辑面板内。</small></div>
2728
+ <div class="entity-dialog-management-actions">
2729
+ <button class="ghost-button" type="button" data-dialog-entity-history>版本历史</button>
2730
+ ${canMerge ? `<button class="ghost-button" type="button" data-dialog-entity-merge>合并</button>` : ""}
2731
+ ${canDelete ? `<button class="danger-button" type="button" data-dialog-entity-delete>删除</button>` : ""}
2732
+ </div>
2733
+ </section>`;
2734
+ }
2735
+
2736
+ function bindManagedEntityDialogActions({ type, typeLabel, item, candidates, endpoint, body, impact, refresh, deleteEndpoint, warning }) {
2737
+ if (!item) return;
2738
+ const fields = $("#dialog-fields");
2739
+ fields.querySelector("[data-dialog-entity-history]")?.addEventListener("click", () => {
2740
+ $("#form-dialog").close();
2741
+ openEntityHistory(type, item.id, item.name, async () => { await refresh(); await loadAiReferences(); });
2742
+ });
2743
+ fields.querySelector("[data-dialog-entity-merge]")?.addEventListener("click", () => {
2744
+ $("#form-dialog").close();
2745
+ openEntityMergeDialog({ typeLabel, source: item, candidates, endpoint, body, refresh, impact });
2746
+ });
2747
+ fields.querySelector("[data-dialog-entity-delete]")?.addEventListener("click", () => {
2748
+ void deleteManagedEntity({
2749
+ typeLabel,
2750
+ item,
2751
+ endpoint: deleteEndpoint,
2752
+ refresh,
2753
+ warning,
2754
+ onDeleted: () => $("#form-dialog").close()
2755
+ });
2756
+ });
2757
+ }
2758
+
2692
2759
  function openEntityMergeDialog({ typeLabel, source, candidates, endpoint, body, refresh, impact }) {
2693
2760
  const targetOptions = candidates
2694
2761
  .filter((candidate) => candidate.id !== source.id)
2695
2762
  .map((candidate) => [candidate.id, candidate.name]);
2696
2763
  openDialog(`合并${typeLabel}`,
2697
2764
  `<p class="merge-dialog-note">“${esc(source.name)}”将合并到所选档案,目标档案会保留。${esc(impact)}</p>` +
2698
- field("targetId", `目标${typeLabel}`, "select", targetOptions[0]?.[0] ?? "", targetOptions),
2765
+ field("targetId", `目标${typeLabel}`, "select", targetOptions[0]?.[0] ?? "", targetOptions) +
2766
+ `<label class="danger-confirm-field"><input name="mergeConfirm" type="checkbox" value="confirmed"><span><strong>我确认执行合并</strong><small>来源档案及关联资料将迁移到目标档案,合并后不可撤销。</small></span></label>`,
2699
2767
  async (form) => {
2700
2768
  const target = candidates.find((candidate) => candidate.id === form.get("targetId"));
2701
2769
  if (!target) throw new Error(`请选择目标${typeLabel}`);
2770
+ if (form.get("mergeConfirm") !== "confirmed") throw new Error("请先完成合并二次确认");
2702
2771
  await api(endpoint(source), { method: "POST", body: body(target) });
2703
2772
  await refresh();
2704
2773
  await loadAiReferences();
2705
2774
  toast(`已将“${source.name}”合并到“${target.name}”`);
2706
2775
  }, "人工资料管理", { submitLabel: "确认合并" });
2776
+ const confirmation = $("#dialog-fields").querySelector("[name='mergeConfirm']");
2777
+ const submit = $("#dialog-submit");
2778
+ submit.disabled = true;
2779
+ confirmation?.addEventListener("change", () => { submit.disabled = !confirmation.checked; });
2707
2780
  }
2708
2781
 
2709
- async function deleteManagedEntity({ typeLabel, item, endpoint, refresh, warning = "" }) {
2710
- const message = warning
2711
- ? `确认删除${typeLabel}“${item.name}”吗?\n${warning}`
2712
- : `确认删除${typeLabel}“${item.name}”吗?`;
2713
- if (!(await confirmToast(message, { title: `删除${typeLabel}`, confirmLabel: "确认删除" }))) return;
2782
+ async function deleteManagedEntity({ typeLabel, item, endpoint, refresh, warning = "", onDeleted }) {
2783
+ const detail = warning ? `\n${warning}` : "";
2784
+ if (!await confirmToast(`确认删除${typeLabel}“${item.name}”吗?${detail}`, { title: `删除${typeLabel}`, confirmLabel: "继续删除" })) return;
2785
+ if (!await confirmToast(`删除${typeLabel}“${item.name}”后无法恢复。${detail}`, { title: "删除操作需要再次确认", confirmLabel: "确认删除" })) return;
2714
2786
  try {
2715
2787
  await api(endpoint(item), { method: "DELETE" });
2788
+ await onDeleted?.();
2716
2789
  await refresh();
2717
2790
  await loadAiReferences();
2718
2791
  toast(`已删除${typeLabel}“${item.name}”`);
@@ -2746,7 +2819,7 @@ function moduleRowPreview(text, max = 180) {
2746
2819
  }
2747
2820
 
2748
2821
  function renderModuleLayoutToggle(layout, ariaLabel = "列表样式") {
2749
- return `<div class="module-layout-toolbar">
2822
+ return `<div class="module-layout-toolbar" data-module-header-action="layout-toggle">
2750
2823
  <div class="module-layout-toggle" role="group" aria-label="${esc(ariaLabel)}">
2751
2824
  <button type="button" data-module-layout="cards" aria-pressed="${layout === "cards"}">卡片</button>
2752
2825
  <button type="button" data-module-layout="rows" aria-pressed="${layout === "rows"}">列表</button>
@@ -2755,15 +2828,22 @@ function renderModuleLayoutToggle(layout, ariaLabel = "列表样式") {
2755
2828
  </div>`;
2756
2829
  }
2757
2830
 
2831
+ function mountModuleLayoutToggle(layout, ariaLabel) {
2832
+ $("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
2833
+ $("#module-header-actions").insertAdjacentHTML("beforeend", renderModuleLayoutToggle(layout, ariaLabel));
2834
+ }
2835
+
2758
2836
  function bindModuleLayoutToggle(refresh) {
2759
- $("#module-content").querySelectorAll("[data-module-layout]").forEach((button) => button.addEventListener("click", async () => {
2837
+ $("#module-header-actions").querySelectorAll("[data-module-layout]").forEach((button) => button.addEventListener("click", async () => {
2760
2838
  saveModuleLayout(button.dataset.moduleLayout);
2761
2839
  await refresh();
2762
2840
  }));
2763
2841
  }
2764
2842
 
2765
2843
  function settingRecordActions(item) {
2766
- return `${item.status === "pending" ? `<button data-setting-status="confirmed" data-setting-id="${esc(item.id)}">确认候选</button><button data-setting-status="deprecated" data-setting-id="${esc(item.id)}">弃用</button>` : ""}<button data-edit-setting="${esc(item.id)}">编辑</button><button data-entity-history="setting" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.title)}">版本历史</button>`;
2844
+ return canEditModule("settings")
2845
+ ? recordCardEditButton("edit-setting", item.id, `设定“${item.title}”`)
2846
+ : recordHistoryButton("setting", item.id, item.title);
2767
2847
  }
2768
2848
 
2769
2849
  function renderSettingCards(records) {
@@ -2787,15 +2867,11 @@ async function renderSettings() {
2787
2867
  const records = (await apiPage(`/api/works/${state.work.id}/settings`)).items;
2788
2868
  state.settings = records;
2789
2869
  const layout = readModuleLayout();
2870
+ if (records.length) mountModuleLayoutToggle(layout, "设定列表样式");
2790
2871
  $("#module-content").innerHTML = records.length
2791
- ? `${renderModuleLayoutToggle(layout, "设定列表样式")}${layout === "rows" ? renderSettingRows(records) : renderSettingCards(records)}`
2872
+ ? `${layout === "rows" ? renderSettingRows(records) : renderSettingCards(records)}`
2792
2873
  : emptyModule("还没有世界观设定", "新建规则、地点、组织、科技或创作约束。AI 提取的候选也会进入这里。");
2793
2874
  bindModuleLayoutToggle(renderSettings);
2794
- $("#module-content").querySelectorAll("[data-setting-status]").forEach((button) => button.addEventListener("click", async () => {
2795
- await api(`/api/settings/${button.dataset.settingId}`, { method: "PATCH", body: { status: button.dataset.settingStatus, changeNote: button.dataset.settingStatus === "confirmed" ? "确认 AI 设定候选" : "弃用 AI 设定候选" } });
2796
- await renderSettings();
2797
- await loadAiReferences();
2798
- }));
2799
2875
  $("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => openSettingEditor(records.find((item) => item.id === button.dataset.editSetting))));
2800
2876
  bindEntityHistoryButtons(async () => { await renderSettings(); await loadAiReferences(); });
2801
2877
  }
@@ -2807,23 +2883,26 @@ async function renderCharacters() {
2807
2883
  canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
2808
2884
  ]);
2809
2885
  const layout = readModuleLayout();
2810
- const characterActions = (item) => `<button data-edit-character="${esc(item.id)}">编辑</button>${canEditModule("characters") && state.characters.length > 1 ? `<button data-merge-character="${esc(item.id)}">合并</button>` : ""}${canEditModule("characters") ? `<button class="danger-button" data-delete-character="${esc(item.id)}">删除</button>` : ""}`;
2886
+ const characterActions = (item) => recordCardEditButton("edit-character", item.id, `角色“${item.name}”`);
2811
2887
  const characterCards = () => `<div class="card-grid">${state.characters.map((item) => {
2812
2888
  const details = normalizeCharacterDetails(item.attributes?.details);
2813
2889
  return `
2814
- <article class="record-card character-card" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}"><small>${item.lockedFields.length ? `锁定 ${item.lockedFields.length} 项` : esc(item.visibility)}</small>
2815
- <h3>${esc(item.name)}</h3><div>${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>
2816
- ${item.species ? `<div class="character-species"><b>种族</b><span class="pill">${esc(racePathLabel(item.race) || item.species)}</span></div>` : ""}
2890
+ <article class="record-card character-card has-card-edit" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}<small>${item.lockedFields.length ? `锁定 ${item.lockedFields.length} 项` : esc(item.visibility)}</small>
2891
+ <h3>${esc(item.name)}</h3>
2817
2892
  ${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
2893
+ ${item.aliases.length ? `<div class="character-aliases">${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
2894
+ ${item.code ? `<div class="character-code"><b>编号</b><span class="pill">${esc(item.code)}</span></div>` : ""}
2895
+ ${item.species ? `<div class="character-species"><b>种族</b><span class="pill">${esc(racePathLabel(item.race) || item.species)}</span></div>` : ""}
2818
2896
  ${details.length ? `<dl class="character-detail-list">${details.slice(0, 4).map((detail) => `<div><dt>${esc(detail.label)}</dt><dd>${esc(detail.value)}</dd></div>`).join("")}</dl>` : ""}
2819
2897
  <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>
2820
2898
  ${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>`}
2821
2899
  ${item.profileSectionCount ? `<small class="character-section-count">${item.profileSectionCount} 个设定章节</small>` : ""}
2822
- <div class="card-actions">${characterActions(item)}</div></article>`;
2900
+ </article>`;
2823
2901
  }).join("")}</div>`;
2824
2902
  const characterRows = () => `<div class="module-row-list">${state.characters.map((item) => {
2825
2903
  const preview = moduleRowPreview(item.profile?.summary || item.attributes?.identity || Object.entries(item.currentState).map(([key, value]) => `${key}:${value}`).join(" ") || "尚未记录当前状态");
2826
2904
  const meta = [
2905
+ item.code ? `编号 ${item.code}` : "",
2827
2906
  item.species ? (racePathLabel(item.race) || item.species) : "",
2828
2907
  ...(item.aliases ?? []).slice(0, 3),
2829
2908
  (item.organizations ?? []).length ? (item.organizations ?? []).map((organization) => organization.name).join("、") : ""
@@ -2838,8 +2917,9 @@ async function renderCharacters() {
2838
2917
  </article>`;
2839
2918
  }).join("")}</div>`;
2840
2919
  const auditPanel = canEditModule("tasks") ? `<section class="character-audit-panel"><div><strong>角色身份确认</strong><small>让 AI 查询角色档案并搜索正文,找出可能被误建成两个档案的同一角色。AI 只提交审核建议,不会自动合并。</small></div><button id="create-character-audit-task" class="ghost-button" type="button" ${state.characters.length < 2 ? "disabled" : ""}>AI 角色查重</button></section>` : "";
2920
+ if (state.characters.length) mountModuleLayoutToggle(layout, "角色列表样式");
2841
2921
  $("#module-content").innerHTML = auditPanel + (state.characters.length
2842
- ? `${renderModuleLayoutToggle(layout, "角色列表样式")}${layout === "rows" ? characterRows() : characterCards()}`
2922
+ ? `${layout === "rows" ? characterRows() : characterCards()}`
2843
2923
  : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
2844
2924
  bindModuleLayoutToggle(renderCharacters);
2845
2925
  $("#create-character-audit-task")?.addEventListener("click", async () => {
@@ -2864,34 +2944,6 @@ async function renderCharacters() {
2864
2944
  });
2865
2945
  });
2866
2946
  $("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterEditor(state.characters.find((item) => item.id === button.dataset.editCharacter))));
2867
- $("#module-content").querySelectorAll("[data-merge-character]").forEach((button) => button.addEventListener("click", () => {
2868
- const source = state.characters.find((item) => item.id === button.dataset.mergeCharacter);
2869
- if (!source) return;
2870
- openEntityMergeDialog({
2871
- typeLabel: "角色",
2872
- source,
2873
- candidates: state.characters,
2874
- endpoint: (item) => `/api/characters/${encodeURIComponent(item.id)}/merge`,
2875
- body: (target) => ({
2876
- targetCharacterId: target.id,
2877
- expectedTargetVersionNo: target.versionNo,
2878
- expectedSourceVersionNo: source.versionNo
2879
- }),
2880
- refresh: renderCharacters,
2881
- impact: "来源角色的别名、组织、档案章节、时间线与人物关系会迁移到目标角色。"
2882
- });
2883
- }));
2884
- $("#module-content").querySelectorAll("[data-delete-character]").forEach((button) => button.addEventListener("click", () => {
2885
- const item = state.characters.find((character) => character.id === button.dataset.deleteCharacter);
2886
- if (!item) return;
2887
- void deleteManagedEntity({
2888
- typeLabel: "角色",
2889
- item,
2890
- endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}`,
2891
- refresh: renderCharacters,
2892
- warning: "相关人物关系会删除,时间线中的参与者引用会移除。"
2893
- });
2894
- }));
2895
2947
  }
2896
2948
 
2897
2949
  async function renderRaces() {
@@ -2900,16 +2952,22 @@ async function renderRaces() {
2900
2952
  canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
2901
2953
  ]);
2902
2954
  const layout = readModuleLayout();
2903
- const raceActions = (item) => `<button data-edit-race="${esc(item.id)}">编辑</button><button data-entity-history="race" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button>${canEditModule("races") && state.races.length > 1 ? `<button data-merge-race="${esc(item.id)}">合并</button>` : ""}${canEditModule("races") ? `<button class="danger-button" data-delete-race="${esc(item.id)}">删除</button>` : ""}`;
2955
+ const canEditRaces = canEditModule("races");
2956
+ const raceActions = (item) => canEditRaces
2957
+ ? recordCardEditButton("edit-race", item.id, `种族“${item.name}”`)
2958
+ : recordHistoryButton("race", item.id, item.name);
2959
+ const raceCardActions = (item) => canEditRaces
2960
+ ? raceActions(item)
2961
+ : `<div class="card-actions">${raceActions(item)}</div>`;
2904
2962
  const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
2905
2963
  <summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
2906
2964
  <div class="race-tree-branch">
2907
- <article class="record-card race-card"><small>${item.memberIds.length} 位直接角色 · ${item.settings.length ? "已填写共同设定" : "暂无共同设定"}</small>
2965
+ <article class="record-card race-card${canEditRaces ? " has-card-edit" : ""}"><small>${item.memberIds.length} 位直接角色 · ${item.settings.length} 条自身设定</small>
2908
2966
  <div class="race-path" aria-label="种族路径">${esc(racePathLabel(item))}</div>
2909
2967
  <p>${esc(item.description || "尚未填写种族简介")}</p>
2910
2968
  <div class="race-settings">${item.effectiveSettings.length ? item.effectiveSettings.map((setting) => `<section class="knowledge-markdown-block${setting.inherited ? " inherited" : ""}"><div class="knowledge-markdown-block-heading"><h4>${esc(setting.title || "未命名章节")}</h4><small>${esc(setting.inherited ? `继承自 ${setting.sourceRaceName}` : `定义于 ${setting.sourceRaceName}`)}</small></div><div class="message-body">${renderMarkdown(setting.value) || '<p class="markdown-editor-empty">暂无内容</p>'}</div></section>`).join("") : '<span class="pill">暂无共同设定</span>'}</div>
2911
2969
  <p class="race-members">直接角色:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
2912
- <div class="card-actions">${raceActions(item)}</div>
2970
+ ${raceCardActions(item)}
2913
2971
  </article>
2914
2972
  ${item.children.length ? `<div class="race-tree-children">${item.children.map(renderRaceNode).join("")}</div>` : ""}
2915
2973
  </div>
@@ -2925,35 +2983,12 @@ async function renderRaces() {
2925
2983
  <div class="card-actions">${raceActions(item)}</div>
2926
2984
  </article>`;
2927
2985
  }).join("")}</div>`;
2986
+ if (state.races.length) mountModuleLayoutToggle(layout, "种族列表样式");
2928
2987
  $("#module-content").innerHTML = state.races.length
2929
- ? `${renderModuleLayoutToggle(layout, "种族列表样式")}${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>`}`
2988
+ ? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>`}`
2930
2989
  : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
2931
2990
  bindModuleLayoutToggle(renderRaces);
2932
2991
  $("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => openRaceDialog(state.races.find((item) => item.id === button.dataset.editRace))));
2933
- $("#module-content").querySelectorAll("[data-merge-race]").forEach((button) => button.addEventListener("click", () => {
2934
- const source = state.races.find((item) => item.id === button.dataset.mergeRace);
2935
- if (!source) return;
2936
- openEntityMergeDialog({
2937
- typeLabel: "种族",
2938
- source,
2939
- candidates: state.races,
2940
- endpoint: (item) => `/api/races/${encodeURIComponent(item.id)}/merge`,
2941
- body: (target) => ({ targetRaceId: target.id }),
2942
- refresh: renderRaces,
2943
- impact: "来源种族的角色、子种族、简介与共同设定会迁移到目标种族。"
2944
- });
2945
- }));
2946
- $("#module-content").querySelectorAll("[data-delete-race]").forEach((button) => button.addEventListener("click", () => {
2947
- const item = state.races.find((race) => race.id === button.dataset.deleteRace);
2948
- if (!item) return;
2949
- void deleteManagedEntity({
2950
- typeLabel: "种族",
2951
- item,
2952
- endpoint: (race) => `/api/races/${encodeURIComponent(race.id)}`,
2953
- refresh: renderRaces,
2954
- warning: "已绑定角色将变为未指定种族;有子种族时需先迁移或合并。"
2955
- });
2956
- }));
2957
2992
  bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
2958
2993
  }
2959
2994
 
@@ -2963,13 +2998,19 @@ async function renderOrganizations() {
2963
2998
  canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
2964
2999
  ]);
2965
3000
  const layout = readModuleLayout();
2966
- const organizationActions = (item) => `<button data-edit-organization="${esc(item.id)}">编辑</button><button data-entity-history="organization" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button>${canEditModule("organizations") && state.organizations.length > 1 ? `<button data-merge-organization="${esc(item.id)}">合并</button>` : ""}${canEditModule("organizations") ? `<button class="danger-button" data-delete-organization="${esc(item.id)}">删除</button>` : ""}`;
3001
+ const canEditOrganizations = canEditModule("organizations");
3002
+ const organizationActions = (item) => canEditOrganizations
3003
+ ? recordCardEditButton("edit-organization", item.id, `组织“${item.name}”`)
3004
+ : recordHistoryButton("organization", item.id, item.name);
3005
+ const organizationCardActions = (item) => canEditOrganizations
3006
+ ? organizationActions(item)
3007
+ : `<div class="card-actions">${organizationActions(item)}</div>`;
2967
3008
  const organizationCards = () => `<div class="card-grid organization-grid">${state.organizations.map((item) => `
2968
- <article class="record-card organization-card"><small>${item.memberIds.length} 位成员 · ${item.settings.length ? "已填写组织设定" : "暂无组织设定"}</small>
3009
+ <article class="record-card organization-card${canEditOrganizations ? " has-card-edit" : ""}"><small>${item.memberIds.length} 位成员 · ${item.settings.length ? "已填写组织设定" : "暂无组织设定"}</small>
2969
3010
  <h3>${esc(item.name)}</h3><p>${esc(item.description || "尚未填写组织简介")}</p>
2970
3011
  <div class="organization-settings">${item.settingsSections?.length ? item.settingsSections.map((section) => `<article class="knowledge-markdown-block"><div class="knowledge-markdown-block-heading"><h4>${esc(section.title || "未命名章节")}</h4></div><div class="message-body">${renderMarkdown(section.contentMarkdown) || '<p class="markdown-editor-empty">暂无内容</p>'}</div></article>`).join("") : '<span class="pill">暂无组织设定</span>'}</div>
2971
3012
  <p class="organization-members">成员:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
2972
- <div class="card-actions">${organizationActions(item)}</div>
3013
+ ${organizationCardActions(item)}
2973
3014
  </article>`).join("")}</div>`;
2974
3015
  const organizationRows = () => `<div class="module-row-list">${state.organizations.map((item) => {
2975
3016
  const preview = moduleRowPreview(item.description || "尚未填写组织简介");
@@ -2982,51 +3023,56 @@ async function renderOrganizations() {
2982
3023
  <div class="card-actions">${organizationActions(item)}</div>
2983
3024
  </article>`;
2984
3025
  }).join("")}</div>`;
3026
+ if (state.organizations.length) mountModuleLayoutToggle(layout, "组织列表样式");
2985
3027
  $("#module-content").innerHTML = state.organizations.length
2986
- ? `${renderModuleLayoutToggle(layout, "组织列表样式")}${layout === "rows" ? organizationRows() : organizationCards()}`
3028
+ ? `${layout === "rows" ? organizationRows() : organizationCards()}`
2987
3029
  : emptyModule("还没有组织", "创建国家、机构、阵营或团队,并维护组织设定与成员。");
2988
3030
  bindModuleLayoutToggle(renderOrganizations);
2989
3031
  $("#module-content").querySelectorAll("[data-edit-organization]").forEach((button) => button.addEventListener("click", () => openOrganizationDialog(state.organizations.find((item) => item.id === button.dataset.editOrganization))));
2990
- $("#module-content").querySelectorAll("[data-merge-organization]").forEach((button) => button.addEventListener("click", () => {
2991
- const source = state.organizations.find((item) => item.id === button.dataset.mergeOrganization);
2992
- if (!source) return;
2993
- openEntityMergeDialog({
2994
- typeLabel: "组织",
2995
- source,
2996
- candidates: state.organizations,
2997
- endpoint: (item) => `/api/organizations/${encodeURIComponent(item.id)}/merge`,
2998
- body: (target) => ({ targetOrganizationId: target.id }),
2999
- refresh: renderOrganizations,
3000
- impact: "来源组织的成员、简介与组织设定会迁移到目标组织。"
3001
- });
3002
- }));
3003
- $("#module-content").querySelectorAll("[data-delete-organization]").forEach((button) => button.addEventListener("click", () => {
3004
- const item = state.organizations.find((organization) => organization.id === button.dataset.deleteOrganization);
3005
- if (!item) return;
3006
- void deleteManagedEntity({
3007
- typeLabel: "组织",
3008
- item,
3009
- endpoint: (organization) => `/api/organizations/${encodeURIComponent(organization.id)}`,
3010
- refresh: renderOrganizations,
3011
- warning: "角色与该组织的成员关系会一并移除。"
3012
- });
3013
- }));
3014
3032
  bindEntityHistoryButtons(async () => { await renderOrganizations(); await loadAiReferences(); });
3015
3033
  }
3016
3034
 
3035
+ function updateTimelineMultiSelectControls() {
3036
+ const toggle = $("#timeline-multi-select-toggle");
3037
+ if (!toggle) return;
3038
+ const selectedCount = $("#module-content").querySelectorAll("[data-event-select]:checked").length;
3039
+ toggle.setAttribute("aria-pressed", String(timelineMultiSelectEnabled));
3040
+ toggle.textContent = timelineMultiSelectEnabled ? "退出多选" : "多选";
3041
+ $("#module-content").querySelectorAll("[data-event-select]").forEach((input) => {
3042
+ input.hidden = !timelineMultiSelectEnabled;
3043
+ if (!timelineMultiSelectEnabled) input.checked = false;
3044
+ });
3045
+ const merge = $("#merge-events");
3046
+ if (merge) {
3047
+ merge.hidden = !timelineMultiSelectEnabled;
3048
+ merge.disabled = !timelineMultiSelectEnabled || selectedCount < 2;
3049
+ }
3050
+ }
3051
+
3052
+ function setTimelineMultiSelectMode(enabled) {
3053
+ timelineMultiSelectEnabled = enabled;
3054
+ updateTimelineMultiSelectControls();
3055
+ }
3056
+
3017
3057
  async function renderTimeline() {
3018
3058
  const [events, tracks] = await Promise.all([
3019
3059
  apiPage(`/api/works/${state.work.id}/timeline`).then((result) => result.items),
3020
3060
  apiAllPages(`/api/works/${state.work.id}/timeline-tracks`)
3021
3061
  ]);
3062
+ timelineMultiSelectEnabled = false;
3063
+ $("#timeline-tools")?.remove();
3064
+ $("#module-header-actions").insertAdjacentHTML("beforeend", `<div id="timeline-tools" class="timeline-tools" data-module-header-action="timeline-tools" role="group" aria-label="时间轴操作"><button id="create-timeline-track" class="ghost-button" type="button">新建独立时间轴</button><button id="timeline-multi-select-toggle" class="ghost-button" type="button" aria-pressed="false">多选</button>${events.length > 1 ? '<button id="merge-events" class="ghost-button" type="button" hidden>合并所选事件</button>' : ""}</div>`);
3022
3065
  state.timelineTracks = tracks;
3023
3066
  const lanes = [...tracks, { id: "", name: "未分组时间轴", description: "尚未归入独立大事件的时间节点。", sortOrder: Number.MAX_SAFE_INTEGER }];
3024
- const eventCard = (item) => `<article class="timeline-kanban-card"><div class="timeline-card-meta"><input type="checkbox" data-event-select="${esc(item.id)}" aria-label="选择 ${esc(item.name)}"><small>${esc(item.timeLabel)} · ${esc(item.status)}</small></div><h4>${esc(item.name)}</h4><p>${esc(item.description || "暂无说明")}</p>${item.location ? `<span>地点:${esc(item.location)}</span>` : ""}<div class="card-actions"><button data-edit-event="${esc(item.id)}">编辑与排序</button><button data-split-event="${esc(item.id)}">拆分</button><button data-entity-history="timeline-event" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button></div></article>`;
3025
- $("#module-content").innerHTML = `<div class="timeline-tools"><button id="create-timeline-track" class="primary-button" type="button">新建独立时间轴</button>${events.length > 1 ? '<button id="merge-events" class="ghost-button" type="button">合并所选事件</button>' : ""}</div><div class="timeline-kanban" data-testid="timeline-kanban">${lanes.map((track) => {
3067
+ const eventCard = (item) => `<article class="timeline-kanban-card"><div class="timeline-card-meta"><input type="checkbox" data-event-select="${esc(item.id)}" aria-label="选择 ${esc(item.name)}" hidden><small>${esc(item.timeLabel)} · ${esc(item.status)}</small></div><h4>${esc(item.name)}</h4><p>${esc(item.description || "暂无说明")}</p>${item.location ? `<span>地点:${esc(item.location)}</span>` : ""}<div class="card-actions"><button data-edit-event="${esc(item.id)}">编辑与排序</button><button data-split-event="${esc(item.id)}">拆分</button><button data-entity-history="timeline-event" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button></div></article>`;
3068
+ $("#module-content").innerHTML = `<div class="timeline-kanban" data-testid="timeline-kanban">${lanes.map((track) => {
3026
3069
  const laneEvents = events.filter((item) => (item.trackId ?? "") === track.id);
3027
3070
  return `<section class="timeline-lane" data-track-id="${esc(track.id)}"><header><div><small>${laneEvents.length} 个节点</small><h3>${esc(track.name)}</h3></div>${track.id ? `<div class="timeline-track-actions"><button class="timeline-track-menu" data-edit-timeline-track="${esc(track.id)}" type="button">编辑</button><button class="timeline-track-menu" data-entity-history="timeline-track" data-entity-id="${esc(track.id)}" data-entity-title="${esc(track.name)}" type="button">历史</button></div>` : ""}</header><p class="timeline-track-description">${esc(track.description || "暂无说明")}</p><div class="timeline-lane-events">${laneEvents.map(eventCard).join("") || '<div class="timeline-lane-empty">还没有时间节点</div>'}</div><button class="timeline-add-event" data-add-event-track="${esc(track.id)}" type="button">添加事件</button></section>`;
3028
3071
  }).join("")}</div>`;
3029
3072
  $("#create-timeline-track").addEventListener("click", () => openTimelineTrackDialog());
3073
+ $("#timeline-multi-select-toggle").addEventListener("click", () => setTimelineMultiSelectMode(!timelineMultiSelectEnabled));
3074
+ $("#module-content").querySelectorAll("[data-event-select]").forEach((input) => input.addEventListener("change", updateTimelineMultiSelectControls));
3075
+ setTimelineMultiSelectMode(false);
3030
3076
  $("#module-content").querySelectorAll("[data-edit-timeline-track]").forEach((button) => button.addEventListener("click", () => openTimelineTrackDialog(tracks.find((track) => track.id === button.dataset.editTimelineTrack))));
3031
3077
  $("#module-content").querySelectorAll("[data-add-event-track]").forEach((button) => button.addEventListener("click", () => openTimelineDialog(null, button.dataset.addEventTrack || null)));
3032
3078
  $("#module-content").querySelectorAll("[data-edit-event]").forEach((button) => button.addEventListener("click", () => openTimelineDialog(events.find((item) => item.id === button.dataset.editEvent))));
@@ -3080,8 +3126,9 @@ async function renderOutlines() {
3080
3126
  </article>`;
3081
3127
  }).join("")}</div>`;
3082
3128
  const foreshadowHtml = foreshadows.length
3083
- ? `${renderModuleLayoutToggle(layout, "伏笔列表样式")}${layout === "rows" ? foreshadowRows() : foreshadowCards()}`
3129
+ ? `${layout === "rows" ? foreshadowRows() : foreshadowCards()}`
3084
3130
  : emptyModule("还没有伏笔", "创建伏笔并关联埋设、提醒与回收章节,未回收项会持续显示。\n");
3131
+ if (foreshadows.length) mountModuleLayoutToggle(layout, "伏笔列表样式");
3085
3132
  const outlineHtml = outlines.length ? `<div class="outline-list">${outlines.map((item) => `
3086
3133
  <article class="outline-row ${item.status === "completed" ? "is-complete" : ""}">
3087
3134
  <div><small>${esc(item.volumeTitle)} · ${esc(item.status)}</small><h3>${esc(item.chapterTitle)}</h3></div>
@@ -3108,7 +3155,7 @@ async function renderRelationships() {
3108
3155
  state.relationshipGraph = graph;
3109
3156
  $("#module-content").innerHTML = `<div id="relationship-map-host"></div>${relationships.length ? `<table class="table-list relationship-table"><thead><tr><th>人物</th><th>关系</th><th>关键词</th><th>证据</th><th>置信度</th><th>状态</th><th>操作</th></tr></thead><tbody>${relationships.map((item) => `
3110
3157
  <tr><td>${esc(nameOf(item.fromCharacterId))} ${item.directed ? "→" : "—"} ${esc(nameOf(item.toCharacterId))}</td>
3111
- <td>${esc(item.category)} / ${esc(item.subtype || "未细分")}</td><td>${(item.keywords ?? []).map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || "—"}</td><td>${item.evidence.length} 条</td><td>${Math.round(item.confidence * 100)}%</td><td>${esc(item.confirmationStatus)}</td><td class="relationship-actions"><button data-edit-relationship="${esc(item.id)}">编辑</button><button data-entity-history="relationship" data-entity-id="${esc(item.id)}" data-entity-title="${esc(`${nameOf(item.fromCharacterId)} / ${nameOf(item.toCharacterId)}`)}">历史</button></td></tr>`).join("")}</tbody></table>` : '<div class="relationship-empty-note">尚无关系边;孤立角色仍显示在力导向图谱中。可人工新建关系,或运行全书人物关系分析。</div>'}`;
3158
+ <td>${esc(item.category)} / ${esc(item.subtype || "未细分")}</td><td>${(item.keywords ?? []).map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || "—"}</td><td>${item.evidence.length}</td><td>${Math.round(item.confidence * 100)}%</td><td>${esc(item.confirmationStatus)}</td><td class="relationship-actions"><button data-edit-relationship="${esc(item.id)}">编辑</button><button data-entity-history="relationship" data-entity-id="${esc(item.id)}" data-entity-title="${esc(`${nameOf(item.fromCharacterId)} / ${nameOf(item.toCharacterId)}`)}">历史</button></td></tr>`).join("")}</tbody></table>` : '<div class="relationship-empty-note">尚无关系边;孤立角色仍显示在力导向图谱中。可人工新建关系,或运行全书人物关系分析。</div>'}`;
3112
3159
  const openGalaxy = () => {
3113
3160
  state.galaxy?.destroy();
3114
3161
  state.galaxy = createGalaxyRenderer($("#relationship-galaxy-dialog"), graph, { workId: state.work.id });
@@ -3172,8 +3219,9 @@ async function renderReviews() {
3172
3219
  <div class="card-actions">${item.status === "pending" && canResolveReview ? `<button data-review-status="fixed" data-review-id="${esc(item.id)}">标为已修复</button><button data-review-status="ignored" data-review-id="${esc(item.id)}">忽略</button>` : ""}</div>
3173
3220
  </article>`;
3174
3221
  };
3222
+ if (reviews.length) mountModuleLayoutToggle(layout, "审核列表样式");
3175
3223
  $("#module-content").innerHTML = reviews.length
3176
- ? `${renderModuleLayoutToggle(layout, "审核列表样式")}${layout === "rows" ? `<div class="module-row-list">${reviews.map(reviewRow).join("")}</div>` : `<div class="card-grid">${reviews.map(reviewCard).join("")}</div>`}`
3224
+ ? `${layout === "rows" ? `<div class="module-row-list">${reviews.map(reviewRow).join("")}</div>` : `<div class="card-grid">${reviews.map(reviewCard).join("")}</div>`}`
3177
3225
  : emptyModule("没有待审核事项", "候选设定、冲突与低置信度结论会集中显示在这里。");
3178
3226
  bindModuleLayoutToggle(renderReviews);
3179
3227
  $("#module-content").querySelectorAll("[data-review-id]").forEach((button) => button.addEventListener("click", async () => {
@@ -3973,8 +4021,12 @@ function openWorkSettingsDialog(work) {
3973
4021
  <div><strong id="import-history-settings-title">正文导入历史</strong><small>查看导入前快照并恢复被覆盖的分卷、章节标题和正文;大纲、伏笔等章节关联资料不在快照中。</small></div>
3974
4022
  <button id="import-history-button" class="ghost-button" type="button" aria-controls="import-history-dialog" aria-haspopup="dialog" ${canOpenImportHistory ? "" : "disabled"}>${importHistoryAction}</button>
3975
4023
  </section>`;
4024
+ const whitespaceField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="whitespace-settings-title">
4025
+ <div><strong id="whitespace-settings-title">正文空白符</strong><small>在编辑器正文中显示或隐藏空格、全角空格和 Tab 的可视标记。</small></div>
4026
+ <button id="toggle-whitespace-settings" class="ghost-button" data-toggle-whitespace type="button" aria-pressed="${chapterWhitespaceVisible}" title="用点标记半角空格,用方框标记全角空格,用箭头标记 Tab">${chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符"}</button>
4027
+ </section>` : "";
3976
4028
  openDialog("作品信息",
3977
- workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + accessField + importHistoryField,
4029
+ workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + whitespaceField + accessField + importHistoryField,
3978
4030
  async (form) => {
3979
4031
  await api(`/api/works/${work.id}`, { method: "PATCH", body: { title: form.get("title"), author: form.get("author"), description: form.get("description") } });
3980
4032
  state.works = (await apiPage("/api/works")).items;
@@ -3986,12 +4038,13 @@ function openWorkSettingsDialog(work) {
3986
4038
  state.work.description = String(form.get("description") ?? state.work.description);
3987
4039
  if (updated?.coverUrl !== undefined) state.work.coverUrl = updated.coverUrl;
3988
4040
  updateDocumentTitle(state.work);
3989
- $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
4041
+ $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
3990
4042
  }
3991
4043
  renderShelf();
3992
4044
  toast("作品信息已保存");
3993
4045
  }, "作品设置");
3994
4046
  bindWorkCoverControls(work);
4047
+ $("#toggle-whitespace-settings")?.addEventListener("click", toggleChapterWhitespaceVisibility);
3995
4048
  $("#import-history-button")?.addEventListener("click", () => {
3996
4049
  $("#form-dialog").close();
3997
4050
  void openImportHistory();
@@ -4057,6 +4110,43 @@ function openSettingEditor(item = null) {
4057
4110
  $("#setting-editor-form").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = viewOnly; });
4058
4111
  $("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
4059
4112
  $("#setting-editor-submit").classList.toggle("hidden", viewOnly);
4113
+ const management = $("#setting-editor-management");
4114
+ management.classList.toggle("hidden", !item || viewOnly);
4115
+ const statusButtons = [$("#setting-editor-confirm"), $("#setting-editor-deprecate")];
4116
+ $("#setting-editor-confirm").classList.toggle("hidden", item?.status !== "pending");
4117
+ $("#setting-editor-deprecate").classList.toggle("hidden", item?.status !== "pending");
4118
+ $("#setting-editor-history").onclick = async () => {
4119
+ if (!item) return;
4120
+ if (!(await closeEntityEditor())) return;
4121
+ openEntityHistory("setting", item.id, item.title, async () => { await renderSettings(); await loadAiReferences(); });
4122
+ };
4123
+ $("#setting-editor-delete").onclick = () => {
4124
+ if (!item) return;
4125
+ void deleteManagedEntity({
4126
+ typeLabel: "设定",
4127
+ item,
4128
+ endpoint: (setting) => `/api/settings/${encodeURIComponent(setting.id)}`,
4129
+ refresh: () => closeEntityEditor({ force: true }),
4130
+ warning: "这条设定及其版本历史会被删除。"
4131
+ });
4132
+ };
4133
+ statusButtons.forEach((button) => {
4134
+ button.onclick = async () => {
4135
+ if (!item) return;
4136
+ button.disabled = true;
4137
+ const status = button.id === "setting-editor-confirm" ? "confirmed" : "deprecated";
4138
+ try {
4139
+ await api(`/api/settings/${item.id}`, { method: "PATCH", body: { status, changeNote: status === "confirmed" ? "确认 AI 设定候选" : "弃用 AI 设定候选" } });
4140
+ await closeEntityEditor({ force: true });
4141
+ await loadAiReferences();
4142
+ toast(status === "confirmed" ? "设定候选已确认" : "设定候选已弃用");
4143
+ } catch (error) {
4144
+ toast(error.message, "error");
4145
+ } finally {
4146
+ button.disabled = false;
4147
+ }
4148
+ };
4149
+ });
4060
4150
  $("#setting-editor-form").onsubmit = async (event) => {
4061
4151
  event.preventDefault();
4062
4152
  if (!canEditModule("settings")) return;
@@ -4654,6 +4744,7 @@ function renderCharacterEditorFields(item) {
4654
4744
  ? field("firstChapterId", "首次登场章节", "select", item?.firstChapterId ?? "", chapterOptions)
4655
4745
  : '<div class="character-editor-empty-field"><b>首次登场章节</b><span>当前账户没有正文读取权限,原有绑定不会被修改。</span></div>')),
4656
4746
  characterEditorSection("profile", "人物档案", "记录人物定位、行为动力和便于创作时快速理解的简介。",
4747
+ field("code", "编号", "text", item?.code) +
4657
4748
  field("identity", "身份与定位", "text", item?.attributes?.identity) +
4658
4749
  field("motivation", "核心动机", "textarea", item?.profile?.motivation) +
4659
4750
  field("summary", "人物简介", "textarea", item?.profile?.summary)),
@@ -4690,6 +4781,7 @@ function collectCharacterBody(form) {
4690
4781
  delete profile.sections;
4691
4782
  const body = {
4692
4783
  name: String(form.get("name") ?? "").trim(),
4784
+ code: String(form.get("code") ?? "").trim(),
4693
4785
  aliases: form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean),
4694
4786
  attributes: {
4695
4787
  ...(item?.attributes ?? {}),
@@ -4796,6 +4888,34 @@ async function openCharacterEditor(item = null) {
4796
4888
  $("#character-editor-submit").textContent = item ? "保存新版本" : "创建人物档案";
4797
4889
  $("#character-history-button").disabled = !item;
4798
4890
  $("#character-history-button").title = item ? "查看、比较和回滚历史版本" : "创建人物档案后即可查看版本历史";
4891
+ const characterMergeButton = $("#character-merge-button");
4892
+ const characterDeleteButton = $("#character-delete-button");
4893
+ const canManageCharacter = Boolean(item && canEditModule("characters"));
4894
+ characterMergeButton.classList.toggle("hidden", !canManageCharacter || state.characters.length < 2);
4895
+ characterDeleteButton.classList.toggle("hidden", !canManageCharacter);
4896
+ characterMergeButton.onclick = async () => {
4897
+ if (!item) return;
4898
+ await closeEntityEditor({ force: true });
4899
+ openEntityMergeDialog({
4900
+ typeLabel: "角色",
4901
+ source: item,
4902
+ candidates: state.characters,
4903
+ endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}/merge`,
4904
+ body: (target) => ({ targetCharacterId: target.id, expectedTargetVersionNo: target.versionNo, expectedSourceVersionNo: item.versionNo }),
4905
+ refresh: renderCharacters,
4906
+ impact: "来源角色的别名、组织、档案章节、时间线与人物关系会迁移到目标角色。"
4907
+ });
4908
+ };
4909
+ characterDeleteButton.onclick = () => {
4910
+ if (!item) return;
4911
+ void deleteManagedEntity({
4912
+ typeLabel: "角色",
4913
+ item,
4914
+ endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}`,
4915
+ refresh: () => closeEntityEditor({ force: true }),
4916
+ warning: "相关人物关系会删除,时间线中的参与者引用会移除。"
4917
+ });
4918
+ };
4799
4919
  setCharacterHistoryVisible(false);
4800
4920
  renderCharacterEditorFields(item);
4801
4921
  const viewOnly = !canEditModule("characters");
@@ -4900,6 +5020,43 @@ async function openKnowledgeEditor(kind, item) {
4900
5020
  $("#knowledge-editor-header-note").textContent = isRace ? "层级、共同设定与角色归属" : "简介、组织设定与成员归属";
4901
5021
  $("#knowledge-editor-footer-note").textContent = `保存${label}档案后返回${isRace ? "种族" : "组织"}列表。`;
4902
5022
  $("#knowledge-editor-submit").textContent = item ? `保存${label}档案` : `创建${label}档案`;
5023
+ const historyButton = $("#knowledge-editor-history");
5024
+ const mergeButton = $("#knowledge-editor-merge");
5025
+ const deleteButton = $("#knowledge-editor-delete");
5026
+ const refresh = isRace ? renderRaces : renderOrganizations;
5027
+ const candidates = isRace ? state.races : state.organizations;
5028
+ const typeLabel = label;
5029
+ historyButton.classList.toggle("hidden", !item);
5030
+ mergeButton.classList.toggle("hidden", !item || !canEditModule(module) || candidates.length < 2);
5031
+ deleteButton.classList.toggle("hidden", !item || !canEditModule(module));
5032
+ historyButton.onclick = async () => {
5033
+ if (!item) return;
5034
+ if (!(await closeEntityEditor())) return;
5035
+ openEntityHistory(kind, item.id, item.name, async () => { await refresh(); await loadAiReferences(); });
5036
+ };
5037
+ mergeButton.onclick = async () => {
5038
+ if (!item) return;
5039
+ await closeEntityEditor({ force: true });
5040
+ openEntityMergeDialog({
5041
+ typeLabel,
5042
+ source: item,
5043
+ candidates,
5044
+ endpoint: (source) => `/api/${module}/${encodeURIComponent(source.id)}/merge`,
5045
+ body: (target) => isRace ? { targetRaceId: target.id } : { targetOrganizationId: target.id },
5046
+ refresh,
5047
+ impact: isRace ? "来源种族的角色、子种族、简介与共同设定会迁移到目标种族。" : "来源组织的成员、简介与组织设定会迁移到目标组织。"
5048
+ });
5049
+ };
5050
+ deleteButton.onclick = () => {
5051
+ if (!item) return;
5052
+ void deleteManagedEntity({
5053
+ typeLabel,
5054
+ item,
5055
+ endpoint: (source) => `/api/${module}/${encodeURIComponent(source.id)}`,
5056
+ refresh: () => closeEntityEditor({ force: true }),
5057
+ warning: isRace ? "已绑定角色将变为未指定种族;有子种族时需先迁移或合并。" : "角色与该组织的成员关系会一并移除。"
5058
+ });
5059
+ };
4903
5060
  renderKnowledgeEditorFields(kind, item, memberOptions, parentOptions);
4904
5061
  const viewOnly = !canEditModule(module);
4905
5062
  if (viewOnly) {
@@ -5867,6 +6024,7 @@ function cleanupExpandedRelationshipMap() {
5867
6024
  $("#relationship-map-close").addEventListener("click", () => $("#relationship-map-dialog").close());
5868
6025
  $("#relationship-map-dialog").addEventListener("close", cleanupExpandedRelationshipMap);
5869
6026
  $("#appearance-button").addEventListener("click", openAppearanceDialog);
6027
+ $("#toggle-whitespace-appearance").addEventListener("click", toggleChapterWhitespaceVisibility);
5870
6028
  $("#theme-toggle").addEventListener("click", () => {
5871
6029
  const theme = nextTheme(currentColorTheme());
5872
6030
  const persisted = saveColorTheme(theme);
@@ -5898,10 +6056,6 @@ $("#chapter-content").addEventListener("input", (event) => {
5898
6056
  });
5899
6057
  $("#chapter-content").addEventListener("select", scheduleAiContextUsage);
5900
6058
  $("#chapter-content").addEventListener("scroll", syncChapterLineNumberScroll);
5901
- $("#toggle-whitespace-button").addEventListener("click", () => {
5902
- chapterWhitespaceVisible = !chapterWhitespaceVisible;
5903
- scheduleChapterLineNumbers();
5904
- });
5905
6059
  $("#chapter-line-numbers-inner").addEventListener("pointerdown", (event) => {
5906
6060
  const row = event.target.closest(".chapter-line-number");
5907
6061
  if (!row || event.button !== 0) return;
@@ -6103,6 +6257,13 @@ document.addEventListener("keydown", (event) => {
6103
6257
  hideAiMentionMenu();
6104
6258
  }
6105
6259
  });
6260
+ document.addEventListener("keydown", (event) => {
6261
+ if (!isGlobalSearchShortcut(event) || !state.work) return;
6262
+ event.preventDefault();
6263
+ event.stopPropagation();
6264
+ if (event.repeat) return;
6265
+ openSearchDialog().catch((error) => toast(error.message, "error"));
6266
+ }, { capture: true });
6106
6267
  $("#ai-send").addEventListener("click", sendAi);
6107
6268
  $("#ai-new-conversation").addEventListener("click", async () => {
6108
6269
  const button = $("#ai-new-conversation");