@musnows/scriverse 0.5.5 → 0.5.6

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.
@@ -133,6 +133,11 @@ const acknowledgedCollaborativeChangeIds = new Set();
133
133
  let collaborativeChangePromptOpen = false;
134
134
  let relationshipPresenceId = null;
135
135
  let collaborationAutoSaveDisabled = false;
136
+ let chapterAnnotations = [];
137
+ let workAuditRecords = [];
138
+ let workAuditNextPage = null;
139
+ const chapterBatchSelectedIds = new Set();
140
+ let chapterMovePending = false;
136
141
 
137
142
  let timelineMultiSelectEnabled = false;
138
143
  let taskProgressRefreshTimer = null;
@@ -277,6 +282,7 @@ function applyWorkAccessMode() {
277
282
  }
278
283
  $("#module-nav [data-work-settings]").classList.toggle("permission-hidden", Boolean(state.work) && !canManageWork());
279
284
  $("#new-volume-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
285
+ $("#chapter-batch-button").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
280
286
  $("#welcome-new-work").classList.toggle("permission-hidden", Boolean(state.work) && proseReadOnly);
281
287
  $("#import-file-button").setAttribute("aria-disabled", String(proseReadOnly));
282
288
  $("#import-file-button").setAttribute("title", proseReadOnly ? "当前权限不能导入正文" : "导入 TXT / DOCX");
@@ -897,6 +903,8 @@ function applyChapterEditorMode() {
897
903
  $("#chapter-title").setAttribute("aria-readonly", String(viewOnly));
898
904
  $("#chapter-content").setAttribute("aria-readonly", String(viewOnly));
899
905
  $("#chapter-edit-button").classList.toggle("hidden", permissionBlocked || !chapterEditorReadOnly || !state.chapter);
906
+ $("#chapter-delete-button").classList.toggle("hidden", permissionBlocked || !state.chapter);
907
+ $("#chapter-annotations-button").classList.toggle("hidden", !state.chapter);
900
908
  if (viewOnly) cancelChapterAutoSave();
901
909
  }
902
910
 
@@ -1735,6 +1743,96 @@ function addSelectedLinesAsCitation() {
1735
1743
  toast(`已引用《${citation.chapterTitle}》第 ${citation.startLine}${citation.startLine === citation.endLine ? "" : `-${citation.endLine}`} 行`);
1736
1744
  }
1737
1745
 
1746
+ async function createSelectedLineAnnotation(kind) {
1747
+ if (!state.chapter || !chapterLineSelection || !canEditProse()) return;
1748
+ const selection = selectedChapterLinePayload(chapterLineSelection.start, chapterLineSelection.end);
1749
+ closeLineCitationMenu();
1750
+ const note = await inputToast(kind === "todo" ? "描述需要后续处理的事项" : "写下这段正文的批注", {
1751
+ title: kind === "todo" ? "添加正文待办" : "添加正文批注",
1752
+ inputLabel: kind === "todo" ? "待办内容" : "批注内容",
1753
+ confirmLabel: "添加",
1754
+ maxLength: 2000
1755
+ });
1756
+ if (!note) return;
1757
+ try {
1758
+ await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}/annotations`, {
1759
+ method: "POST",
1760
+ body: { kind, startLine: selection.safeStart + 1, endLine: selection.safeEnd + 1, note }
1761
+ });
1762
+ toast(kind === "todo" ? "正文待办已添加" : "正文批注已添加");
1763
+ await openChapterAnnotationsDialog();
1764
+ } catch (error) {
1765
+ toast(error.message, "error");
1766
+ }
1767
+ }
1768
+
1769
+ function renderChapterAnnotations() {
1770
+ const host = $("#chapter-annotations-list");
1771
+ host.innerHTML = chapterAnnotations.length ? chapterAnnotations.map((annotation) => `<article class="chapter-annotation-card ${annotation.status === "resolved" ? "is-resolved" : ""}" data-annotation-id="${esc(annotation.id)}">
1772
+ <header><span class="chapter-annotation-kind">${annotation.kind === "todo" ? "待办" : "批注"}</span><strong>${annotation.startLine === annotation.endLine ? `第 ${annotation.startLine} 行` : `第 ${annotation.startLine}-${annotation.endLine} 行`}</strong><em>${annotation.status === "resolved" ? "已完成" : "处理中"}</em></header>
1773
+ <blockquote>${esc(annotation.quote || "空白行")}</blockquote>
1774
+ <p>${esc(annotation.note)}</p>
1775
+ <small>${esc(annotation.actor)} · ${esc(formatDateTime(annotation.updatedAt))} · v${Number(annotation.versionNo)}</small>
1776
+ <footer><button type="button" data-annotation-locate>定位原文</button>${canEditProse() ? `<button type="button" data-annotation-edit>编辑说明</button><button type="button" data-annotation-status>${annotation.status === "resolved" ? "重新打开" : "完成待办"}</button><button class="danger-button" type="button" data-annotation-delete>删除</button>` : ""}</footer>
1777
+ </article>`).join("") : '<p class="entity-history-empty">本章还没有批注或待办。选择正文行并打开右键菜单即可添加。</p>';
1778
+ host.querySelectorAll("[data-annotation-id]").forEach((card) => {
1779
+ const annotation = chapterAnnotations.find((item) => item.id === card.dataset.annotationId);
1780
+ if (!annotation) return;
1781
+ card.querySelector("[data-annotation-locate]")?.addEventListener("click", () => {
1782
+ $("#chapter-annotations-dialog").close();
1783
+ paintChapterLineSelection(annotation.startLine - 1, annotation.endLine - 1);
1784
+ selectChapterLines(annotation.startLine - 1, annotation.endLine - 1);
1785
+ });
1786
+ card.querySelector("[data-annotation-status]")?.addEventListener("click", async () => {
1787
+ try {
1788
+ await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "PATCH", body: { status: annotation.status === "resolved" ? "open" : "resolved", expectedVersionNo: annotation.versionNo } });
1789
+ await loadChapterAnnotations();
1790
+ } catch (error) { toast(error.message, "error"); }
1791
+ });
1792
+ card.querySelector("[data-annotation-edit]")?.addEventListener("click", async () => {
1793
+ const dialog = $("#chapter-annotations-dialog");
1794
+ dialog.close();
1795
+ const note = await inputToast("修改批注或待办说明", { title: "编辑说明", inputLabel: "说明", placeholder: annotation.note, confirmLabel: "保存", maxLength: 2000 });
1796
+ if (!note) return dialog.showModal();
1797
+ try {
1798
+ await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "PATCH", body: { note, expectedVersionNo: annotation.versionNo } });
1799
+ await loadChapterAnnotations();
1800
+ } catch (error) { toast(error.message, "error"); }
1801
+ dialog.showModal();
1802
+ });
1803
+ card.querySelector("[data-annotation-delete]")?.addEventListener("click", async () => {
1804
+ const dialog = $("#chapter-annotations-dialog");
1805
+ dialog.close();
1806
+ const confirmed = await confirmToast("删除后不会在本章清单中显示,但历史版本仍会保留。", { title: "删除批注或待办", confirmLabel: "确认删除" });
1807
+ if (!confirmed) return dialog.showModal();
1808
+ try {
1809
+ await api(`/api/chapter-annotations/${encodeURIComponent(annotation.id)}`, { method: "DELETE", body: { expectedVersionNo: annotation.versionNo } });
1810
+ await loadChapterAnnotations();
1811
+ dialog.showModal();
1812
+ } catch (error) { dialog.showModal(); toast(error.message, "error"); }
1813
+ });
1814
+ });
1815
+ }
1816
+
1817
+ async function loadChapterAnnotations() {
1818
+ if (!state.chapter) return;
1819
+ chapterAnnotations = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}/annotations`);
1820
+ renderChapterAnnotations();
1821
+ }
1822
+
1823
+ async function openChapterAnnotationsDialog() {
1824
+ if (!state.chapter) return;
1825
+ $("#chapter-annotations-meta").textContent = `《${state.chapter.title}》的正文批注与待办`;
1826
+ $("#chapter-annotations-list").innerHTML = '<p class="entity-history-empty">正在加载批注与待办…</p>';
1827
+ if (!$("#chapter-annotations-dialog").open) $("#chapter-annotations-dialog").showModal();
1828
+ try {
1829
+ await loadChapterAnnotations();
1830
+ } catch (error) {
1831
+ $("#chapter-annotations-dialog").close();
1832
+ toast(error.message, "error");
1833
+ }
1834
+ }
1835
+
1738
1836
  function closeLineCitationMenu() {
1739
1837
  $("#line-citation-menu").classList.add("hidden");
1740
1838
  }
