@musnows/scriverse 0.4.0 → 0.4.2

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";
@@ -19,8 +19,9 @@ import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelatio
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
21
  import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
22
- import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260723-ai-analysis-permission";
22
+ import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260724-outline-title";
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;
@@ -232,7 +235,7 @@ const workspaceOnboardingSteps = [
232
235
  { selector: "[data-new-chapter-volume]", eyebrow: "正文结构", title: "新建章节", description: "使用分卷和章节组织长篇正文,章节会自动保存并保留版本。", placement: "right" },
233
236
  { selector: "#versions-button", eyebrow: "版本安全", title: "查看章节版本", description: "每次保存都会生成可恢复版本,误改内容时可以随时回溯。", placement: "bottom" },
234
237
  { selector: "[data-module=\"characters\"]", eyebrow: "作品知识", title: "维护角色与世界资料", description: "角色、种族、组织、设定和时间线共同构成 AI 可引用的作品知识。", placement: "right" },
235
- { selector: "[data-module=\"outlines\"]", eyebrow: "创作规划", title: "跟踪大纲与伏笔", description: "记录剧情目标、冲突、转折和伏笔回收,避免长线遗漏。", placement: "right" },
238
+ { selector: "[data-module=\"outlines\"]", eyebrow: "创作规划", title: "跟踪大纲/伏笔", description: "记录剧情目标、冲突、转折和伏笔回收,避免长线遗漏。", placement: "right" },
236
239
  { selector: "[data-module=\"tasks\"]", eyebrow: "AI 分析中心", title: "从这里理解整部小说", description: "运行人物、关系、世界观、设定、事件和一致性分析,并查看每次分析的结果与进度。", placement: "right" },
237
240
  { selector: "#top-search-button", eyebrow: "全文检索", title: "搜索整部作品", description: "一次检索正文、角色、设定、种族与组织,快速定位创作依据。", placement: "bottom" },
238
241
  { selector: ".quick-actions button[data-task=\"continue\"]", eyebrow: "AI 快捷指令", title: "让创作助手基于正文工作", description: "总结、续写、剧情方向和冲突检查都以已保存内容为依据。", placement: "left" },
@@ -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
 
@@ -1609,6 +1624,23 @@ async function apiAllPages(path, limit = 100) {
1609
1624
  }
1610
1625
  }
1611
1626
 