@@ -1746,6 +1844,8 @@ function showLineCitationMenu(event, lineIndex) {
1746
1844
  selectChapterLines(lineIndex, lineIndex);
1747
1845
  }
1748
1846
  const menu = $("#line-citation-menu");
1847
+ $("#add-line-annotation").classList.toggle("hidden", !canEditProse());
1848
+ $("#add-line-todo").classList.toggle("hidden", !canEditProse());
1749
1849
  const { start, end } = chapterLineSelection;
1750
1850
  $("#line-citation-label").textContent = start === end ? `第 ${start + 1} 行` : `第 ${start + 1}-${end + 1} 行`;
1751
1851
  menu.classList.remove("hidden");
@@ -2586,6 +2686,8 @@ function renderSettingsHub() {
2586
2686
  $("#user-management-button").classList.toggle("hidden", !isAdmin);
2587
2687
  $("#platform-ui-settings-button").classList.toggle("hidden", !isAdmin);
2588
2688
  $("#collaboration-button").disabled = !canManageWork;
2689
+ $("#writing-progress-button").disabled = !hasWork || !canReadModule("editor");
2690
+ $("#work-audit-button").disabled = !canManageWork;
2589
2691
  $("#top-search-button").disabled = !canReadAggregate;
2590
2692
  $("#export-button").disabled = !canReadAggregate;
2591
2693
  $("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
@@ -2594,6 +2696,115 @@ function renderSettingsHub() {
2594
2696
  : "当前未选择作品;打开作品后可使用导出。";
2595
2697
  }
2596
2698
 
2699
+ function renderWritingProgress(progress) {
2700
+ $("#writing-current-words").textContent = Number(progress.currentWords).toLocaleString("zh-CN");
2701
+ $("#writing-today-words").textContent = Number(progress.todayWords).toLocaleString("zh-CN");
2702
+ $("#writing-daily-completion").textContent = `${Math.round(Number(progress.dailyCompletion) * 100)}%`;
2703
+ $("#writing-total-completion").textContent = `${Math.round(Number(progress.totalCompletion) * 100)}%`;
2704
+ $("#writing-daily-goal").value = String(progress.goal.dailyGoal);
2705
+ $("#writing-total-goal").value = String(progress.goal.targetTotal);
2706
+ $("#writing-deadline").value = progress.goal.deadline ?? "";
2707
+ $("#writing-goal-save").disabled = !canEditProse();
2708
+ const maxWords = Math.max(1, ...progress.trend.map((item) => Number(item.words)));
2709
+ $("#writing-trend-chart").innerHTML = progress.trend.map((item, index) => {
2710
+ const height = Math.max(3, Math.round(Number(item.words) / maxWords * 100));
2711
+ const label = `${item.date}:${Number(item.words).toLocaleString("zh-CN")} 字,较前日 ${Number(item.delta) >= 0 ? "+" : ""}${Number(item.delta).toLocaleString("zh-CN")}`;
2712
+ return `<span class="writing-trend-bar${index === progress.trend.length - 1 ? " is-today" : ""}" style="--bar-height:${height}%" title="${esc(label)}" aria-label="${esc(label)}"><i></i><small>${index % 5 === 0 || index === progress.trend.length - 1 ? esc(item.date.slice(5)) : ""}</small></span>`;
2713
+ }).join("");
2714
+ }
2715
+
2716
+ async function loadWritingProgress() {
2717
+ if (!state.work) return;
2718
+ renderWritingProgress(await api(`/api/works/${encodeURIComponent(state.work.id)}/writing-progress`));
2719
+ }
2720
+
2721
+ async function openWritingProgressDialog() {
2722
+ if (!state.work || !canReadModule("editor")) return;
2723
+ $("#writing-progress-dialog").showModal();
2724
+ try {
2725
+ await loadWritingProgress();
2726
+ } catch (error) {
2727
+ $("#writing-progress-dialog").close();
2728
+ toast(error.message, "error");
2729
+ }
2730
+ }
2731
+
2732
+ const workAuditActionLabels = {
2733
+ "work.created": "创建作品",
2734
+ "work.updated": "更新作品",
2735
+ "volume.created": "创建分卷",
2736
+ "volume.updated": "更新分卷",
2737
+ "volume.deleted": "删除分卷",
2738
+ "chapter.created": "创建章节",
2739
+ "chapter.saved": "保存章节",
2740
+ "chapter.moved": "移动章节",
2741
+ "chapter.deleted": "删除章节",
2742
+ "chapter.restored": "恢复章节",
2743
+ "work.imported": "导入正文"
2744
+ };
2745
+
2746
+ function workAuditEntityLabel(type) {
2747
+ return ({ work: "作品", volume: "分卷", chapter: "章节", user: "用户" })[type] ?? type;
2748
+ }
2749
+
2750
+ function workAuditDetailText(detail) {
2751
+ const entries = Object.entries(detail ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== "").slice(0, 6);
2752
+ return entries.map(([key, value]) => `${key}: ${typeof value === "object" ? JSON.stringify(value) : String(value)}`).join(" · ");
2753
+ }
2754
+
2755
+ function renderWorkAuditRecords() {
2756
+ $("#work-audit-list").innerHTML = workAuditRecords.length ? workAuditRecords.map((record) => `<article class="work-audit-row">
2757
+ <time>${esc(formatDateTime(record.createdAt))}</time>
2758
+ <div><strong>${esc(workAuditActionLabels[record.action] ?? record.action)}</strong><span>${esc(record.actor)} · ${esc(workAuditEntityLabel(record.entityType))}${record.entityId ? ` · ${esc(record.entityId)}` : ""}</span>${workAuditDetailText(record.detail) ? `<small>${esc(workAuditDetailText(record.detail))}</small>` : ""}</div>
2759
+ </article>`).join("") : '<p class="entity-history-empty">当前作品还没有操作记录。</p>';
2760
+ $("#work-audit-load-more").classList.toggle("hidden", workAuditNextPage === null);
2761
+ }
2762
+
2763
+ async function loadWorkAuditPage(page = 1, append = false) {
2764
+ if (!state.work) return;
2765
+ const result = await apiPage(`/api/works/${encodeURIComponent(state.work.id)}/audit-logs`, page, 30);
2766
+ workAuditRecords = append ? [...workAuditRecords, ...result.items] : result.items;
2767
+ workAuditNextPage = result.nextPage;
2768
+ renderWorkAuditRecords();
2769
+ }
2770
+
2771
+ async function openWorkAuditDialog() {
2772
+ if (!state.work || !["admin", "owner"].includes(String(state.work.accessRole))) return;
2773
+ workAuditRecords = [];
2774
+ workAuditNextPage = null;
2775
+ $("#work-audit-list").innerHTML = '<p class="entity-history-empty">正在加载操作记录…</p>';
2776
+ $("#work-audit-dialog").showModal();
2777
+ try {
2778
+ await loadWorkAuditPage();
2779
+ } catch (error) {
2780
+ $("#work-audit-dialog").close();
2781
+ toast(error.message, "error");
2782
+ }
2783
+ }
2784
+
2785
+ async function saveWritingGoal(event) {
2786
+ event.preventDefault();
2787
+ if (!state.work || !canEditProse()) return;
2788
+ const button = $("#writing-goal-save");
2789
+ button.disabled = true;
2790
+ try {
2791
+ const progress = await api(`/api/works/${encodeURIComponent(state.work.id)}/writing-goal`, {
2792
+ method: "PUT",
2793
+ body: {
2794
+ dailyGoal: Number($("#writing-daily-goal").value),
2795
+ targetTotal: Number($("#writing-total-goal").value),
2796
+ deadline: $("#writing-deadline").value || null
2797
+ }
2798
+ });
2799
+ renderWritingProgress(progress);
2800
+ toast("写作目标已保存");
2801
+ } catch (error) {
2802
+ toast(error.message, "error");
2803
+ } finally {
2804
+ button.disabled = !canEditProse();
2805
+ }
2806
+ }
2807
+
2597
2808
  function renderUsers(users) {
2598
2809
  const currentUserId = state.user?.userId;
2599
2810
  $("#users-list").innerHTML = users.map((user) => `<article class="access-row" data-user-row="${esc(user.userId)}">
@@ -3022,12 +3233,12 @@ function renderTree() {
3022
3233
  $("#novel-tree").innerHTML = state.work.volumes.map((volume) => `
3023
3234
  <div class="volume-node ${state.collapsedVolumeIds.has(volume.id) ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
3024
3235
  <div class="volume-title">
3025
- <button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${state.collapsedVolumeIds.has(volume.id) ? "false" : "true"}" title="左键折叠,右键设置分卷"><span>${esc(volume.title)}</span><span>${volume.chapters.length} 章</span></button>
3236
+ <button class="volume-toggle" type="button" data-volume-toggle="${esc(volume.id)}" aria-expanded="${state.collapsedVolumeIds.has(volume.id) ? "false" : "true"}" title="左键折叠,右键设置分卷;可将章节拖到这里追加"><span>${esc(volume.title)}</span><span>${volume.chapters.length} 章</span></button>
3026
3237
  ${proseEditable ? `<button class="add-button chapter-add-button" type="button" data-new-chapter-volume="${esc(volume.id)}" aria-label="在“${esc(volume.title)}”中新建章节" title="在“${esc(volume.title)}”中新建章节">+</button>` : ""}
3027
3238
  </div>
3028
3239
  <div class="volume-chapters">
3029
3240
  ${volume.chapters.map((chapter) => `
3030
- <button class="chapter-node ${state.chapter?.id === chapter.id ? "active" : ""}" type="button" data-chapter-id="${esc(chapter.id)}">
3241
+ <button class="chapter-node ${state.chapter?.id === chapter.id ? "active" : ""}" type="button" data-chapter-id="${esc(chapter.id)}" draggable="${proseEditable ? "true" : "false"}" title="${proseEditable ? "拖拽排序;Alt+方向键排序,Alt+Shift+方向键跨卷" : ""}">
3031
3242
  <span>${esc(chapter.title)}</span><span class="chapter-node-meta">${chapter.chapterType && chapter.chapterType !== "正文" ? `<em class="chapter-type-badge">${esc(chapter.chapterType)}</em>` : ""}<small>${Number(chapter.wordCount ?? 0).toLocaleString("zh-CN")}</small></span>
3032
3243
  </button>`).join("")}
3033
3244
  </div>
@@ -3044,13 +3255,30 @@ function renderTree() {
3044
3255
  event.preventDefault();
3045
3256
  openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeToggle));
3046
3257
  });
3258
+ if (proseEditable) {
3259
+ button.addEventListener("dragover", (event) => {
3260
+ if (!event.dataTransfer?.types.includes("text/plain")) return;
3261
+ event.preventDefault();
3262
+ button.closest(".volume-node")?.classList.add("is-drag-target");
3263
+ });
3264
+ button.addEventListener("dragleave", () => button.closest(".volume-node")?.classList.remove("is-drag-target"));
3265
+ button.addEventListener("drop", async (event) => {
3266
+ event.preventDefault();
3267
+ button.closest(".volume-node")?.classList.remove("is-drag-target");
3268
+ const chapterId = event.dataTransfer?.getData("text/plain");
3269
+ const volume = state.work?.volumes.find((item) => item.id === button.dataset.volumeToggle);
3270
+ if (chapterId && volume) await moveChapterInTree(chapterId, volume.id, volume.chapters.length);
3271
+ });
3272
+ }
3047
3273
  });