1627
+ async function initializeProductFooters() {
1628
+ const year = String(new Date().getFullYear());
1629
+ document.querySelectorAll("[data-product-footer-year]").forEach((element) => { element.textContent = year; });
1630
+ try {
1631
+ const health = await api("/api/health");
1632
+ const version = String(health.version ?? "").trim();
1633
+ document.querySelectorAll("[data-product-footer-version]").forEach((element) => {
1634
+ element.textContent = version ? `v${version}` : "v—";
1635
+ });
1636
+ document.querySelectorAll("[data-product-footer-development]").forEach((element) => {
1637
+ element.classList.toggle("hidden", health.development !== true);
1638
+ });
1639
+ } catch {
1640
+ document.querySelectorAll("[data-product-footer-version]").forEach((element) => { element.textContent = "v—"; });
1641
+ }
1642
+ }
1643
+
1612
1644
  function selectAuthMode(mode) {
1613
1645
  const registerTab = $("#auth-register-tab");
1614
1646
  const login = mode === "login" || registerTab.disabled;
@@ -1670,7 +1702,8 @@ function applyAuthenticatedUser(session) {
1670
1702
  state.csrfToken = session.csrfToken;
1671
1703
  $("#account-name").textContent = session.user.displayName;
1672
1704
  renderUserAvatar($("#account-avatar"), session.user);
1673
- $("#account-menu-name").textContent = `${session.user.displayName} · @${session.user.username}`;
1705
+ $("#account-menu-display-name").textContent = session.user.displayName;
1706
+ $("#account-menu-username").textContent = `@${session.user.username}`;
1674
1707
  $("#account-menu-role").textContent = session.user.role === "admin" ? "系统管理员" : "普通用户";
1675
1708
  $("#auth-view").classList.add("hidden");
1676
1709
  document.documentElement.classList.remove("login-route");
@@ -1934,7 +1967,8 @@ function restoredSettingsReturnContext(route) {
1934
1967
  }
1935
1968
 
1936
1969
  async function initializePage() {
1937
- if (!(await initializeAuthentication())) {
1970
+ const [authenticated] = await Promise.all([initializeAuthentication(), initializeProductFooters()]);
1971
+ if (!authenticated) {
1938
1972
  restoringPageRoute = false;
1939
1973
  return;
1940
1974
  }
@@ -2209,6 +2243,11 @@ async function openSearchDialog() {
2209
2243
  toast("请先打开一部作品", "error");
2210
2244
  return;
2211
2245
  }
2246
+ if ($("#search-dialog").open) {
2247
+ $("#search-query").focus();
2248
+ $("#search-query").select();
2249
+ return;
2250
+ }
2212
2251
  $("#search-dialog .eyebrow").textContent = `当前作品 · 《${state.work.title}》`;
2213
2252
  $("#search-query").value = "";
2214
2253
  $("#search-results").innerHTML = '<p class="search-results-empty">输入关键词后开始检索。</p>';
@@ -2312,7 +2351,7 @@ async function returnFromSettings() {
2312
2351
  $("#app").classList.remove("shelf-mode");
2313
2352
  $("#shelf-view").classList.add("hidden");
2314
2353
  updateDocumentTitle(state.work);
2315
- $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
2354
+ $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
2316
2355
  if (context.view === "module") return showModule(context.module);
2317
2356
  if (context.view === "editor" && context.chapterId) return selectChapter(context.chapterId);
2318
2357
  return showWelcome(true);
@@ -2346,7 +2385,7 @@ function renderShelf() {
2346
2385
  <span class="book-cover-fallback">${esc(Array.from(work.title)[0] ?? "书")}</span>
2347
2386
  ${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面">` : ""}
2348
2387
  </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>
2388
+ <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
2389
  </button>
2351
2390
  ${canManageWork(work) ? `<button class="book-card-settings" type="button" data-edit-work="${esc(work.id)}" aria-label="作品设置" title="作品设置">设置</button>` : ""}
2352
2391
  </article>`).join("")}
@@ -2410,7 +2449,7 @@ async function selectWork(workId, preferredChapterId = null) {
2410
2449
  if (!canReadModule(state.module)) state.module = firstReadableUiModule(state.work) ?? "editor";
2411
2450
  applyWorkAccessMode();
2412
2451
  updateDocumentTitle(state.work);
2413
- $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
2452
+ $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
2414
2453
  $("#top-search-button").disabled = !canReadAggregateContent();
2415
2454
  renderTree();
2416
2455
  const chapters = state.work.volumes.flatMap((volume) => volume.chapters);
@@ -2561,7 +2600,7 @@ const moduleMeta = {
2561
2600
  races: ["物种档案", "种族与共同设定", "先维护种族档案,再由角色选择引用;角色不能临时填写种族。", "新建种族"],
2562
2601
  organizations: ["世界阵营", "组织与成员", "维护组织简介、设定清单,并将角色绑定到所属组织。", "新建组织"],
2563
2602
  timeline: ["剧情脉络", "大事件时间轴", "候选事件经作者确认后,才进入正式时间线。", "新建事件"],
2564
- outlines: ["创作规划", "大纲与伏笔", "为每章维护目标、冲突与转折,并持续提醒尚未回收的伏笔。", "新建伏笔"],
2603
+ outlines: ["创作规划", "大纲/伏笔", "为每章维护目标、冲突与转折,并持续提醒尚未回收的伏笔。", "新建伏笔"],
2565
2604
  relationships: ["跨章证据", "人物关系", "记录关系方向、阶段、置信度与原文依据。", "新建关系"],
2566
2605
  reviews: ["作者决策", "审核队列", "集中处理冲突、候选设定、低置信度关系和时间问题。", "新增审核项"],
2567
2606
  tasks: ["AI 深度分析", "AI 分析中心", "对全书或指定章节运行人物关系、世界观、设定、事件与一致性分析。", "开始 AI 分析"],
@@ -2599,6 +2638,7 @@ async function showModule(module) {
2599
2638
  $("#module-eyebrow").textContent = meta[0];
2600
2639
  $("#module-title").textContent = meta[1];
2601
2640
  $("#module-description").textContent = meta[2];
2641
+ $("#module-header-actions").querySelectorAll("[data-module-header-action]").forEach((action) => action.remove());
2602
2642
  $("#module-create-button").textContent = meta[3];
2603
2643
  $("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditModule(module));
2604
2644
  $("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
@@ -2689,30 +2729,82 @@ function bindEntityHistoryButtons(refresh) {
2689
2729
  }));
2690
2730
  }
2691
2731
 
2732
+ function pencilIconMarkup() {
2733
+ 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>';
2734
+ }
2735
+
2736
+ function recordCardEditButton(attribute, id, label) {
2737
+ return `<button class="record-card-edit" type="button" data-${attribute}="${esc(id)}" aria-label="编辑${esc(label)}" title="编辑">${pencilIconMarkup()}</button>`;
2738
+ }
2739
+
2740
+ function recordHistoryButton(type, id, title) {
2741
+ return `<button type="button" data-entity-history="${esc(type)}" data-entity-id="${esc(id)}" data-entity-title="${esc(title)}">版本历史</button>`;
2742
+ }
2743
+
2744
+ function entityDialogManagementHtml({ typeLabel, canMerge, canDelete }) {
2745
+ return `<section class="entity-dialog-management" aria-label="${esc(typeLabel)}档案操作">
2746
+ <div><strong>档案操作</strong><small>版本历史和高风险操作集中在编辑面板内。</small></div>
2747
+ <div class="entity-dialog-management-actions">
2748
+ <button class="ghost-button" type="button" data-dialog-entity-history>版本历史</button>
2749
+ ${canMerge ? `<button class="ghost-button" type="button" data-dialog-entity-merge>合并</button>` : ""}
2750
+ ${canDelete ? `<button class="danger-button" type="button" data-dialog-entity-delete>删除</button>` : ""}
2751
+ </div>
2752
+ </section>`;
2753
+ }
2754
+
2755
+ function bindManagedEntityDialogActions({ type, typeLabel, item, candidates, endpoint, body, impact, refresh, deleteEndpoint, warning }) {
2756
+ if (!item) return;
2757
+ const fields = $("#dialog-fields");
2758
+ fields.querySelector("[data-dialog-entity-history]")?.addEventListener("click", () => {
2759
+ $("#form-dialog").close();
2760
+ openEntityHistory(type, item.id, item.name, async () => { await refresh(); await loadAiReferences(); });
2761
+ });
2762
+ fields.querySelector("[data-dialog-entity-merge]")?.addEventListener("click", () => {
2763
+ $("#form-dialog").close();
2764
+ openEntityMergeDialog({ typeLabel, source: item, candidates, endpoint, body, refresh, impact });
2765
+ });
2766
+ fields.querySelector("[data-dialog-entity-delete]")?.addEventListener("click", () => {
2767
+ void deleteManagedEntity({
2768
+ typeLabel,
2769
+ item,
2770
+ endpoint: deleteEndpoint,
2771
+ refresh,
2772
+ warning,
2773
+ onDeleted: () => $("#form-dialog").close()
2774
+ });
2775
+ });
2776
+ }
2777
+
2692
2778
  function openEntityMergeDialog({ typeLabel, source, candidates, endpoint, body, refresh, impact }) {
2693
2779
  const targetOptions = candidates
2694
2780
  .filter((candidate) => candidate.id !== source.id)
2695
2781
  .map((candidate) => [candidate.id, candidate.name]);
2696
2782
  openDialog(`合并${typeLabel}`,
2697
2783
  `<p class="merge-dialog-note">“${esc(source.name)}”将合并到所选档案,目标档案会保留。${esc(impact)}</p>` +
2698
- field("targetId", `目标${typeLabel}`, "select", targetOptions[0]?.[0] ?? "", targetOptions),
2784
+ field("targetId", `目标${typeLabel}`, "select", targetOptions[0]?.[0] ?? "", targetOptions) +
2785
+ `<label class="danger-confirm-field"><input name="mergeConfirm" type="checkbox" value="confirmed"><span><strong>我确认执行合并</strong><small>来源档案及关联资料将迁移到目标档案,合并后不可撤销。</small></span></label>`,
2699
2786
  async (form) => {
2700
2787
  const target = candidates.find((candidate) => candidate.id === form.get("targetId"));
2701
2788
  if (!target) throw new Error(`请选择目标${typeLabel}`);
2789
+ if (form.get("mergeConfirm") !== "confirmed") throw new Error("请先完成合并二次确认");
2702
2790
  await api(endpoint(source), { method: "POST", body: body(target) });
2703
2791
  await refresh();
2704
2792
  await loadAiReferences();
2705
2793
  toast(`已将“${source.name}”合并到“${target.name}”`);
2706
2794
  }, "人工资料管理", { submitLabel: "确认合并" });
2795
+ const confirmation = $("#dialog-fields").querySelector("[name='mergeConfirm']");
2796
+ const submit = $("#dialog-submit");
2797
+ submit.disabled = true;
2798
+ confirmation?.addEventListener("change", () => { submit.disabled = !confirmation.checked; });
2707
2799
  }
2708
2800
 
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;
2801
+ async function deleteManagedEntity({ typeLabel, item, endpoint, refresh, warning = "", onDeleted }) {
2802
+ const detail = warning ? `\n${warning}` : "";
2803
+ if (!await confirmToast(`确认删除${typeLabel}“${item.name}”吗?${detail}`, { title: `删除${typeLabel}`, confirmLabel: "继续删除" })) return;
2804
+ if (!await confirmToast(`删除${typeLabel}“${item.name}”后无法恢复。${detail}`, { title: "删除操作需要再次确认", confirmLabel: "确认删除" })) return;
2714
2805
  try {
2715
2806
  await api(endpoint(item), { method: "DELETE" });
2807
+ await onDeleted?.();
2716
2808
  await refresh();
2717
2809
  await loadAiReferences();
2718
2810
  toast(`已删除${typeLabel}“${item.name}”`);
@@ -2746,7 +2838,7 @@ function moduleRowPreview(text, max = 180) {
2746
2838
  }
2747
2839
 
2748
2840
  function renderModuleLayoutToggle(layout, ariaLabel = "列表样式") {
2749
- return `<div class="module-layout-toolbar">
2841
+ return `<div class="module-layout-toolbar" data-module-header-action="layout-toggle">
2750
2842
  <div class="module-layout-toggle" role="group" aria-label="${esc(ariaLabel)}">
2751
2843
  <button type="button" data-module-layout="cards" aria-pressed="${layout === "cards"}">卡片</button>
2752
2844
  <button type="button" data-module-layout="rows" aria-pressed="${layout === "rows"}">列表</button>
@@ -2755,15 +2847,22 @@ function renderModuleLayoutToggle(layout, ariaLabel = "列表样式") {
2755
2847
  </div>`;
2756
2848
  }
2757
2849
 
2850
+ function mountModuleLayoutToggle(layout, ariaLabel) {
2851
+ $("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
2852
+ $("#module-header-actions").insertAdjacentHTML("beforeend", renderModuleLayoutToggle(layout, ariaLabel));
2853
+ }
2854
+
2758
2855
  function bindModuleLayoutToggle(refresh) {
2759
- $("#module-content").querySelectorAll("[data-module-layout]").forEach((button) => button.addEventListener("click", async () => {
2856
+ $("#module-header-actions").querySelectorAll("[data-module-layout]").forEach((button) => button.addEventListener("click", async () => {
2760
2857
  saveModuleLayout(button.dataset.moduleLayout);
2761
2858
  await refresh();
2762
2859
  }));
2763
2860
  }
2764
2861
 
2765
2862
  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>`;
2863
+ return canEditModule("settings")
2864
+ ? recordCardEditButton("edit-setting", item.id, `设定“${item.title}”`)
2865
+ : recordHistoryButton("setting", item.id, item.title);
2767
2866
  }
2768
2867
 
2769
2868
  function renderSettingCards(records) {
@@ -2787,15 +2886,11 @@ async function renderSettings() {
2787
2886
  const records = (await apiPage(`/api/works/${state.work.id}/settings`)).items;
2788
2887
  state.settings = records;
2789
2888
  const layout = readModuleLayout();
2889
+ if (records.length) mountModuleLayoutToggle(layout, "设定列表样式");
2790
2890
  $("#module-content").innerHTML = records.length
2791
- ? `${renderModuleLayoutToggle(layout, "设定列表样式")}${layout === "rows" ? renderSettingRows(records) : renderSettingCards(records)}`
2891
+ ? `${layout === "rows" ? renderSettingRows(records) : renderSettingCards(records)}`
2792
2892
  : emptyModule("还没有世界观设定", "新建规则、地点、组织、科技或创作约束。AI 提取的候选也会进入这里。");
2793
2893
  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
2894
  $("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => openSettingEditor(records.find((item) => item.id === button.dataset.editSetting))));
2800
2895
  bindEntityHistoryButtons(async () => { await renderSettings(); await loadAiReferences(); });
2801
2896
  }
@@ -2807,23 +2902,26 @@ async function renderCharacters() {
2807
2902
  canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
2808
2903
  ]);
2809
2904
  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>` : ""}`;
2905
+ const characterActions = (item) => recordCardEditButton("edit-character", item.id, `角色“${item.name}”`);
2811
2906
  const characterCards = () => `<div class="card-grid">${state.characters.map((item) => {
2812
2907
  const details = normalizeCharacterDetails(item.attributes?.details);
2813
2908
  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>` : ""}
2909
+ <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>
2910
+ <h3>${esc(item.name)}</h3>
2817
2911
  ${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
2912
+ ${item.aliases.length ? `<div class="character-aliases">${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
2913
+ ${item.code ? `<div class="character-code"><b>编号</b><span class="pill">${esc(item.code)}</span></div>` : ""}
2914
+ ${item.species ? `<div class="character-species"><b>种族</b><span class="pill">${esc(racePathLabel(item.race) || item.species)}</span></div>` : ""}
2818
2915
  ${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
2916
  <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
2917
  ${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
2918
  ${item.profileSectionCount ? `<small class="character-section-count">${item.profileSectionCount} 个设定章节</small>` : ""}
2822
- <div class="card-actions">${characterActions(item)}</div></article>`;
2919
+ </article>`;
2823
2920
  }).join("")}</div>`;
2824
2921
  const characterRows = () => `<div class="module-row-list">${state.characters.map((item) => {
2825
2922
  const preview = moduleRowPreview(item.profile?.summary || item.attributes?.identity || Object.entries(item.currentState).map(([key, value]) => `${key}:${value}`).join(" ") || "尚未记录当前状态");
2826
2923
  const meta = [
2924
+ item.code ? `编号 ${item.code}` : "",
2827
2925
  item.species ? (racePathLabel(item.race) || item.species) : "",
2828
2926
  ...(item.aliases ?? []).slice(0, 3),
2829
2927
  (item.organizations ?? []).length ? (item.organizations ?? []).map((organization) => organization.name).join("、") : ""
@@ -2838,8 +2936,9 @@ async function renderCharacters() {
2838
2936
  </article>`;
2839
2937
  }).join("")}</div>`;
2840
2938
  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>` : "";
2939
+ if (state.characters.length) mountModuleLayoutToggle(layout, "角色列表样式");
2841
2940
  $("#module-content").innerHTML = auditPanel + (state.characters.length
2842
- ? `${renderModuleLayoutToggle(layout, "角色列表样式")}${layout === "rows" ? characterRows() : characterCards()}`
2941
+ ? `${layout === "rows" ? characterRows() : characterCards()}`
2843
2942
  : emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
2844
2943
  bindModuleLayoutToggle(renderCharacters);
2845
2944
  $("#create-character-audit-task")?.addEventListener("click", async () => {
@@ -2864,34 +2963,6 @@ async function renderCharacters() {
2864
2963
  });
2865
2964
  });
2866
2965
  $("#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
2966
  }
2896
2967
 
2897
2968
  async function renderRaces() {
@@ -2900,16 +2971,22 @@ async function renderRaces() {
2900
2971
  canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
2901
2972
  ]);
2902
2973
  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>` : ""}`;
2974
+ const canEditRaces = canEditModule("races");
2975
+ const raceActions = (item) => canEditRaces
2976
+ ? recordCardEditButton("edit-race", item.id, `种族“${item.name}”`)
2977
+ : recordHistoryButton("race", item.id, item.name);
2978
+ const raceCardActions = (item) => canEditRaces
2979
+ ? raceActions(item)
2980
+ : `<div class="card-actions">${raceActions(item)}</div>`;
2904
2981
  const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
2905
2982
  <summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
2906
2983
  <div class="race-tree-branch">
2907
- <article class="record-card race-card"><small>${item.memberIds.length} 位直接角色 · ${item.settings.length ? "已填写共同设定" : "暂无共同设定"}</small>
2984
+ <article class="record-card race-card${canEditRaces ? " has-card-edit" : ""}"><small>${item.memberIds.length} 位直接角色 · ${item.settings.length} 条自身设定</small>
2908
2985
  <div class="race-path" aria-label="种族路径">${esc(racePathLabel(item))}</div>
2909
2986
  <p>${esc(item.description || "尚未填写种族简介")}</p>
2910
2987
  <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
2988
  <p class="race-members">直接角色:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
2912
- <div class="card-actions">${raceActions(item)}</div>
2989
+ ${raceCardActions(item)}
2913
2990
  </article>
2914
2991
  ${item.children.length ? `<div class="race-tree-children">${item.children.map(renderRaceNode).join("")}</div>` : ""}
2915
2992
  </div>
@@ -2925,35 +3002,12 @@ async function renderRaces() {
2925
3002
  <div class="card-actions">${raceActions(item)}</div>
2926
3003
  </article>`;
2927
3004
  }).join("")}</div>`;
3005
+ if (state.races.length) mountModuleLayoutToggle(layout, "种族列表样式");
2928
3006
  $("#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>`}`
3007
+ ? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>`}`
2930
3008
  : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
2931
3009
  bindModuleLayoutToggle(renderRaces);
2932
3010
  $("#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
3011
  bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
2958
3012
  }
2959
3013
 
@@ -2963,13 +3017,19 @@ async function renderOrganizations() {
2963
3017
  canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
2964
3018
  ]);
2965
3019
  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>` : ""}`;
3020
+ const canEditOrganizations = canEditModule("organizations");
3021
+ const organizationActions = (item) => canEditOrganizations
3022
+ ? recordCardEditButton("edit-organization", item.id, `组织“${item.name}”`)
3023
+ : recordHistoryButton("organization", item.id, item.name);
3024
+ const organizationCardActions = (item) => canEditOrganizations
3025
+ ? organizationActions(item)
3026
+ : `<div class="card-actions">${organizationActions(item)}</div>`;
2967
3027
  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>
3028
+ <article class="record-card organization-card${canEditOrganizations ? " has-card-edit" : ""}"><small>${item.memberIds.length} 位成员 · ${item.settings.length ? "已填写组织设定" : "暂无组织设定"}</small>
2969
3029
  <h3>${esc(item.name)}</h3><p>${esc(item.description || "尚未填写组织简介")}</p>
2970
3030
  <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
3031
  <p class="organization-members">成员:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
2972
- <div class="card-actions">${organizationActions(item)}</div>
3032
+ ${organizationCardActions(item)}
2973
3033
  </article>`).join("")}</div>`;
2974
3034
  const organizationRows = () => `<div class="module-row-list">${state.organizations.map((item) => {
2975
3035
  const preview = moduleRowPreview(item.description || "尚未填写组织简介");
@@ -2982,51 +3042,56 @@ async function renderOrganizations() {
2982
3042
  <div class="card-actions">${organizationActions(item)}</div>
2983
3043
  </article>`;
2984
3044
  }).join("")}</div>`;
3045
+ if (state.organizations.length) mountModuleLayoutToggle(layout, "组织列表样式");
2985
3046
  $("#module-content").innerHTML = state.organizations.length
2986
- ? `${renderModuleLayoutToggle(layout, "组织列表样式")}${layout === "rows" ? organizationRows() : organizationCards()}`
3047
+ ? `${layout === "rows" ? organizationRows() : organizationCards()}`
2987
3048
  : emptyModule("还没有组织", "创建国家、机构、阵营或团队,并维护组织设定与成员。");
2988
3049
  bindModuleLayoutToggle(renderOrganizations);
2989
3050
  $("#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
3051
  bindEntityHistoryButtons(async () => { await renderOrganizations(); await loadAiReferences(); });
3015
3052
  }
3016
3053
 
3054
+ function updateTimelineMultiSelectControls() {
3055
+ const toggle = $("#timeline-multi-select-toggle");
3056
+ if (!toggle) return;
3057
+ const selectedCount = $("#module-content").querySelectorAll("[data-event-select]:checked").length;
3058
+ toggle.setAttribute("aria-pressed", String(timelineMultiSelectEnabled));
3059
+ toggle.textContent = timelineMultiSelectEnabled ? "退出多选" : "多选";
3060
+ $("#module-content").querySelectorAll("[data-event-select]").forEach((input) => {
3061
+ input.hidden = !timelineMultiSelectEnabled;
3062
+ if (!timelineMultiSelectEnabled) input.checked = false;
3063
+ });
3064
+ const merge = $("#merge-events");
3065
+ if (merge) {
3066
+ merge.hidden = !timelineMultiSelectEnabled;
3067
+ merge.disabled = !timelineMultiSelectEnabled || selectedCount < 2;
3068
+ }
3069
+ }
3070
+
3071
+ function setTimelineMultiSelectMode(enabled) {
3072
+ timelineMultiSelectEnabled = enabled;
3073
+ updateTimelineMultiSelectControls();
3074
+ }
3075
+
3017
3076
  async function renderTimeline() {
3018
3077
  const [events, tracks] = await Promise.all([
3019
3078
  apiPage(`/api/works/${state.work.id}/timeline`).then((result) => result.items),
3020
3079
  apiAllPages(`/api/works/${state.work.id}/timeline-tracks`)
3021
3080
  ]);
3081
+ timelineMultiSelectEnabled = false;
3082
+ $("#timeline-tools")?.remove();
3083
+ $("#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
3084
  state.timelineTracks = tracks;
3023
3085
  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) => {
3086
+ 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>`;
3087
+ $("#module-content").innerHTML = `<div class="timeline-kanban" data-testid="timeline-kanban">${lanes.map((track) => {
3026
3088
  const laneEvents = events.filter((item) => (item.trackId ?? "") === track.id);
3027
3089
  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
3090
  }).join("")}</div>`;
3029
3091
  $("#create-timeline-track").addEventListener("click", () => openTimelineTrackDialog());
3092
+ $("#timeline-multi-select-toggle").addEventListener("click", () => setTimelineMultiSelectMode(!timelineMultiSelectEnabled));
3093
+ $("#module-content").querySelectorAll("[data-event-select]").forEach((input) => input.addEventListener("change", updateTimelineMultiSelectControls));
3094
+ setTimelineMultiSelectMode(false);
3030
3095
  $("#module-content").querySelectorAll("[data-edit-timeline-track]").forEach((button) => button.addEventListener("click", () => openTimelineTrackDialog(tracks.find((track) => track.id === button.dataset.editTimelineTrack))));
3031
3096
  $("#module-content").querySelectorAll("[data-add-event-track]").forEach((button) => button.addEventListener("click", () => openTimelineDialog(null, button.dataset.addEventTrack || null)));
3032
3097
  $("#module-content").querySelectorAll("[data-edit-event]").forEach((button) => button.addEventListener("click", () => openTimelineDialog(events.find((item) => item.id === button.dataset.editEvent))));
@@ -3056,8 +3121,6 @@ async function renderOutlines() {
3056
3121
  const layout = readModuleLayout();
3057
3122
  const unresolved = foreshadows.filter((item) => item.unresolved);
3058
3123
  const overdue = unresolved.filter((item) => item.overdue);
3059
- const navButton = $("#module-nav [data-module=outlines] .nav-label");
3060
- if (navButton) navButton.textContent = unresolved.length ? `大纲与伏笔 · ${unresolved.length}` : "大纲与伏笔";
3061
3124
  const foreshadowActions = (item) => `<button data-edit-foreshadow="${esc(item.id)}">编辑伏笔</button><button data-entity-history="foreshadow" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.title)}">版本历史</button>`;
3062
3125
  const foreshadowCards = () => `<div class="card-grid foreshadow-grid">${foreshadows.map((item) => `
3063
3126
  <article class="record-card foreshadow-card ${item.overdue ? "is-overdue" : ""}">
@@ -3080,8 +3143,9 @@ async function renderOutlines() {
3080
3143
  </article>`;
3081
3144
  }).join("")}</div>`;
3082
3145
  const foreshadowHtml = foreshadows.length
3083
- ? `${renderModuleLayoutToggle(layout, "伏笔列表样式")}${layout === "rows" ? foreshadowRows() : foreshadowCards()}`
3146
+ ? `${layout === "rows" ? foreshadowRows() : foreshadowCards()}`
3084
3147
  : emptyModule("还没有伏笔", "创建伏笔并关联埋设、提醒与回收章节,未回收项会持续显示。\n");
3148
+ if (foreshadows.length) mountModuleLayoutToggle(layout, "伏笔列表样式");
3085
3149
  const outlineHtml = outlines.length ? `<div class="outline-list">${outlines.map((item) => `
3086
3150
  <article class="outline-row ${item.status === "completed" ? "is-complete" : ""}">
3087
3151
  <div><small>${esc(item.volumeTitle)} · ${esc(item.status)}</small><h3>${esc(item.chapterTitle)}</h3></div>
@@ -3108,7 +3172,7 @@ async function renderRelationships() {
3108
3172
  state.relationshipGraph = graph;
3109
3173
  $("#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
3174
  <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>'}`;
3175
+ <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
3176
  const openGalaxy = () => {
3113
3177
  state.galaxy?.destroy();
3114
3178
  state.galaxy = createGalaxyRenderer($("#relationship-galaxy-dialog"), graph, { workId: state.work.id });
@@ -3172,8 +3236,9 @@ async function renderReviews() {
3172
3236
  <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
3237
  </article>`;
3174
3238
  };
3239
+ if (reviews.length) mountModuleLayoutToggle(layout, "审核列表样式");
3175
3240
  $("#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>`}`
3241
+ ? `${layout === "rows" ? `<div class="module-row-list">${reviews.map(reviewRow).join("")}</div>` : `<div class="card-grid">${reviews.map(reviewCard).join("")}</div>`}`
3177
3242
  : emptyModule("没有待审核事项", "候选设定、冲突与低置信度结论会集中显示在这里。");
3178
3243
  bindModuleLayoutToggle(renderReviews);
3179
3244
  $("#module-content").querySelectorAll("[data-review-id]").forEach((button) => button.addEventListener("click", async () => {
@@ -3973,8 +4038,12 @@ function openWorkSettingsDialog(work) {
3973
4038
  <div><strong id="import-history-settings-title">正文导入历史</strong><small>查看导入前快照并恢复被覆盖的分卷、章节标题和正文;大纲、伏笔等章节关联资料不在快照中。</small></div>
3974
4039
  <button id="import-history-button" class="ghost-button" type="button" aria-controls="import-history-dialog" aria-haspopup="dialog" ${canOpenImportHistory ? "" : "disabled"}>${importHistoryAction}</button>
3975
4040
  </section>`;
4041
+ const whitespaceField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="whitespace-settings-title">
4042
+ <div><strong id="whitespace-settings-title">正文空白符</strong><small>在编辑器正文中显示或隐藏空格、全角空格和 Tab 的可视标记。</small></div>
4043
+ <button id="toggle-whitespace-settings" class="ghost-button" data-toggle-whitespace type="button" aria-pressed="${chapterWhitespaceVisible}" title="用点标记半角空格,用方框标记全角空格,用箭头标记 Tab">${chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符"}</button>
4044
+ </section>` : "";
3976
4045
  openDialog("作品信息",
3977
- workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + accessField + importHistoryField,
4046
+ workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + whitespaceField + accessField + importHistoryField,
3978
4047
  async (form) => {
3979
4048
  await api(`/api/works/${work.id}`, { method: "PATCH", body: { title: form.get("title"), author: form.get("author"), description: form.get("description") } });
3980
4049
  state.works = (await apiPage("/api/works")).items;
@@ -3986,12 +4055,13 @@ function openWorkSettingsDialog(work) {
3986
4055
  state.work.description = String(form.get("description") ?? state.work.description);
3987
4056
  if (updated?.coverUrl !== undefined) state.work.coverUrl = updated.coverUrl;
3988
4057
  updateDocumentTitle(state.work);
3989
- $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
4058
+ $("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
3990
4059
  }
3991
4060
  renderShelf();
3992
4061
  toast("作品信息已保存");
3993
4062
  }, "作品设置");
3994
4063
  bindWorkCoverControls(work);
4064
+ $("#toggle-whitespace-settings")?.addEventListener("click", toggleChapterWhitespaceVisibility);
3995
4065
  $("#import-history-button")?.addEventListener("click", () => {
3996
4066
  $("#form-dialog").close();
3997
4067
  void openImportHistory();
@@ -4057,6 +4127,43 @@ function openSettingEditor(item = null) {
4057
4127
  $("#setting-editor-form").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = viewOnly; });
4058
4128
  $("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
4059
4129
  $("#setting-editor-submit").classList.toggle("hidden", viewOnly);
4130
+ const management = $("#setting-editor-management");
4131
+ management.classList.toggle("hidden", !item || viewOnly);
4132
+ const statusButtons = [$("#setting-editor-confirm"), $("#setting-editor-deprecate")];
4133
+ $("#setting-editor-confirm").classList.toggle("hidden", item?.status !== "pending");
4134
+ $("#setting-editor-deprecate").classList.toggle("hidden", item?.status !== "pending");
4135
+ $("#setting-editor-history").onclick = async () => {
4136
+ if (!item) return;
4137
+ if (!(await closeEntityEditor())) return;
4138
+ openEntityHistory("setting", item.id, item.title, async () => { await renderSettings(); await loadAiReferences(); });
4139
+ };
4140
+ $("#setting-editor-delete").onclick = () => {
4141
+ if (!item) return;
4142
+ void deleteManagedEntity({
4143
+ typeLabel: "设定",
4144
+ item,
4145
+ endpoint: (setting) => `/api/settings/${encodeURIComponent(setting.id)}`,
4146
+ refresh: () => closeEntityEditor({ force: true }),
4147
+ warning: "这条设定及其版本历史会被删除。"
4148
+ });
4149
+ };
4150
+ statusButtons.forEach((button) => {
4151
+ button.onclick = async () => {
4152
+ if (!item) return;
4153
+ button.disabled = true;
4154
+ const status = button.id === "setting-editor-confirm" ? "confirmed" : "deprecated";
4155
+ try {
4156
+ await api(`/api/settings/${item.id}`, { method: "PATCH", body: { status, changeNote: status === "confirmed" ? "确认 AI 设定候选" : "弃用 AI 设定候选" } });
4157
+ await closeEntityEditor({ force: true });
4158
+ await loadAiReferences();
4159
+ toast(status === "confirmed" ? "设定候选已确认" : "设定候选已弃用");
4160
+ } catch (error) {
4161
+ toast(error.message, "error");
4162
+ } finally {
4163
+ button.disabled = false;
4164
+ }
4165
+ };
4166
+ });
4060
4167
  $("#setting-editor-form").onsubmit = async (event) => {
4061
4168
  event.preventDefault();
4062
4169
  if (!canEditModule("settings")) return;
@@ -4278,7 +4385,7 @@ function createVditorUploadHandler(uploadAttachment, getEditor) {
4278
4385
  };
4279
4386
  }
4280
4387
 
4281
- function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment = uploadMarkdownAttachment, placeholder = "", readOnly = false } = {}) {
4388
+ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment = uploadMarkdownAttachment, placeholder = "", readOnly = false, width = "auto" } = {}) {
4282
4389
  if (!window.Vditor) {
4283
4390
  toast("Markdown 编辑器资源加载失败,请刷新页面后重试", "error");
4284
4391
  return null;
@@ -4292,6 +4399,7 @@ function createVditorEditor(host, value, { onInput = () => {}, uploadAttachment
4292
4399
  mode: "ir",
4293
4400
  value: String(value ?? ""),
4294
4401
  height: "100%",
4402
+ width,
4295
4403
  minHeight: 260,
4296
4404
  placeholder,
4297
4405
  preview: { transform: transformVditorPreview },
@@ -4446,7 +4554,9 @@ async function openKnowledgeSectionEditor(index = null) {
4446
4554
  const titleInput = $("#knowledge-section-title");
4447
4555
  host.querySelectorAll("input, textarea").forEach((control) => control.addEventListener("input", () => { knowledgeSectionEditorDirty = true; }));
4448
4556
  knowledgeSectionVditor = createVditorEditor($("#knowledge-section-markdown"), section?.contentMarkdown ?? "", {
4449
- onInput: () => { knowledgeSectionEditorDirty = true; }
4557
+ onInput: () => { knowledgeSectionEditorDirty = true; },
4558
+ placeholder: "从这里开始写 Markdown 设定…",
4559
+ width: "100%"
4450
4560
  });
4451
4561
  host.querySelector("[data-knowledge-section-edit-close]").addEventListener("click", () => void closeKnowledgeSectionEditor());
4452
4562
  host.querySelector("[data-knowledge-section-edit-cancel]").addEventListener("click", () => void closeKnowledgeSectionEditor());
@@ -4505,7 +4615,9 @@ async function openCharacterSectionEditor(section = null) {
4505
4615
  host.querySelectorAll("input, textarea, select").forEach((control) => control.addEventListener("input", () => { characterSectionEditorDirty = true; }));
4506
4616
  characterSectionVditor = createVditorEditor($("#character-section-markdown"), section?.contentMarkdown ?? "", {
4507
4617
  uploadAttachment: uploadCharacterSectionAttachment,
4508
- onInput: () => { characterSectionEditorDirty = true; }
4618
+ onInput: () => { characterSectionEditorDirty = true; },
4619
+ placeholder: "从这里开始写人物章节…",
4620
+ width: "100%"
4509
4621
  });
4510
4622
  host.querySelector("[data-character-section-edit-close]").addEventListener("click", () => void closeCharacterSectionEditor());
4511
4623
  host.querySelector("[data-character-section-edit-cancel]").addEventListener("click", () => void closeCharacterSectionEditor());
@@ -4654,6 +4766,7 @@ function renderCharacterEditorFields(item) {
4654
4766
  ? field("firstChapterId", "首次登场章节", "select", item?.firstChapterId ?? "", chapterOptions)
4655
4767
  : '<div class="character-editor-empty-field"><b>首次登场章节</b><span>当前账户没有正文读取权限,原有绑定不会被修改。</span></div>')),
4656
4768
  characterEditorSection("profile", "人物档案", "记录人物定位、行为动力和便于创作时快速理解的简介。",
4769
+ field("code", "编号", "text", item?.code) +
4657
4770
  field("identity", "身份与定位", "text", item?.attributes?.identity) +
4658
4771
  field("motivation", "核心动机", "textarea", item?.profile?.motivation) +
4659
4772
  field("summary", "人物简介", "textarea", item?.profile?.summary)),
@@ -4690,6 +4803,7 @@ function collectCharacterBody(form) {
4690
4803
  delete profile.sections;
4691
4804
  const body = {
4692
4805
  name: String(form.get("name") ?? "").trim(),
4806
+ code: String(form.get("code") ?? "").trim(),
4693
4807
  aliases: form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean),
4694
4808
  attributes: {
4695
4809
  ...(item?.attributes ?? {}),
@@ -4796,6 +4910,34 @@ async function openCharacterEditor(item = null) {
4796
4910
  $("#character-editor-submit").textContent = item ? "保存新版本" : "创建人物档案";
4797
4911
  $("#character-history-button").disabled = !item;
4798
4912
  $("#character-history-button").title = item ? "查看、比较和回滚历史版本" : "创建人物档案后即可查看版本历史";
4913
+ const characterMergeButton = $("#character-merge-button");
4914
+ const characterDeleteButton = $("#character-delete-button");
4915
+ const canManageCharacter = Boolean(item && canEditModule("characters"));
4916
+ characterMergeButton.classList.toggle("hidden", !canManageCharacter || state.characters.length < 2);
4917
+ characterDeleteButton.classList.toggle("hidden", !canManageCharacter);
4918
+ characterMergeButton.onclick = async () => {
4919
+ if (!item) return;
4920
+ await closeEntityEditor({ force: true });
4921
+ openEntityMergeDialog({
4922
+ typeLabel: "角色",
4923
+ source: item,
4924
+ candidates: state.characters,
4925
+ endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}/merge`,
4926
+ body: (target) => ({ targetCharacterId: target.id, expectedTargetVersionNo: target.versionNo, expectedSourceVersionNo: item.versionNo }),
4927
+ refresh: renderCharacters,
4928
+ impact: "来源角色的别名、组织、档案章节、时间线与人物关系会迁移到目标角色。"
4929
+ });
4930
+ };
4931
+ characterDeleteButton.onclick = () => {
4932
+ if (!item) return;
4933
+ void deleteManagedEntity({
4934
+ typeLabel: "角色",
4935
+ item,
4936
+ endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}`,
4937
+ refresh: () => closeEntityEditor({ force: true }),
4938
+ warning: "相关人物关系会删除,时间线中的参与者引用会移除。"
4939
+ });
4940
+ };
4799
4941
  setCharacterHistoryVisible(false);
4800
4942
  renderCharacterEditorFields(item);
4801
4943
  const viewOnly = !canEditModule("characters");
@@ -4900,6 +5042,43 @@ async function openKnowledgeEditor(kind, item) {
4900
5042
  $("#knowledge-editor-header-note").textContent = isRace ? "层级、共同设定与角色归属" : "简介、组织设定与成员归属";
4901
5043
  $("#knowledge-editor-footer-note").textContent = `保存${label}档案后返回${isRace ? "种族" : "组织"}列表。`;
4902
5044
  $("#knowledge-editor-submit").textContent = item ? `保存${label}档案` : `创建${label}档案`;
5045
+ const historyButton = $("#knowledge-editor-history");
5046
+ const mergeButton = $("#knowledge-editor-merge");
5047
+ const deleteButton = $("#knowledge-editor-delete");
5048
+ const refresh = isRace ? renderRaces : renderOrganizations;
5049
+ const candidates = isRace ? state.races : state.organizations;
5050
+ const typeLabel = label;
5051
+ historyButton.classList.toggle("hidden", !item);
5052
+ mergeButton.classList.toggle("hidden", !item || !canEditModule(module) || candidates.length < 2);
5053
+ deleteButton.classList.toggle("hidden", !item || !canEditModule(module));
5054
+ historyButton.onclick = async () => {
5055
+ if (!item) return;
5056
+ if (!(await closeEntityEditor())) return;
5057
+ openEntityHistory(kind, item.id, item.name, async () => { await refresh(); await loadAiReferences(); });
5058
+ };
5059
+ mergeButton.onclick = async () => {
5060
+ if (!item) return;
5061
+ await closeEntityEditor({ force: true });
5062
+ openEntityMergeDialog({
5063
+ typeLabel,
5064
+ source: item,
5065
+ candidates,
5066
+ endpoint: (source) => `/api/${module}/${encodeURIComponent(source.id)}/merge`,
5067
+ body: (target) => isRace ? { targetRaceId: target.id } : { targetOrganizationId: target.id },
5068
+ refresh,
5069
+ impact: isRace ? "来源种族的角色、子种族、简介与共同设定会迁移到目标种族。" : "来源组织的成员、简介与组织设定会迁移到目标组织。"
5070
+ });
5071
+ };
5072
+ deleteButton.onclick = () => {
5073
+ if (!item) return;
5074
+ void deleteManagedEntity({
5075
+ typeLabel,
5076
+ item,
5077
+ endpoint: (source) => `/api/${module}/${encodeURIComponent(source.id)}`,
5078
+ refresh: () => closeEntityEditor({ force: true }),
5079
+ warning: isRace ? "已绑定角色将变为未指定种族;有子种族时需先迁移或合并。" : "角色与该组织的成员关系会一并移除。"
5080
+ });
5081
+ };
4903
5082
  renderKnowledgeEditorFields(kind, item, memberOptions, parentOptions);
4904
5083
  const viewOnly = !canEditModule(module);
4905
5084
  if (viewOnly) {
@@ -5867,6 +6046,7 @@ function cleanupExpandedRelationshipMap() {
5867
6046
  $("#relationship-map-close").addEventListener("click", () => $("#relationship-map-dialog").close());
5868
6047
  $("#relationship-map-dialog").addEventListener("close", cleanupExpandedRelationshipMap);
5869
6048
  $("#appearance-button").addEventListener("click", openAppearanceDialog);
6049
+ $("#toggle-whitespace-appearance").addEventListener("click", toggleChapterWhitespaceVisibility);
5870
6050
  $("#theme-toggle").addEventListener("click", () => {
5871
6051
  const theme = nextTheme(currentColorTheme());
5872
6052
  const persisted = saveColorTheme(theme);
@@ -5898,10 +6078,6 @@ $("#chapter-content").addEventListener("input", (event) => {
5898
6078
  });
5899
6079
  $("#chapter-content").addEventListener("select", scheduleAiContextUsage);
5900
6080
  $("#chapter-content").addEventListener("scroll", syncChapterLineNumberScroll);
5901
- $("#toggle-whitespace-button").addEventListener("click", () => {
5902
- chapterWhitespaceVisible = !chapterWhitespaceVisible;
5903
- scheduleChapterLineNumbers();
5904
- });
5905
6081
  $("#chapter-line-numbers-inner").addEventListener("pointerdown", (event) => {
5906
6082
  const row = event.target.closest(".chapter-line-number");
5907
6083
  if (!row || event.button !== 0) return;
@@ -6103,6 +6279,13 @@ document.addEventListener("keydown", (event) => {
6103
6279
  hideAiMentionMenu();
6104
6280
  }
6105
6281
  });
6282
+ document.addEventListener("keydown", (event) => {
6283
+ if (!isGlobalSearchShortcut(event) || !state.work) return;
6284
+ event.preventDefault();
6285
+ event.stopPropagation();
6286
+ if (event.repeat) return;
6287
+ openSearchDialog().catch((error) => toast(error.message, "error"));
6288
+ }, { capture: true });
6106
6289
  $("#ai-send").addEventListener("click", sendAi);
6107
6290
  $("#ai-new-conversation").addEventListener("click", async () => {
6108
6291
  const button = $("#ai-new-conversation");