3048
3274
  $("#novel-tree").querySelectorAll("[data-new-chapter-volume]").forEach((button) => {
3049
3275
  button.addEventListener("click", () => openChapterDialog(button.dataset.newChapterVolume));
3050
3276
  });
3051
3277
  $("#novel-tree").querySelectorAll("[data-chapter-id]").forEach((button) => {
3052
- button.addEventListener("click", () => {
3053
- selectChapter(button.dataset.chapterId);
3278
+ button.addEventListener("click", async () => {
3279
+ const chapterId = button.dataset.chapterId;
3280
+ await selectChapter(chapterId);
3281
+ $("#novel-tree").querySelector(`[data-chapter-id="${CSS.escape(chapterId)}"]`)?.focus();
3054
3282
  if (isMobileViewport()) {
3055
3283
  panelLayout.leftCollapsed = true;
3056
3284
  applyPanelLayout(true);
@@ -3061,9 +3289,172 @@ function renderTree() {
3061
3289
  event.preventDefault();
3062
3290
  openChapterTypeMenu(button.dataset.chapterId, event.clientX, event.clientY);
3063
3291
  });
3292
+ if (proseEditable) {
3293
+ button.addEventListener("dragstart", (event) => {
3294
+ event.dataTransfer?.setData("text/plain", button.dataset.chapterId);
3295
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
3296
+ button.classList.add("is-dragging");
3297
+ });
3298
+ button.addEventListener("dragend", () => {
3299
+ button.classList.remove("is-dragging");
3300
+ $("#novel-tree").querySelectorAll(".is-drag-over, .is-drag-target").forEach((node) => node.classList.remove("is-drag-over", "drop-after", "is-drag-target"));
3301
+ });
3302
+ button.addEventListener("dragover", (event) => {
3303
+ event.preventDefault();
3304
+ const after = event.clientY >= button.getBoundingClientRect().top + button.offsetHeight / 2;
3305
+ button.classList.toggle("drop-after", after);
3306
+ button.classList.add("is-drag-over");
3307
+ });
3308
+ button.addEventListener("dragleave", () => button.classList.remove("is-drag-over", "drop-after"));
3309
+ button.addEventListener("drop", async (event) => {
3310
+ event.preventDefault();
3311
+ event.stopPropagation();
3312
+ const chapterId = event.dataTransfer?.getData("text/plain");
3313
+ const target = findChapterLocation(button.dataset.chapterId);
3314
+ const after = button.classList.contains("drop-after");
3315
+ button.classList.remove("is-drag-over", "drop-after");
3316
+ if (!chapterId || !target) return;
3317
+ const targetChapters = target.volume.chapters.filter((chapter) => chapter.id !== chapterId);
3318
+ const targetIndex = targetChapters.findIndex((chapter) => chapter.id === button.dataset.chapterId);
3319
+ await moveChapterInTree(chapterId, target.volume.id, Math.max(0, targetIndex + (after ? 1 : 0)));
3320
+ });
3321
+ button.addEventListener("keydown", async (event) => {
3322
+ if (!event.altKey || !["ArrowUp", "ArrowDown"].includes(event.key)) return;
3323
+ event.preventDefault();
3324
+ await moveChapterByKeyboard(button.dataset.chapterId, event.key === "ArrowDown" ? 1 : -1, event.shiftKey);
3325
+ });
3326
+ }
3064
3327
  });
3065
3328
  }
3066
3329
 
3330
+ function renderChapterBatchDialog() {
3331
+ const chapters = state.work?.volumes.flatMap((volume) => volume.chapters.map((chapter) => ({ ...chapter, volumeTitle: volume.title }))) ?? [];
3332
+ for (const chapterId of chapterBatchSelectedIds) {
3333
+ if (!chapters.some((chapter) => chapter.id === chapterId)) chapterBatchSelectedIds.delete(chapterId);
3334
+ }
3335
+ $("#chapter-batch-list").innerHTML = chapters.length ? chapters.map((chapter) => `<label class="chapter-batch-item">
3336
+ <input type="checkbox" value="${esc(chapter.id)}" ${chapterBatchSelectedIds.has(chapter.id) ? "checked" : ""}>
3337
+ <span><strong>${esc(chapter.title)}</strong><small>${esc(chapter.volumeTitle)} · ${Number(chapter.wordCount ?? 0).toLocaleString("zh-CN")} 字 · ${esc(chapter.chapterType || "正文")}</small></span>
3338
+ </label>`).join("") : '<p class="entity-history-empty">当前作品还没有章节。</p>';
3339
+ $("#chapter-batch-list").querySelectorAll('input[type="checkbox"]').forEach((input) => input.addEventListener("change", () => {
3340
+ if (input.checked) chapterBatchSelectedIds.add(input.value);
3341
+ else chapterBatchSelectedIds.delete(input.value);
3342
+ updateChapterBatchControls();
3343
+ }));
3344
+ $("#chapter-batch-volume").innerHTML = (state.work?.volumes ?? []).map((volume) => `<option value="${esc(volume.id)}">${esc(volume.title)}</option>`).join("");
3345
+ updateChapterBatchControls();
3346
+ }
3347
+
3348
+ function updateChapterBatchControls() {
3349
+ const count = chapterBatchSelectedIds.size;
3350
+ const action = $("#chapter-batch-action").value;
3351
+ $("#chapter-batch-count").textContent = `已选择 ${count} 章`;
3352
+ $("#chapter-batch-apply").disabled = count === 0;
3353
+ $("#chapter-batch-volume-field").classList.toggle("hidden", action !== "move");
3354
+ $("#chapter-batch-type-field").classList.toggle("hidden", action !== "setType");
3355
+ $("#chapter-batch-apply").textContent = action === "delete" ? "软删除所选章节" : "应用到所选章节";
3356
+ }
3357
+
3358
+ function openChapterBatchDialog() {
3359
+ if (!state.work || !canEditProse()) return;
3360
+ chapterBatchSelectedIds.clear();
3361
+ renderChapterBatchDialog();
3362
+ $("#chapter-batch-dialog").showModal();
3363
+ }
3364
+
3365
+ async function submitChapterBatch(event) {
3366
+ event.preventDefault();
3367
+ if (!state.work || !chapterBatchSelectedIds.size) return;
3368
+ const chapters = state.work.volumes.flatMap((volume) => volume.chapters).filter((chapter) => chapterBatchSelectedIds.has(chapter.id));
3369
+ const actionValue = $("#chapter-batch-action").value;
3370
+ const action = actionValue === "move"
3371
+ ? { type: "move", volumeId: $("#chapter-batch-volume").value }
3372
+ : actionValue === "setType"
3373
+ ? { type: "setType", chapterType: $("#chapter-batch-type").value }
3374
+ : actionValue === "exclude" || actionValue === "include"
3375
+ ? { type: "setAnalysisExclusion", excludedFromAnalysis: actionValue === "exclude" }
3376
+ : { type: "delete" };
3377
+ const dialog = $("#chapter-batch-dialog");
3378
+ if (action.type === "delete") {
3379
+ dialog.close();
3380
+ const confirmed = await confirmToast(`所选 ${chapters.length} 个章节的正文、版本和关联资料会保留,后续可以恢复。仍要删除吗?`, {
3381
+ title: "批量删除需要再次确认",
3382
+ confirmLabel: "确认软删除"
3383
+ });
3384
+ if (!confirmed) {
3385
+ dialog.showModal();
3386
+ return;
3387
+ }
3388
+ }
3389
+ $("#chapter-batch-apply").disabled = true;
3390
+ try {
3391
+ await api(`/api/works/${encodeURIComponent(state.work.id)}/chapters/batch`, {
3392
+ method: "POST",
3393
+ body: { chapters: chapters.map((chapter) => ({ id: chapter.id, expectedVersionNo: chapter.versionNo })), action }
3394
+ });
3395
+ const workId = state.work.id;
3396
+ state.work = await api(`/api/works/${encodeURIComponent(workId)}`);
3397
+ const currentStillExists = state.chapter && state.work.volumes.some((volume) => volume.chapters.some((chapter) => chapter.id === state.chapter.id));
3398
+ if (state.chapter && currentStillExists) state.chapter = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}`);
3399
+ if (state.chapter && !currentStillExists) {
3400
+ state.chapter = null;
3401
+ showWelcome(true);
3402
+ } else renderTree();
3403
+ if (dialog.open) dialog.close();
3404
+ chapterBatchSelectedIds.clear();
3405
+ toast(`已批量处理 ${chapters.length} 个章节`);
3406
+ } catch (error) {
3407
+ if (!dialog.open) dialog.showModal();
3408
+ $("#chapter-batch-apply").disabled = false;
3409
+ toast(error.message, "error");
3410
+ }
3411
+ }
3412
+
3413
+ function findChapterLocation(chapterId) {
3414
+ for (const [volumeIndex, volume] of (state.work?.volumes ?? []).entries()) {
3415
+ const chapterIndex = volume.chapters.findIndex((chapter) => chapter.id === chapterId);
3416
+ if (chapterIndex >= 0) return { volume, volumeIndex, chapterIndex, chapter: volume.chapters[chapterIndex] };
3417
+ }
3418
+ return null;
3419
+ }
3420
+
3421
+ async function moveChapterInTree(chapterId, volumeId, sortOrder) {
3422
+ const location = findChapterLocation(chapterId);
3423
+ if (!location || chapterMovePending) return;
3424
+ const samePosition = location.volume.id === volumeId && location.chapterIndex === sortOrder;
3425
+ if (samePosition) return;
3426
+ chapterMovePending = true;
3427
+ try {
3428
+ const moved = await api(`/api/chapters/${encodeURIComponent(chapterId)}/move`, {
3429
+ method: "POST",
3430
+ body: { volumeId, sortOrder, expectedVersionNo: location.chapter.versionNo }
3431
+ });
3432
+ state.work = await api(`/api/works/${encodeURIComponent(state.work.id)}`);
3433
+ if (state.chapter?.id === chapterId) state.chapter = { ...state.chapter, ...moved };
3434
+ renderTree();
3435
+ $("#novel-tree").querySelector(`[data-chapter-id="${CSS.escape(chapterId)}"]`)?.focus();
3436
+ toast(location.volume.id === volumeId ? "章节顺序已更新" : "章节已移动到目标分卷");
3437
+ } catch (error) {
3438
+ toast(error.message, "error");
3439
+ } finally {
3440
+ chapterMovePending = false;
3441
+ }
3442
+ }
3443
+
3444
+ async function moveChapterByKeyboard(chapterId, direction, crossVolume) {
3445
+ const location = findChapterLocation(chapterId);
3446
+ if (!location || !state.work) return;
3447
+ if (crossVolume) {
3448
+ const targetVolume = state.work.volumes[location.volumeIndex + direction];
3449
+ if (!targetVolume) return toast("已经是最前或最后一个分卷");
3450
+ await moveChapterInTree(chapterId, targetVolume.id, direction < 0 ? targetVolume.chapters.length : 0);
3451
+ return;
3452
+ }
3453
+ const targetIndex = location.chapterIndex + direction;
3454
+ if (targetIndex < 0 || targetIndex >= location.volume.chapters.length) return toast("已经是本卷首章或末章");
3455
+ await moveChapterInTree(chapterId, location.volume.id, targetIndex);
3456
+ }
3457
+
3067
3458
  function closeChapterTypeMenu() {
3068
3459
  state.contextChapterId = null;
3069
3460
  $("#chapter-type-menu").classList.add("hidden");
@@ -3085,6 +3476,57 @@ function openChapterTypeMenu(chapterId, clientX, clientY) {
3085
3476
  menu.style.top = `${Math.max(8, Math.min(clientY, window.innerHeight - rect.height - 8))}px`;
3086
3477
  }
3087
3478
 
3479
+ async function deleteChapter(chapterId) {
3480
+ if (!state.work || !canEditProse()) return toast("当前权限不能删除正文", "error");
3481
+ const chaptersBeforeDelete = state.work.volumes.flatMap((volume) => volume.chapters);
3482
+ const chapterIndex = chaptersBeforeDelete.findIndex((chapter) => chapter.id === chapterId);
3483
+ const chapter = chaptersBeforeDelete[chapterIndex];
3484
+ if (!chapter) return toast("章节不存在或已被删除", "error");
3485
+ const deletingCurrentChapter = state.chapter?.id === chapterId;
3486
+ if (deletingCurrentChapter) cancelChapterAutoSave();
3487
+ const resumeAutoSave = () => {
3488
+ if (deletingCurrentChapter && state.dirty && !chapterEditorReadOnly) scheduleChapterAutoSave();
3489
+ };
3490
+ const dirtyWarning = deletingCurrentChapter && state.dirty ? ",当前未保存修改不会保留" : "";
3491
+ if (!await confirmToast(`确认删除章节“${chapter.title}”吗?删除后将从正文目录隐藏${dirtyWarning}。`, {
3492
+ title: "删除章节",
3493
+ confirmLabel: "继续删除"
3494
+ })) {
3495
+ resumeAutoSave();
3496
+ return;
3497
+ }
3498
+ if (!await confirmToast(`章节“${chapter.title}”的正文、版本和关联资料会保留,后续可以恢复。仍要删除吗?`, {
3499
+ title: "删除操作需要再次确认",
3500
+ confirmLabel: "确认删除"
3501
+ })) {
3502
+ resumeAutoSave();
3503
+ return;
3504
+ }
3505
+ try {
3506
+ const deletingSelectedChapter = state.chapter?.id === chapterId;
3507
+ const expectedVersionNo = deletingSelectedChapter ? state.chapter.versionNo : chapter.versionNo;
3508
+ await api(`/api/chapters/${chapterId}`, { method: "DELETE", body: { expectedVersionNo } });
3509
+ const workId = state.work.id;
3510
+ state.work = await api(`/api/works/${workId}`);
3511
+ if (deletingSelectedChapter) {
3512
+ state.chapter = null;
3513
+ state.dirty = false;
3514
+ lastSavedChapterSnapshot = null;
3515
+ setSaveState("已删除");
3516
+ const remainingChapters = state.work.volumes.flatMap((volume) => volume.chapters);
3517
+ const nextChapter = remainingChapters[Math.min(Math.max(chapterIndex, 0), remainingChapters.length - 1)];
3518
+ if (nextChapter) await selectChapter(nextChapter.id);
3519
+ else showWelcome(true);
3520
+ } else {
3521
+ renderTree();
3522
+ }
3523
+ toast(`已删除章节“${chapter.title}”,正文和版本记录已保留`);
3524
+ } catch (error) {
3525
+ resumeAutoSave();
3526
+ toast(error.message, "error");
3527
+ }
3528
+ }
3529
+
3088
3530
  async function selectChapter(chapterId, { editMode = false } = {}) {
3089
3531
  if (state.chapter?.id !== chapterId && !(await confirmDiscardChanges("当前章节有未保存修改,仍要切换吗?"))) return;
3090
3532
  cancelChapterAutoSave();
@@ -5867,12 +6309,16 @@ function openWorkSettingsDialog(work) {
5867
6309
  <div><strong id="import-history-settings-title">正文导入历史</strong><small>查看导入前快照并恢复被覆盖的分卷、章节标题和正文;大纲、伏笔等章节关联资料不在快照中。</small></div>
5868
6310
  <button id="import-history-button" class="ghost-button" type="button" aria-controls="import-history-dialog" aria-haspopup="dialog" ${canOpenImportHistory ? "" : "disabled"}>${importHistoryAction}</button>
5869
6311
  </section>`;
6312
+ const recycleBinField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="chapter-recycle-bin-settings-title">
6313
+ <div><strong id="chapter-recycle-bin-settings-title">章节回收站</strong><small>查看并恢复已软删除的章节,正文、版本和关联资料不会在删除时清理。</small></div>
6314
+ <button id="chapter-recycle-bin-button" class="ghost-button" type="button" aria-controls="chapter-recycle-bin-dialog" aria-haspopup="dialog" ${canEditProse() ? "" : "disabled"}>打开回收站</button>
6315
+ </section>` : "";
5870
6316
  const whitespaceField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="whitespace-settings-title">
5871
6317
  <div><strong id="whitespace-settings-title">正文空白符</strong><small>在编辑器正文中显示或隐藏空格、全角空格和 Tab 的可视标记。</small></div>
5872
6318
  <button id="toggle-whitespace-settings" class="ghost-button" data-toggle-whitespace type="button" aria-pressed="${chapterWhitespaceVisible}" title="用点标记半角空格,用方框标记全角空格,用箭头标记 Tab">${chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符"}</button>
5873
6319
  </section>` : "";
5874
6320
  openDialog("作品信息",
5875
- workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + whitespaceField + accessField + importHistoryField,
6321
+ workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + whitespaceField + accessField + importHistoryField + recycleBinField,
5876
6322
  async (form) => {
5877
6323
  await api(`/api/works/${work.id}`, { method: "PATCH", body: { title: form.get("title"), author: form.get("author"), description: form.get("description") } });
5878
6324
  state.works = (await apiPage("/api/works")).items;
@@ -5895,6 +6341,10 @@ function openWorkSettingsDialog(work) {
5895
6341
  $("#form-dialog").close();
5896
6342
  void openImportHistory();
5897
6343
  });
6344
+ $("#chapter-recycle-bin-button")?.addEventListener("click", () => {
6345
+ $("#form-dialog").close();
6346
+ void openChapterRecycleBin();
6347
+ });
5898
6348
  $("#work-access-manage")?.addEventListener("click", () => {
5899
6349
  $("#form-dialog").close();
5900
6350
  openMembersDialog(work);
@@ -7925,6 +8375,69 @@ async function openImportHistory() {
7925
8375
  }
7926
8376
  }
7927
8377
 
8378
+ function renderChapterRecycleBin(chapters) {
8379
+ const host = $("#chapter-recycle-bin-list");
8380
+ if (!chapters.length) {
8381
+ host.innerHTML = '<p class="entity-history-empty">回收站为空,没有可恢复的章节。</p>';
8382
+ return;
8383
+ }
8384
+ host.innerHTML = chapters.map((chapter) => `<article class="entity-version-card" data-deleted-chapter="${esc(chapter.id)}">
8385
+ <header><strong>${esc(chapter.title)}</strong><span class="import-history-kind">${esc(chapter.volumeTitle)}</span></header>
8386
+ <time>${esc(formatDateTime(chapter.deletedAt))} · ${esc(chapter.actor)}</time>
8387
+ <p>${esc(chapter.contentPreview || "空白章节")}</p>
8388
+ <small>${Number(chapter.wordCount).toLocaleString("zh-CN")} 字 · 删除版本 v${Number(chapter.versionNo)}</small>
8389
+ <button type="button" data-restore-deleted-chapter="${esc(chapter.id)}">恢复章节</button>
8390
+ </article>`).join("");
8391
+ host.querySelectorAll("[data-restore-deleted-chapter]").forEach((button) => button.addEventListener("click", async () => {
8392
+ const chapter = chapters.find((item) => item.id === button.dataset.restoreDeletedChapter);
8393
+ if (!chapter || !state.work) return;
8394
+ const dialog = $("#chapter-recycle-bin-dialog");
8395
+ dialog.close();
8396
+ const confirmed = await confirmToast(`将章节“${chapter.title}”恢复到分卷“${chapter.volumeTitle}”吗?`, {
8397
+ title: "恢复已删除章节",
8398
+ confirmLabel: "确认恢复"
8399
+ });
8400
+ if (!confirmed) {
8401
+ dialog.showModal();
8402
+ return;
8403
+ }
8404
+ button.disabled = true;
8405
+ try {
8406
+ await api(`/api/chapters/${encodeURIComponent(chapter.id)}/restore`, {
8407
+ method: "POST",
8408
+ body: { versionNo: chapter.versionNo, expectedVersionNo: chapter.versionNo }
8409
+ });
8410
+ state.work = await api(`/api/works/${encodeURIComponent(state.work.id)}`);
8411
+ renderTree();
8412
+ await loadChapterRecycleBin();
8413
+ dialog.showModal();
8414
+ toast(`已恢复章节“${chapter.title}”`);
8415
+ } catch (error) {
8416
+ button.disabled = false;
8417
+ dialog.showModal();
8418
+ toast(error.message, "error");
8419
+ }
8420
+ }));
8421
+ }
8422
+
8423
+ async function loadChapterRecycleBin() {
8424
+ if (!state.work) return;
8425
+ const chapters = await api(`/api/works/${encodeURIComponent(state.work.id)}/deleted-chapters`);
8426
+ renderChapterRecycleBin(chapters);
8427
+ }
8428
+
8429
+ async function openChapterRecycleBin() {
8430
+ if (!state.work || !canEditProse()) return toast("当前权限不能恢复正文", "error");
8431
+ $("#chapter-recycle-bin-list").innerHTML = '<p class="entity-history-empty">正在读取已删除章节…</p>';
8432
+ $("#chapter-recycle-bin-dialog").showModal();
8433
+ try {
8434
+ await loadChapterRecycleBin();
8435
+ } catch (error) {
8436
+ $("#chapter-recycle-bin-dialog").close();
8437
+ toast(error.message, "error");
8438
+ }
8439
+ }
8440
+
7928
8441
  async function showChapterInsight() {
7929
8442
  if (!state.chapter) return;
7930
8443
  const chapterId = state.chapter.id;
@@ -8554,6 +9067,17 @@ $("#platform-usage-refresh").addEventListener("click", async () => {
8554
9067
  }
8555
9068
  });
8556
9069
  $("#user-management-button").addEventListener("click", openUsersDialog);
9070
+ $("#writing-progress-button").addEventListener("click", () => openWritingProgressDialog().catch((error) => toast(error.message, "error")));
9071
+ $("#writing-progress-close").addEventListener("click", () => $("#writing-progress-dialog").close());
9072
+ $("#writing-progress-refresh").addEventListener("click", () => loadWritingProgress().catch((error) => toast(error.message, "error")));
9073
+ $("#writing-goal-form").addEventListener("submit", saveWritingGoal);
9074
+ $("#work-audit-button").addEventListener("click", () => openWorkAuditDialog().catch((error) => toast(error.message, "error")));
9075
+ $("#work-audit-close").addEventListener("click", () => $("#work-audit-dialog").close());
9076
+ $("#work-audit-settings-return").addEventListener("click", () => returnToSettingsHub("#work-audit-button", "#work-audit-dialog").catch((error) => toast(error.message, "error")));
9077
+ $("#work-audit-refresh").addEventListener("click", () => loadWorkAuditPage().catch((error) => toast(error.message, "error")));
9078
+ $("#work-audit-load-more").addEventListener("click", () => {
9079
+ if (workAuditNextPage !== null) loadWorkAuditPage(workAuditNextPage, true).catch((error) => toast(error.message, "error"));
9080
+ });
8557
9081
  $("#platform-ui-settings-button").addEventListener("click", openPlatformUiSettingsDialog);
8558
9082
  $("#collaboration-button").addEventListener("click", () => openMembersDialog());
8559
9083
  $("#presence-button").addEventListener("click", () => {
@@ -8640,13 +9164,30 @@ $("#platform-new-provider").addEventListener("click", () => openProviderDialog()
8640
9164
  $("#shelf-new-work").addEventListener("click", openWorkDialog);
8641
9165
  $("#welcome-new-work").addEventListener("click", () => state.work ? openChapterDialog() : openWorkDialog());
8642
9166
  $("#save-button").addEventListener("click", saveChapter);
9167
+ $("#chapter-delete-button").addEventListener("click", () => {
9168
+ if (state.chapter) void deleteChapter(state.chapter.id);
9169
+ });
8643
9170
  $("#chapter-edit-button").addEventListener("click", enterChapterEditMode);
8644
9171
  $("#tidy-blank-lines-button").addEventListener("click", tidyChapterBlankLines);
8645
9172
  $("#new-volume-button").addEventListener("click", () => openVolumeDialog());
9173
+ $("#chapter-batch-button").addEventListener("click", openChapterBatchDialog);
9174
+ $("#chapter-batch-close").addEventListener("click", () => $("#chapter-batch-dialog").close());
9175
+ $("#chapter-batch-cancel").addEventListener("click", () => $("#chapter-batch-dialog").close());
9176
+ $("#chapter-batch-action").addEventListener("change", updateChapterBatchControls);
9177
+ $("#chapter-batch-select-all").addEventListener("click", () => {
9178
+ state.work?.volumes.flatMap((volume) => volume.chapters).forEach((chapter) => chapterBatchSelectedIds.add(chapter.id));
9179
+ renderChapterBatchDialog();
9180
+ });
9181
+ $("#chapter-batch-clear").addEventListener("click", () => {
9182
+ chapterBatchSelectedIds.clear();
9183
+ renderChapterBatchDialog();
9184
+ });
9185
+ $("#chapter-batch-form").addEventListener("submit", submitChapterBatch);
8646
9186
  $("#insight-button").addEventListener("click", () => showChapterInsight().catch((error) => toast(error.message, "error")));
8647
9187
  $("#versions-button").addEventListener("click", showVersions);
8648
9188
  $("#versions-close").addEventListener("click", () => $("#versions-dialog").close());
8649
9189
  $("#import-history-close").addEventListener("click", () => $("#import-history-dialog").close());
9190
+ $("#chapter-recycle-bin-close").addEventListener("click", () => $("#chapter-recycle-bin-dialog").close());
8650
9191
  $("#entity-history-close").addEventListener("click", () => $("#entity-history-dialog").close());
8651
9192
  $("#ai-tool-call-close").addEventListener("click", () => $("#ai-tool-call-dialog").close());
8652
9193
  $("#setting-editor-back").addEventListener("click", () => { void closeEntityEditor(); });
@@ -8736,6 +9277,12 @@ $("#chapter-line-numbers-inner").addEventListener("contextmenu", (event) => {
8736
9277
  if (row) showLineCitationMenu(event, Number(row.dataset.lineIndex));
8737
9278
  });
8738
9279
  $("#add-line-citation").addEventListener("click", addSelectedLinesAsCitation);
9280
+ $("#add-line-annotation").addEventListener("click", () => createSelectedLineAnnotation("note"));
9281
+ $("#add-line-todo").addEventListener("click", () => createSelectedLineAnnotation("todo"));
9282
+ $("#chapter-annotations-button").addEventListener("click", () => openChapterAnnotationsDialog().catch((error) => toast(error.message, "error")));
9283
+ $("#chapter-annotations-close").addEventListener("click", () => $("#chapter-annotations-dialog").close());
9284
+ $("#chapter-annotations-done").addEventListener("click", () => $("#chapter-annotations-dialog").close());
9285
+ $("#chapter-annotations-refresh").addEventListener("click", () => loadChapterAnnotations().catch((error) => toast(error.message, "error")));
8739
9286
  $("#left-panel-toggle").addEventListener("click", () => {
8740
9287
  panelLayout.leftCollapsed = !panelLayout.leftCollapsed;
8741
9288
  applyPanelLayout(true);
@@ -8894,6 +9441,13 @@ $("#cover-file").addEventListener("change", async (event) => {
8894
9441
  }
8895
9442
  });
8896
9443
  $("#chapter-type-menu").addEventListener("click", async (event) => {
9444
+ const deleteButton = event.target.closest("[data-delete-chapter]");
9445
+ if (deleteButton) {
9446
+ const chapterId = state.contextChapterId;
9447
+ closeChapterTypeMenu();
9448
+ if (chapterId) await deleteChapter(chapterId);
9449
+ return;
9450
+ }
8897
9451
  const button = event.target.closest("[data-chapter-type]");
8898
9452
  const chapterId = state.contextChapterId;
8899
9453
  if (!button || !chapterId) return;