@musnows/scriverse 0.5.5 → 0.5.7
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.
- package/dist/ai.js +7 -7
- package/dist/ai.js.map +1 -1
- package/dist/app.js +71 -3
- package/dist/app.js.map +1 -1
- package/dist/database.js +95 -0
- package/dist/database.js.map +1 -1
- package/dist/public/app.js +641 -12
- package/dist/public/index.html +79 -4
- package/dist/public/relationship-graph.js +80 -22
- package/dist/public/styles.css +85 -2
- package/dist/store.js +425 -30
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +11 -1
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/public/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=
|
|
1
|
+
import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260728-galaxy-edge-stars-v3";
|
|
2
2
|
import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
|
|
3
3
|
import { renderMarkdown } from "/markdown.js?v=20260725-ordered-list";
|
|
4
4
|
import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
|
|
@@ -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");
|
|
@@ -382,7 +388,7 @@ const workspaceOnboardingSteps = [
|
|
|
382
388
|
{ selector: "#top-search-button", eyebrow: "全文检索", title: "搜索整部作品", description: "一次检索正文、角色、设定、种族与组织,快速定位创作依据。", placement: "bottom" },
|
|
383
389
|
{ selector: ".quick-actions button[data-task=\"continue\"]", eyebrow: "AI 快捷指令", title: "让创作助手基于正文工作", description: "总结、续写、剧情方向和冲突检查都以已保存内容为依据。", placement: "left" },
|
|
384
390
|
{ selector: "#ai-send", eyebrow: "AI 对话", title: "发送你的创作要求", description: "选择上下文范围与模型后发送任务。AI 结果默认只是建议,不会直接覆盖正文。", placement: "left" },
|
|
385
|
-
{ selector: "#settings-button", eyebrow: "工作台设置", title: "管理 AI、协作与导出", description: "
|
|
391
|
+
{ selector: "#settings-button", eyebrow: "工作台设置", title: "管理 AI、协作与导出", description: "供应商、显示偏好、作品成员和正文 ZIP 导出都集中在这里。", placement: "bottom" },
|
|
386
392
|
{ selector: "#account-button", eyebrow: "账户", title: "管理账户并重看导览", description: "账户菜单保存个人设置入口,也可以随时重新打开这套功能导览。", placement: "bottom" }
|
|
387
393
|
];
|
|
388
394
|
|
|
@@ -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 || chapterEditorReadOnly || !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,14 +2686,154 @@ 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 ? "返回书架" : "返回当前作品";
|
|
2592
2694
|
$("#settings-work-note").textContent = hasWork
|
|
2593
|
-
? `当前作品:《${state.work.title}
|
|
2695
|
+
? `当前作品:《${state.work.title}》。导出的 ZIP 内含 Markdown 正文,仅包含分卷、章节标题与正文。`
|
|
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
|
+
const maxDailyWords = Math.max(1, ...progress.trend.map((item) => Math.max(0, Number(item.delta))));
|
|
2710
|
+
const dailyPoints = progress.trend.map((item, index) => {
|
|
2711
|
+
const x = progress.trend.length > 0 ? (index + 0.5) / progress.trend.length * 1000 : 0;
|
|
2712
|
+
const y = 94 - Math.max(0, Number(item.delta)) / maxDailyWords * 88;
|
|
2713
|
+
return { x: x.toFixed(2), y: y.toFixed(2) };
|
|
2714
|
+
});
|
|
2715
|
+
const dailyLine = `<svg class="writing-trend-daily-line" viewBox="0 0 1000 100" preserveAspectRatio="none" aria-hidden="true"><polyline points="${dailyPoints.map((point) => `${point.x},${point.y}`).join(" ")}"></polyline></svg><span class="writing-trend-daily-points" aria-hidden="true">${dailyPoints.map((point) => `<i style="--point-x:${(Number(point.x) / 10).toFixed(2)}%;--point-y:${point.y}%"></i>`).join("")}</span>`;
|
|
2716
|
+
const chart = $("#writing-trend-chart");
|
|
2717
|
+
chart.innerHTML = progress.trend.map((item, index) => {
|
|
2718
|
+
const height = Math.max(3, Math.round(Number(item.words) / maxWords * 100));
|
|
2719
|
+
const dailyWords = Math.max(0, Number(item.delta));
|
|
2720
|
+
const label = `${item.date}:累计 ${Number(item.words).toLocaleString("zh-CN")} 字,当日新增 +${dailyWords.toLocaleString("zh-CN")} 字`;
|
|
2721
|
+
return `<span class="writing-trend-bar${index === progress.trend.length - 1 ? " is-today" : ""}" style="--bar-height:${height}%" data-writing-trend-label="${esc(label)}" tabindex="0" aria-label="${esc(label)}"><i></i><small>${index % 5 === 0 || index === progress.trend.length - 1 ? esc(item.date.slice(5)) : ""}</small></span>`;
|
|
2722
|
+
}).join("") + dailyLine + '<span class="writing-trend-legend" aria-hidden="true"><i></i>累计字数 <b></b>当日新增</span><output id="writing-trend-tooltip" class="writing-trend-tooltip" role="tooltip" hidden></output>';
|
|
2723
|
+
const tooltip = $("#writing-trend-tooltip");
|
|
2724
|
+
const hideTooltip = () => { tooltip.hidden = true; };
|
|
2725
|
+
const showTooltip = (bar) => {
|
|
2726
|
+
const marker = bar.querySelector("i");
|
|
2727
|
+
if (!marker) return;
|
|
2728
|
+
tooltip.textContent = bar.dataset.writingTrendLabel;
|
|
2729
|
+
tooltip.hidden = false;
|
|
2730
|
+
const chartRect = chart.getBoundingClientRect();
|
|
2731
|
+
const markerRect = marker.getBoundingClientRect();
|
|
2732
|
+
const centeredLeft = markerRect.left + markerRect.width / 2 - chartRect.left;
|
|
2733
|
+
const edgeInset = tooltip.offsetWidth / 2 + 8;
|
|
2734
|
+
tooltip.style.left = `${Math.min(chart.clientWidth - edgeInset, Math.max(edgeInset, centeredLeft))}px`;
|
|
2735
|
+
tooltip.style.top = `${Math.max(tooltip.offsetHeight + 8, markerRect.top - chartRect.top - 8)}px`;
|
|
2736
|
+
};
|
|
2737
|
+
chart.querySelectorAll(".writing-trend-bar").forEach((bar) => {
|
|
2738
|
+
bar.addEventListener("mouseenter", () => showTooltip(bar));
|
|
2739
|
+
bar.addEventListener("mouseleave", hideTooltip);
|
|
2740
|
+
bar.addEventListener("focus", () => showTooltip(bar));
|
|
2741
|
+
bar.addEventListener("blur", hideTooltip);
|
|
2742
|
+
});
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
async function loadWritingProgress() {
|
|
2746
|
+
if (!state.work) return;
|
|
2747
|
+
renderWritingProgress(await api(`/api/works/${encodeURIComponent(state.work.id)}/writing-progress`));
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
async function openWritingProgressDialog() {
|
|
2751
|
+
if (!state.work || !canReadModule("editor")) return;
|
|
2752
|
+
$("#writing-progress-dialog").showModal();
|
|
2753
|
+
try {
|
|
2754
|
+
await loadWritingProgress();
|
|
2755
|
+
} catch (error) {
|
|
2756
|
+
$("#writing-progress-dialog").close();
|
|
2757
|
+
toast(error.message, "error");
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
const workAuditActionLabels = {
|
|
2762
|
+
"work.created": "创建作品",
|
|
2763
|
+
"work.updated": "更新作品",
|
|
2764
|
+
"volume.created": "创建分卷",
|
|
2765
|
+
"volume.updated": "更新分卷",
|
|
2766
|
+
"volume.deleted": "删除分卷",
|
|
2767
|
+
"chapter.created": "创建章节",
|
|
2768
|
+
"chapter.saved": "保存章节",
|
|
2769
|
+
"chapter.moved": "移动章节",
|
|
2770
|
+
"chapter.deleted": "删除章节",
|
|
2771
|
+
"chapter.restored": "恢复章节",
|
|
2772
|
+
"work.imported": "导入正文"
|
|
2773
|
+
};
|
|
2774
|
+
|
|
2775
|
+
function workAuditEntityLabel(type) {
|
|
2776
|
+
return ({ work: "作品", volume: "分卷", chapter: "章节", user: "用户" })[type] ?? type;
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
function workAuditDetailText(detail) {
|
|
2780
|
+
const entries = Object.entries(detail ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== "").slice(0, 6);
|
|
2781
|
+
return entries.map(([key, value]) => `${key}: ${typeof value === "object" ? JSON.stringify(value) : String(value)}`).join(" · ");
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
function renderWorkAuditRecords() {
|
|
2785
|
+
$("#work-audit-list").innerHTML = workAuditRecords.length ? workAuditRecords.map((record) => `<article class="work-audit-row">
|
|
2786
|
+
<time>${esc(formatDateTime(record.createdAt))}</time>
|
|
2787
|
+
<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>
|
|
2788
|
+
</article>`).join("") : '<p class="entity-history-empty">当前作品还没有操作记录。</p>';
|
|
2789
|
+
$("#work-audit-load-more").classList.toggle("hidden", workAuditNextPage === null);
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
async function loadWorkAuditPage(page = 1, append = false) {
|
|
2793
|
+
if (!state.work) return;
|
|
2794
|
+
const result = await apiPage(`/api/works/${encodeURIComponent(state.work.id)}/audit-logs`, page, 30);
|
|
2795
|
+
workAuditRecords = append ? [...workAuditRecords, ...result.items] : result.items;
|
|
2796
|
+
workAuditNextPage = result.nextPage;
|
|
2797
|
+
renderWorkAuditRecords();
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
async function openWorkAuditDialog() {
|
|
2801
|
+
if (!state.work || !["admin", "owner"].includes(String(state.work.accessRole))) return;
|
|
2802
|
+
workAuditRecords = [];
|
|
2803
|
+
workAuditNextPage = null;
|
|
2804
|
+
$("#work-audit-list").innerHTML = '<p class="entity-history-empty">正在加载操作记录…</p>';
|
|
2805
|
+
$("#work-audit-dialog").showModal();
|
|
2806
|
+
try {
|
|
2807
|
+
await loadWorkAuditPage();
|
|
2808
|
+
} catch (error) {
|
|
2809
|
+
$("#work-audit-dialog").close();
|
|
2810
|
+
toast(error.message, "error");
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
async function saveWritingGoal(event) {
|
|
2815
|
+
event.preventDefault();
|
|
2816
|
+
if (!state.work || !canEditProse()) return;
|
|
2817
|
+
const button = $("#writing-goal-save");
|
|
2818
|
+
button.disabled = true;
|
|
2819
|
+
try {
|
|
2820
|
+
const progress = await api(`/api/works/${encodeURIComponent(state.work.id)}/writing-goal`, {
|
|
2821
|
+
method: "PUT",
|
|
2822
|
+
body: {
|
|
2823
|
+
dailyGoal: Number($("#writing-daily-goal").value),
|
|
2824
|
+
targetTotal: Number($("#writing-total-goal").value),
|
|
2825
|
+
deadline: $("#writing-deadline").value || null
|
|
2826
|
+
}
|
|
2827
|
+
});
|
|
2828
|
+
renderWritingProgress(progress);
|
|
2829
|
+
toast("写作目标已保存");
|
|
2830
|
+
} catch (error) {
|
|
2831
|
+
toast(error.message, "error");
|
|
2832
|
+
} finally {
|
|
2833
|
+
button.disabled = !canEditProse();
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2597
2837
|
function renderUsers(users) {
|
|
2598
2838
|
const currentUserId = state.user?.userId;
|
|
2599
2839
|
$("#users-list").innerHTML = users.map((user) => `<article class="access-row" data-user-row="${esc(user.userId)}">
|
|
@@ -3022,12 +3262,12 @@ function renderTree() {
|
|
|
3022
3262
|
$("#novel-tree").innerHTML = state.work.volumes.map((volume) => `
|
|
3023
3263
|
<div class="volume-node ${state.collapsedVolumeIds.has(volume.id) ? "is-collapsed" : ""}" data-volume-id="${esc(volume.id)}">
|
|
3024
3264
|
<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="
|
|
3265
|
+
<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
3266
|
${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
3267
|
</div>
|
|
3028
3268
|
<div class="volume-chapters">
|
|
3029
3269
|
${volume.chapters.map((chapter) => `
|
|
3030
|
-
<button class="chapter-node ${state.chapter?.id === chapter.id ? "active" : ""}" type="button" data-chapter-id="${esc(chapter.id)}">
|
|
3270
|
+
<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
3271
|
<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
3272
|
</button>`).join("")}
|
|
3033
3273
|
</div>
|
|
@@ -3044,13 +3284,30 @@ function renderTree() {
|
|
|
3044
3284
|
event.preventDefault();
|
|
3045
3285
|
openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeToggle));
|
|
3046
3286
|
});
|
|
3287
|
+
if (proseEditable) {
|
|
3288
|
+
button.addEventListener("dragover", (event) => {
|
|
3289
|
+
if (!event.dataTransfer?.types.includes("text/plain")) return;
|
|
3290
|
+
event.preventDefault();
|
|
3291
|
+
button.closest(".volume-node")?.classList.add("is-drag-target");
|
|
3292
|
+
});
|
|
3293
|
+
button.addEventListener("dragleave", () => button.closest(".volume-node")?.classList.remove("is-drag-target"));
|
|
3294
|
+
button.addEventListener("drop", async (event) => {
|
|
3295
|
+
event.preventDefault();
|
|
3296
|
+
button.closest(".volume-node")?.classList.remove("is-drag-target");
|
|
3297
|
+
const chapterId = event.dataTransfer?.getData("text/plain");
|
|
3298
|
+
const volume = state.work?.volumes.find((item) => item.id === button.dataset.volumeToggle);
|
|
3299
|
+
if (chapterId && volume) await moveChapterInTree(chapterId, volume.id, volume.chapters.length);
|
|
3300
|
+
});
|
|
3301
|
+
}
|
|
3047
3302
|
});
|
|
3048
3303
|
$("#novel-tree").querySelectorAll("[data-new-chapter-volume]").forEach((button) => {
|
|
3049
3304
|
button.addEventListener("click", () => openChapterDialog(button.dataset.newChapterVolume));
|
|
3050
3305
|
});
|
|
3051
3306
|
$("#novel-tree").querySelectorAll("[data-chapter-id]").forEach((button) => {
|
|
3052
|
-
button.addEventListener("click", () => {
|
|
3053
|
-
|
|
3307
|
+
button.addEventListener("click", async () => {
|
|
3308
|
+
const chapterId = button.dataset.chapterId;
|
|
3309
|
+
await selectChapter(chapterId);
|
|
3310
|
+
$("#novel-tree").querySelector(`[data-chapter-id="${CSS.escape(chapterId)}"]`)?.focus();
|
|
3054
3311
|
if (isMobileViewport()) {
|
|
3055
3312
|
panelLayout.leftCollapsed = true;
|
|
3056
3313
|
applyPanelLayout(true);
|
|
@@ -3061,9 +3318,178 @@ function renderTree() {
|
|
|
3061
3318
|
event.preventDefault();
|
|
3062
3319
|
openChapterTypeMenu(button.dataset.chapterId, event.clientX, event.clientY);
|
|
3063
3320
|
});
|
|
3321
|
+
if (proseEditable) {
|
|
3322
|
+
button.addEventListener("dragstart", (event) => {
|
|
3323
|
+
event.dataTransfer?.setData("text/plain", button.dataset.chapterId);
|
|
3324
|
+
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
|
3325
|
+
button.classList.add("is-dragging");
|
|
3326
|
+
});
|
|
3327
|
+
button.addEventListener("dragend", () => {
|
|
3328
|
+
button.classList.remove("is-dragging");
|
|
3329
|
+
$("#novel-tree").querySelectorAll(".is-drag-over, .is-drag-target").forEach((node) => node.classList.remove("is-drag-over", "drop-after", "is-drag-target"));
|
|
3330
|
+
});
|
|
3331
|
+
button.addEventListener("dragover", (event) => {
|
|
3332
|
+
event.preventDefault();
|
|
3333
|
+
const after = event.clientY >= button.getBoundingClientRect().top + button.offsetHeight / 2;
|
|
3334
|
+
button.classList.toggle("drop-after", after);
|
|
3335
|
+
button.classList.add("is-drag-over");
|
|
3336
|
+
});
|
|
3337
|
+
button.addEventListener("dragleave", () => button.classList.remove("is-drag-over", "drop-after"));
|
|
3338
|
+
button.addEventListener("drop", async (event) => {
|
|
3339
|
+
event.preventDefault();
|
|
3340
|
+
event.stopPropagation();
|
|
3341
|
+
const chapterId = event.dataTransfer?.getData("text/plain");
|
|
3342
|
+
const target = findChapterLocation(button.dataset.chapterId);
|
|
3343
|
+
const after = button.classList.contains("drop-after");
|
|
3344
|
+
button.classList.remove("is-drag-over", "drop-after");
|
|
3345
|
+
if (!chapterId || !target) return;
|
|
3346
|
+
const targetChapters = target.volume.chapters.filter((chapter) => chapter.id !== chapterId);
|
|
3347
|
+
const targetIndex = targetChapters.findIndex((chapter) => chapter.id === button.dataset.chapterId);
|
|
3348
|
+
await moveChapterInTree(chapterId, target.volume.id, Math.max(0, targetIndex + (after ? 1 : 0)));
|
|
3349
|
+
});
|
|
3350
|
+
button.addEventListener("keydown", async (event) => {
|
|
3351
|
+
if (!event.altKey || !["ArrowUp", "ArrowDown"].includes(event.key)) return;
|
|
3352
|
+
event.preventDefault();
|
|
3353
|
+
await moveChapterByKeyboard(button.dataset.chapterId, event.key === "ArrowDown" ? 1 : -1, event.shiftKey);
|
|
3354
|
+
});
|
|
3355
|
+
}
|
|
3064
3356
|
});
|
|
3065
3357
|
}
|
|
3066
3358
|
|
|
3359
|
+
function renderChapterBatchDialog() {
|
|
3360
|
+
const chapters = state.work?.volumes.flatMap((volume) => volume.chapters.map((chapter) => ({ ...chapter, volumeTitle: volume.title }))) ?? [];
|
|
3361
|
+
const searchQuery = $("#chapter-batch-search").value.trim().toLocaleLowerCase("zh-CN");
|
|
3362
|
+
const visibleChapters = searchQuery
|
|
3363
|
+
? chapters.filter((chapter) => chapter.title.toLocaleLowerCase("zh-CN").includes(searchQuery))
|
|
3364
|
+
: chapters;
|
|
3365
|
+
for (const chapterId of chapterBatchSelectedIds) {
|
|
3366
|
+
if (!chapters.some((chapter) => chapter.id === chapterId)) chapterBatchSelectedIds.delete(chapterId);
|
|
3367
|
+
}
|
|
3368
|
+
$("#chapter-batch-list").innerHTML = visibleChapters.length ? visibleChapters.map((chapter) => `<label class="chapter-batch-item">
|
|
3369
|
+
<input type="checkbox" value="${esc(chapter.id)}" ${chapterBatchSelectedIds.has(chapter.id) ? "checked" : ""}>
|
|
3370
|
+
<span><strong>${esc(chapter.title)}</strong><small>${esc(chapter.volumeTitle)} · ${Number(chapter.wordCount ?? 0).toLocaleString("zh-CN")} 字 · ${esc(chapter.chapterType || "正文")}</small></span>
|
|
3371
|
+
</label>`).join("") : `<p class="entity-history-empty">${chapters.length ? "没有匹配的章节。" : "当前作品还没有章节。"}</p>`;
|
|
3372
|
+
$("#chapter-batch-search-count").textContent = `${visibleChapters.length} 章`;
|
|
3373
|
+
$("#chapter-batch-list").querySelectorAll('input[type="checkbox"]').forEach((input) => input.addEventListener("change", () => {
|
|
3374
|
+
if (input.checked) chapterBatchSelectedIds.add(input.value);
|
|
3375
|
+
else chapterBatchSelectedIds.delete(input.value);
|
|
3376
|
+
updateChapterBatchControls();
|
|
3377
|
+
}));
|
|
3378
|
+
$("#chapter-batch-volume").innerHTML = (state.work?.volumes ?? []).map((volume) => `<option value="${esc(volume.id)}">${esc(volume.title)}</option>`).join("");
|
|
3379
|
+
updateChapterBatchControls();
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
function updateChapterBatchControls() {
|
|
3383
|
+
const count = chapterBatchSelectedIds.size;
|
|
3384
|
+
const action = $("#chapter-batch-action").value;
|
|
3385
|
+
$("#chapter-batch-count").textContent = `已选择 ${count} 章`;
|
|
3386
|
+
$("#chapter-batch-apply").disabled = count === 0;
|
|
3387
|
+
$("#chapter-batch-volume-field").classList.toggle("hidden", action !== "move");
|
|
3388
|
+
$("#chapter-batch-type-field").classList.toggle("hidden", action !== "setType");
|
|
3389
|
+
$("#chapter-batch-apply").textContent = action === "delete" ? "软删除所选章节" : "应用到所选章节";
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
function openChapterBatchDialog() {
|
|
3393
|
+
if (!state.work || !canEditProse()) return;
|
|
3394
|
+
chapterBatchSelectedIds.clear();
|
|
3395
|
+
$("#chapter-batch-search").value = "";
|
|
3396
|
+
renderChapterBatchDialog();
|
|
3397
|
+
$("#chapter-batch-dialog").showModal();
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
async function submitChapterBatch(event) {
|
|
3401
|
+
event.preventDefault();
|
|
3402
|
+
if (!state.work || !chapterBatchSelectedIds.size) return;
|
|
3403
|
+
const chapters = state.work.volumes.flatMap((volume) => volume.chapters).filter((chapter) => chapterBatchSelectedIds.has(chapter.id));
|
|
3404
|
+
const actionValue = $("#chapter-batch-action").value;
|
|
3405
|
+
const action = actionValue === "move"
|
|
3406
|
+
? { type: "move", volumeId: $("#chapter-batch-volume").value }
|
|
3407
|
+
: actionValue === "setType"
|
|
3408
|
+
? { type: "setType", chapterType: $("#chapter-batch-type").value }
|
|
3409
|
+
: actionValue === "exclude" || actionValue === "include"
|
|
3410
|
+
? { type: "setAnalysisExclusion", excludedFromAnalysis: actionValue === "exclude" }
|
|
3411
|
+
: { type: "delete" };
|
|
3412
|
+
const dialog = $("#chapter-batch-dialog");
|
|
3413
|
+
if (action.type === "delete") {
|
|
3414
|
+
dialog.close();
|
|
3415
|
+
const confirmed = await confirmToast(`所选 ${chapters.length} 个章节的正文、版本和关联资料会保留,后续可以恢复。仍要删除吗?`, {
|
|
3416
|
+
title: "批量删除需要再次确认",
|
|
3417
|
+
confirmLabel: "确认软删除"
|
|
3418
|
+
});
|
|
3419
|
+
if (!confirmed) {
|
|
3420
|
+
dialog.showModal();
|
|
3421
|
+
return;
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3424
|
+
$("#chapter-batch-apply").disabled = true;
|
|
3425
|
+
try {
|
|
3426
|
+
await api(`/api/works/${encodeURIComponent(state.work.id)}/chapters/batch`, {
|
|
3427
|
+
method: "POST",
|
|
3428
|
+
body: { chapters: chapters.map((chapter) => ({ id: chapter.id, expectedVersionNo: chapter.versionNo })), action }
|
|
3429
|
+
});
|
|
3430
|
+
const workId = state.work.id;
|
|
3431
|
+
state.work = await api(`/api/works/${encodeURIComponent(workId)}`);
|
|
3432
|
+
const currentStillExists = state.chapter && state.work.volumes.some((volume) => volume.chapters.some((chapter) => chapter.id === state.chapter.id));
|
|
3433
|
+
if (state.chapter && currentStillExists) state.chapter = await api(`/api/chapters/${encodeURIComponent(state.chapter.id)}`);
|
|
3434
|
+
if (state.chapter && !currentStillExists) {
|
|
3435
|
+
state.chapter = null;
|
|
3436
|
+
showWelcome(true);
|
|
3437
|
+
} else renderTree();
|
|
3438
|
+
if (dialog.open) dialog.close();
|
|
3439
|
+
chapterBatchSelectedIds.clear();
|
|
3440
|
+
toast(`已批量处理 ${chapters.length} 个章节`);
|
|
3441
|
+
} catch (error) {
|
|
3442
|
+
if (!dialog.open) dialog.showModal();
|
|
3443
|
+
$("#chapter-batch-apply").disabled = false;
|
|
3444
|
+
toast(error.message, "error");
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
function findChapterLocation(chapterId) {
|
|
3449
|
+
for (const [volumeIndex, volume] of (state.work?.volumes ?? []).entries()) {
|
|
3450
|
+
const chapterIndex = volume.chapters.findIndex((chapter) => chapter.id === chapterId);
|
|
3451
|
+
if (chapterIndex >= 0) return { volume, volumeIndex, chapterIndex, chapter: volume.chapters[chapterIndex] };
|
|
3452
|
+
}
|
|
3453
|
+
return null;
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
async function moveChapterInTree(chapterId, volumeId, sortOrder) {
|
|
3457
|
+
const location = findChapterLocation(chapterId);
|
|
3458
|
+
if (!location || chapterMovePending) return;
|
|
3459
|
+
const samePosition = location.volume.id === volumeId && location.chapterIndex === sortOrder;
|
|
3460
|
+
if (samePosition) return;
|
|
3461
|
+
chapterMovePending = true;
|
|
3462
|
+
try {
|
|
3463
|
+
const moved = await api(`/api/chapters/${encodeURIComponent(chapterId)}/move`, {
|
|
3464
|
+
method: "POST",
|
|
3465
|
+
body: { volumeId, sortOrder, expectedVersionNo: location.chapter.versionNo }
|
|
3466
|
+
});
|
|
3467
|
+
state.work = await api(`/api/works/${encodeURIComponent(state.work.id)}`);
|
|
3468
|
+
if (state.chapter?.id === chapterId) state.chapter = { ...state.chapter, ...moved };
|
|
3469
|
+
renderTree();
|
|
3470
|
+
$("#novel-tree").querySelector(`[data-chapter-id="${CSS.escape(chapterId)}"]`)?.focus();
|
|
3471
|
+
toast(location.volume.id === volumeId ? "章节顺序已更新" : "章节已移动到目标分卷");
|
|
3472
|
+
} catch (error) {
|
|
3473
|
+
toast(error.message, "error");
|
|
3474
|
+
} finally {
|
|
3475
|
+
chapterMovePending = false;
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
async function moveChapterByKeyboard(chapterId, direction, crossVolume) {
|
|
3480
|
+
const location = findChapterLocation(chapterId);
|
|
3481
|
+
if (!location || !state.work) return;
|
|
3482
|
+
if (crossVolume) {
|
|
3483
|
+
const targetVolume = state.work.volumes[location.volumeIndex + direction];
|
|
3484
|
+
if (!targetVolume) return toast("已经是最前或最后一个分卷");
|
|
3485
|
+
await moveChapterInTree(chapterId, targetVolume.id, direction < 0 ? targetVolume.chapters.length : 0);
|
|
3486
|
+
return;
|
|
3487
|
+
}
|
|
3488
|
+
const targetIndex = location.chapterIndex + direction;
|
|
3489
|
+
if (targetIndex < 0 || targetIndex >= location.volume.chapters.length) return toast("已经是本卷首章或末章");
|
|
3490
|
+
await moveChapterInTree(chapterId, location.volume.id, targetIndex);
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3067
3493
|
function closeChapterTypeMenu() {
|
|
3068
3494
|
state.contextChapterId = null;
|
|
3069
3495
|
$("#chapter-type-menu").classList.add("hidden");
|
|
@@ -3085,6 +3511,57 @@ function openChapterTypeMenu(chapterId, clientX, clientY) {
|
|
|
3085
3511
|
menu.style.top = `${Math.max(8, Math.min(clientY, window.innerHeight - rect.height - 8))}px`;
|
|
3086
3512
|
}
|
|
3087
3513
|
|
|
3514
|
+
async function deleteChapter(chapterId) {
|
|
3515
|
+
if (!state.work || !canEditProse()) return toast("当前权限不能删除正文", "error");
|
|
3516
|
+
const chaptersBeforeDelete = state.work.volumes.flatMap((volume) => volume.chapters);
|
|
3517
|
+
const chapterIndex = chaptersBeforeDelete.findIndex((chapter) => chapter.id === chapterId);
|
|
3518
|
+
const chapter = chaptersBeforeDelete[chapterIndex];
|
|
3519
|
+
if (!chapter) return toast("章节不存在或已被删除", "error");
|
|
3520
|
+
const deletingCurrentChapter = state.chapter?.id === chapterId;
|
|
3521
|
+
if (deletingCurrentChapter) cancelChapterAutoSave();
|
|
3522
|
+
const resumeAutoSave = () => {
|
|
3523
|
+
if (deletingCurrentChapter && state.dirty && !chapterEditorReadOnly) scheduleChapterAutoSave();
|
|
3524
|
+
};
|
|
3525
|
+
const dirtyWarning = deletingCurrentChapter && state.dirty ? ",当前未保存修改不会保留" : "";
|
|
3526
|
+
if (!await confirmToast(`确认删除章节“${chapter.title}”吗?删除后将从正文目录隐藏${dirtyWarning}。`, {
|
|
3527
|
+
title: "删除章节",
|
|
3528
|
+
confirmLabel: "继续删除"
|
|
3529
|
+
})) {
|
|
3530
|
+
resumeAutoSave();
|
|
3531
|
+
return;
|
|
3532
|
+
}
|
|
3533
|
+
if (!await confirmToast(`章节“${chapter.title}”的正文、版本和关联资料会保留,后续可以恢复。仍要删除吗?`, {
|
|
3534
|
+
title: "删除操作需要再次确认",
|
|
3535
|
+
confirmLabel: "确认删除"
|
|
3536
|
+
})) {
|
|
3537
|
+
resumeAutoSave();
|
|
3538
|
+
return;
|
|
3539
|
+
}
|
|
3540
|
+
try {
|
|
3541
|
+
const deletingSelectedChapter = state.chapter?.id === chapterId;
|
|
3542
|
+
const expectedVersionNo = deletingSelectedChapter ? state.chapter.versionNo : chapter.versionNo;
|
|
3543
|
+
await api(`/api/chapters/${chapterId}`, { method: "DELETE", body: { expectedVersionNo } });
|
|
3544
|
+
const workId = state.work.id;
|
|
3545
|
+
state.work = await api(`/api/works/${workId}`);
|
|
3546
|
+
if (deletingSelectedChapter) {
|
|
3547
|
+
state.chapter = null;
|
|
3548
|
+
state.dirty = false;
|
|
3549
|
+
lastSavedChapterSnapshot = null;
|
|
3550
|
+
setSaveState("已删除");
|
|
3551
|
+
const remainingChapters = state.work.volumes.flatMap((volume) => volume.chapters);
|
|
3552
|
+
const nextChapter = remainingChapters[Math.min(Math.max(chapterIndex, 0), remainingChapters.length - 1)];
|
|
3553
|
+
if (nextChapter) await selectChapter(nextChapter.id);
|
|
3554
|
+
else showWelcome(true);
|
|
3555
|
+
} else {
|
|
3556
|
+
renderTree();
|
|
3557
|
+
}
|
|
3558
|
+
toast(`已删除章节“${chapter.title}”,正文和版本记录已保留`);
|
|
3559
|
+
} catch (error) {
|
|
3560
|
+
resumeAutoSave();
|
|
3561
|
+
toast(error.message, "error");
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3088
3565
|
async function selectChapter(chapterId, { editMode = false } = {}) {
|
|
3089
3566
|
if (state.chapter?.id !== chapterId && !(await confirmDiscardChanges("当前章节有未保存修改,仍要切换吗?"))) return;
|
|
3090
3567
|
cancelChapterAutoSave();
|
|
@@ -5851,6 +6328,11 @@ function bindWorkCoverControls(work) {
|
|
|
5851
6328
|
});
|
|
5852
6329
|
}
|
|
5853
6330
|
|
|
6331
|
+
function downloadWorkManuscript(work) {
|
|
6332
|
+
if (!work?.id) return;
|
|
6333
|
+
window.location.href = `/api/works/${encodeURIComponent(work.id)}/export?format=markdown`;
|
|
6334
|
+
}
|
|
6335
|
+
|
|
5854
6336
|
function openWorkSettingsDialog(work) {
|
|
5855
6337
|
if (!work) return;
|
|
5856
6338
|
const canManageAccess = ["admin", "owner"].includes(String(work.accessRole));
|
|
@@ -5867,12 +6349,20 @@ function openWorkSettingsDialog(work) {
|
|
|
5867
6349
|
<div><strong id="import-history-settings-title">正文导入历史</strong><small>查看导入前快照并恢复被覆盖的分卷、章节标题和正文;大纲、伏笔等章节关联资料不在快照中。</small></div>
|
|
5868
6350
|
<button id="import-history-button" class="ghost-button" type="button" aria-controls="import-history-dialog" aria-haspopup="dialog" ${canOpenImportHistory ? "" : "disabled"}>${importHistoryAction}</button>
|
|
5869
6351
|
</section>`;
|
|
6352
|
+
const exportField = `<section class="work-access-field" aria-labelledby="work-export-settings-title">
|
|
6353
|
+
<div><strong id="work-export-settings-title">导出正文</strong><small>服务器将分卷、章节标题与正文压缩为 ZIP,压缩包内含 Markdown 文件;不包含角色、设定、关系、时间轴、大纲、伏笔或 AI 分析资料。</small></div>
|
|
6354
|
+
<button id="work-export-button" class="ghost-button" type="button">下载 ZIP</button>
|
|
6355
|
+
</section>`;
|
|
6356
|
+
const recycleBinField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="chapter-recycle-bin-settings-title">
|
|
6357
|
+
<div><strong id="chapter-recycle-bin-settings-title">章节回收站</strong><small>查看并恢复已软删除的章节,正文、版本和关联资料不会在删除时清理。</small></div>
|
|
6358
|
+
<button id="chapter-recycle-bin-button" class="ghost-button" type="button" aria-controls="chapter-recycle-bin-dialog" aria-haspopup="dialog" ${canEditProse() ? "" : "disabled"}>打开回收站</button>
|
|
6359
|
+
</section>` : "";
|
|
5870
6360
|
const whitespaceField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="whitespace-settings-title">
|
|
5871
6361
|
<div><strong id="whitespace-settings-title">正文空白符</strong><small>在编辑器正文中显示或隐藏空格、全角空格和 Tab 的可视标记。</small></div>
|
|
5872
6362
|
<button id="toggle-whitespace-settings" class="ghost-button" data-toggle-whitespace type="button" aria-pressed="${chapterWhitespaceVisible}" title="用点标记半角空格,用方框标记全角空格,用箭头标记 Tab">${chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符"}</button>
|
|
5873
6363
|
</section>` : "";
|
|
5874
6364
|
openDialog("作品信息",
|
|
5875
|
-
workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + whitespaceField + accessField + importHistoryField,
|
|
6365
|
+
workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + whitespaceField + accessField + importHistoryField + exportField + recycleBinField,
|
|
5876
6366
|
async (form) => {
|
|
5877
6367
|
await api(`/api/works/${work.id}`, { method: "PATCH", body: { title: form.get("title"), author: form.get("author"), description: form.get("description") } });
|
|
5878
6368
|
state.works = (await apiPage("/api/works")).items;
|
|
@@ -5895,6 +6385,11 @@ function openWorkSettingsDialog(work) {
|
|
|
5895
6385
|
$("#form-dialog").close();
|
|
5896
6386
|
void openImportHistory();
|
|
5897
6387
|
});
|
|
6388
|
+
$("#work-export-button")?.addEventListener("click", () => downloadWorkManuscript(work));
|
|
6389
|
+
$("#chapter-recycle-bin-button")?.addEventListener("click", () => {
|
|
6390
|
+
$("#form-dialog").close();
|
|
6391
|
+
void openChapterRecycleBin();
|
|
6392
|
+
});
|
|
5898
6393
|
$("#work-access-manage")?.addEventListener("click", () => {
|
|
5899
6394
|
$("#form-dialog").close();
|
|
5900
6395
|
openMembersDialog(work);
|
|
@@ -5921,11 +6416,16 @@ function openVolumeDialog(item) {
|
|
|
5921
6416
|
if (!state.work) return openWorkDialog();
|
|
5922
6417
|
if (!canEditProse()) return toast("当前权限只能编辑设定资料,不能修改分卷", "error");
|
|
5923
6418
|
const kindOptions = [["main", "正文卷"], ["prequel", "前传"], ["extra", "番外"], ["epilogue", "后记"], ["appendix", "附录"]];
|
|
6419
|
+
const management = item ? `<section class="entity-dialog-management" aria-label="分卷操作">
|
|
6420
|
+
<div><strong>分卷操作</strong><small>仅空分卷可以删除;卷内章节及回收站章节需先移动到其他分卷。</small></div>
|
|
6421
|
+
<div class="entity-dialog-management-actions"><button class="danger-button" type="button" data-dialog-volume-delete>删除分卷</button></div>
|
|
6422
|
+
</section>` : "";
|
|
5924
6423
|
openDialog(item ? "编辑分卷" : "新建分卷",
|
|
5925
6424
|
field("title", "分卷名称", "text", item?.title) +
|
|
5926
6425
|
field("kind", "分卷类型", "select", item?.kind ?? "main", kindOptions) +
|
|
5927
6426
|
field("description", "分卷简介", "textarea", item?.description) +
|
|
5928
|
-
field("keywords", "分卷关键词", "keyword-chips", item?.keywords ?? [])
|
|
6427
|
+
field("keywords", "分卷关键词", "keyword-chips", item?.keywords ?? []) +
|
|
6428
|
+
management,
|
|
5929
6429
|
async (form) => {
|
|
5930
6430
|
const body = {
|
|
5931
6431
|
title: form.get("title"),
|
|
@@ -5938,6 +6438,28 @@ function openVolumeDialog(item) {
|
|
|
5938
6438
|
renderTree();
|
|
5939
6439
|
toast(item ? "分卷设置已保存" : "分卷已创建");
|
|
5940
6440
|
}, "分卷设置");
|
|
6441
|
+
$("#dialog-fields").querySelector("[data-dialog-volume-delete]")?.addEventListener("click", () => {
|
|
6442
|
+
void deleteVolume(item);
|
|
6443
|
+
});
|
|
6444
|
+
}
|
|
6445
|
+
|
|
6446
|
+
async function deleteVolume(item) {
|
|
6447
|
+
if (!state.work || !item || !canEditProse()) return toast("当前权限不能删除分卷", "error");
|
|
6448
|
+
$("#form-dialog").close();
|
|
6449
|
+
if (!await confirmToast(`确认删除空分卷“${item.title}”吗?分卷信息和版本记录仍会保留。`, {
|
|
6450
|
+
title: "删除分卷",
|
|
6451
|
+
confirmLabel: "确认删除"
|
|
6452
|
+
})) return openVolumeDialog(item);
|
|
6453
|
+
try {
|
|
6454
|
+
await api(`/api/volumes/${item.id}`, { method: "DELETE", body: { expectedVersionNo: item.versionNo } });
|
|
6455
|
+
state.collapsedVolumeIds.delete(item.id);
|
|
6456
|
+
state.work = await api(`/api/works/${state.work.id}`);
|
|
6457
|
+
renderTree();
|
|
6458
|
+
toast(`已删除分卷“${item.title}”`);
|
|
6459
|
+
} catch (error) {
|
|
6460
|
+
toast(error.message, "error");
|
|
6461
|
+
openVolumeDialog(item);
|
|
6462
|
+
}
|
|
5941
6463
|
}
|
|
5942
6464
|
|
|
5943
6465
|
function openSettingEditor(item = null, { readOnly = false } = {}) {
|
|
@@ -7925,6 +8447,69 @@ async function openImportHistory() {
|
|
|
7925
8447
|
}
|
|
7926
8448
|
}
|
|
7927
8449
|
|
|
8450
|
+
function renderChapterRecycleBin(chapters) {
|
|
8451
|
+
const host = $("#chapter-recycle-bin-list");
|
|
8452
|
+
if (!chapters.length) {
|
|
8453
|
+
host.innerHTML = '<p class="entity-history-empty">回收站为空,没有可恢复的章节。</p>';
|
|
8454
|
+
return;
|
|
8455
|
+
}
|
|
8456
|
+
host.innerHTML = chapters.map((chapter) => `<article class="entity-version-card" data-deleted-chapter="${esc(chapter.id)}">
|
|
8457
|
+
<header><strong>${esc(chapter.title)}</strong><span class="import-history-kind">${esc(chapter.volumeTitle)}</span></header>
|
|
8458
|
+
<time>${esc(formatDateTime(chapter.deletedAt))} · ${esc(chapter.actor)}</time>
|
|
8459
|
+
<p>${esc(chapter.contentPreview || "空白章节")}</p>
|
|
8460
|
+
<small>${Number(chapter.wordCount).toLocaleString("zh-CN")} 字 · 删除版本 v${Number(chapter.versionNo)}</small>
|
|
8461
|
+
<button type="button" data-restore-deleted-chapter="${esc(chapter.id)}">恢复章节</button>
|
|
8462
|
+
</article>`).join("");
|
|
8463
|
+
host.querySelectorAll("[data-restore-deleted-chapter]").forEach((button) => button.addEventListener("click", async () => {
|
|
8464
|
+
const chapter = chapters.find((item) => item.id === button.dataset.restoreDeletedChapter);
|
|
8465
|
+
if (!chapter || !state.work) return;
|
|
8466
|
+
const dialog = $("#chapter-recycle-bin-dialog");
|
|
8467
|
+
dialog.close();
|
|
8468
|
+
const confirmed = await confirmToast(`将章节“${chapter.title}”恢复到分卷“${chapter.volumeTitle}”吗?`, {
|
|
8469
|
+
title: "恢复已删除章节",
|
|
8470
|
+
confirmLabel: "确认恢复"
|
|
8471
|
+
});
|
|
8472
|
+
if (!confirmed) {
|
|
8473
|
+
dialog.showModal();
|
|
8474
|
+
return;
|
|
8475
|
+
}
|
|
8476
|
+
button.disabled = true;
|
|
8477
|
+
try {
|
|
8478
|
+
await api(`/api/chapters/${encodeURIComponent(chapter.id)}/restore`, {
|
|
8479
|
+
method: "POST",
|
|
8480
|
+
body: { versionNo: chapter.versionNo, expectedVersionNo: chapter.versionNo }
|
|
8481
|
+
});
|
|
8482
|
+
state.work = await api(`/api/works/${encodeURIComponent(state.work.id)}`);
|
|
8483
|
+
renderTree();
|
|
8484
|
+
await loadChapterRecycleBin();
|
|
8485
|
+
dialog.showModal();
|
|
8486
|
+
toast(`已恢复章节“${chapter.title}”`);
|
|
8487
|
+
} catch (error) {
|
|
8488
|
+
button.disabled = false;
|
|
8489
|
+
dialog.showModal();
|
|
8490
|
+
toast(error.message, "error");
|
|
8491
|
+
}
|
|
8492
|
+
}));
|
|
8493
|
+
}
|
|
8494
|
+
|
|
8495
|
+
async function loadChapterRecycleBin() {
|
|
8496
|
+
if (!state.work) return;
|
|
8497
|
+
const chapters = await api(`/api/works/${encodeURIComponent(state.work.id)}/deleted-chapters`);
|
|
8498
|
+
renderChapterRecycleBin(chapters);
|
|
8499
|
+
}
|
|
8500
|
+
|
|
8501
|
+
async function openChapterRecycleBin() {
|
|
8502
|
+
if (!state.work || !canEditProse()) return toast("当前权限不能恢复正文", "error");
|
|
8503
|
+
$("#chapter-recycle-bin-list").innerHTML = '<p class="entity-history-empty">正在读取已删除章节…</p>';
|
|
8504
|
+
$("#chapter-recycle-bin-dialog").showModal();
|
|
8505
|
+
try {
|
|
8506
|
+
await loadChapterRecycleBin();
|
|
8507
|
+
} catch (error) {
|
|
8508
|
+
$("#chapter-recycle-bin-dialog").close();
|
|
8509
|
+
toast(error.message, "error");
|
|
8510
|
+
}
|
|
8511
|
+
}
|
|
8512
|
+
|
|
7928
8513
|
async function showChapterInsight() {
|
|
7929
8514
|
if (!state.chapter) return;
|
|
7930
8515
|
const chapterId = state.chapter.id;
|
|
@@ -8502,6 +9087,7 @@ $("#login-form").addEventListener("submit", async (event) => {
|
|
|
8502
9087
|
captchaAnswer: form.get("captchaAnswer")
|
|
8503
9088
|
}
|
|
8504
9089
|
});
|
|
9090
|
+
window.history.replaceState(null, "", serializePageRoute({ view: "shelf" }));
|
|
8505
9091
|
window.location.reload();
|
|
8506
9092
|
} catch (error) {
|
|
8507
9093
|
$("#auth-error").textContent = error.message;
|
|
@@ -8554,6 +9140,17 @@ $("#platform-usage-refresh").addEventListener("click", async () => {
|
|
|
8554
9140
|
}
|
|
8555
9141
|
});
|
|
8556
9142
|
$("#user-management-button").addEventListener("click", openUsersDialog);
|
|
9143
|
+
$("#writing-progress-button").addEventListener("click", () => openWritingProgressDialog().catch((error) => toast(error.message, "error")));
|
|
9144
|
+
$("#writing-progress-close").addEventListener("click", () => $("#writing-progress-dialog").close());
|
|
9145
|
+
$("#writing-progress-refresh").addEventListener("click", () => loadWritingProgress().catch((error) => toast(error.message, "error")));
|
|
9146
|
+
$("#writing-goal-form").addEventListener("submit", saveWritingGoal);
|
|
9147
|
+
$("#work-audit-button").addEventListener("click", () => openWorkAuditDialog().catch((error) => toast(error.message, "error")));
|
|
9148
|
+
$("#work-audit-close").addEventListener("click", () => $("#work-audit-dialog").close());
|
|
9149
|
+
$("#work-audit-settings-return").addEventListener("click", () => returnToSettingsHub("#work-audit-button", "#work-audit-dialog").catch((error) => toast(error.message, "error")));
|
|
9150
|
+
$("#work-audit-refresh").addEventListener("click", () => loadWorkAuditPage().catch((error) => toast(error.message, "error")));
|
|
9151
|
+
$("#work-audit-load-more").addEventListener("click", () => {
|
|
9152
|
+
if (workAuditNextPage !== null) loadWorkAuditPage(workAuditNextPage, true).catch((error) => toast(error.message, "error"));
|
|
9153
|
+
});
|
|
8557
9154
|
$("#platform-ui-settings-button").addEventListener("click", openPlatformUiSettingsDialog);
|
|
8558
9155
|
$("#collaboration-button").addEventListener("click", () => openMembersDialog());
|
|
8559
9156
|
$("#presence-button").addEventListener("click", () => {
|
|
@@ -8640,13 +9237,34 @@ $("#platform-new-provider").addEventListener("click", () => openProviderDialog()
|
|
|
8640
9237
|
$("#shelf-new-work").addEventListener("click", openWorkDialog);
|
|
8641
9238
|
$("#welcome-new-work").addEventListener("click", () => state.work ? openChapterDialog() : openWorkDialog());
|
|
8642
9239
|
$("#save-button").addEventListener("click", saveChapter);
|
|
9240
|
+
$("#chapter-delete-button").addEventListener("click", () => {
|
|
9241
|
+
if (state.chapter) void deleteChapter(state.chapter.id);
|
|
9242
|
+
});
|
|
8643
9243
|
$("#chapter-edit-button").addEventListener("click", enterChapterEditMode);
|
|
8644
9244
|
$("#tidy-blank-lines-button").addEventListener("click", tidyChapterBlankLines);
|
|
8645
9245
|
$("#new-volume-button").addEventListener("click", () => openVolumeDialog());
|
|
9246
|
+
$("#chapter-batch-button").addEventListener("click", openChapterBatchDialog);
|
|
9247
|
+
$("#chapter-batch-close").addEventListener("click", () => $("#chapter-batch-dialog").close());
|
|
9248
|
+
$("#chapter-batch-cancel").addEventListener("click", () => $("#chapter-batch-dialog").close());
|
|
9249
|
+
$("#chapter-batch-action").addEventListener("change", updateChapterBatchControls);
|
|
9250
|
+
$("#chapter-batch-search").addEventListener("input", renderChapterBatchDialog);
|
|
9251
|
+
$("#chapter-batch-select-all").addEventListener("click", () => {
|
|
9252
|
+
const searchQuery = $("#chapter-batch-search").value.trim().toLocaleLowerCase("zh-CN");
|
|
9253
|
+
state.work?.volumes.flatMap((volume) => volume.chapters)
|
|
9254
|
+
.filter((chapter) => !searchQuery || chapter.title.toLocaleLowerCase("zh-CN").includes(searchQuery))
|
|
9255
|
+
.forEach((chapter) => chapterBatchSelectedIds.add(chapter.id));
|
|
9256
|
+
renderChapterBatchDialog();
|
|
9257
|
+
});
|
|
9258
|
+
$("#chapter-batch-clear").addEventListener("click", () => {
|
|
9259
|
+
chapterBatchSelectedIds.clear();
|
|
9260
|
+
renderChapterBatchDialog();
|
|
9261
|
+
});
|
|
9262
|
+
$("#chapter-batch-form").addEventListener("submit", submitChapterBatch);
|
|
8646
9263
|
$("#insight-button").addEventListener("click", () => showChapterInsight().catch((error) => toast(error.message, "error")));
|
|
8647
9264
|
$("#versions-button").addEventListener("click", showVersions);
|
|
8648
9265
|
$("#versions-close").addEventListener("click", () => $("#versions-dialog").close());
|
|
8649
9266
|
$("#import-history-close").addEventListener("click", () => $("#import-history-dialog").close());
|
|
9267
|
+
$("#chapter-recycle-bin-close").addEventListener("click", () => $("#chapter-recycle-bin-dialog").close());
|
|
8650
9268
|
$("#entity-history-close").addEventListener("click", () => $("#entity-history-dialog").close());
|
|
8651
9269
|
$("#ai-tool-call-close").addEventListener("click", () => $("#ai-tool-call-dialog").close());
|
|
8652
9270
|
$("#setting-editor-back").addEventListener("click", () => { void closeEntityEditor(); });
|
|
@@ -8736,6 +9354,12 @@ $("#chapter-line-numbers-inner").addEventListener("contextmenu", (event) => {
|
|
|
8736
9354
|
if (row) showLineCitationMenu(event, Number(row.dataset.lineIndex));
|
|
8737
9355
|
});
|
|
8738
9356
|
$("#add-line-citation").addEventListener("click", addSelectedLinesAsCitation);
|
|
9357
|
+
$("#add-line-annotation").addEventListener("click", () => createSelectedLineAnnotation("note"));
|
|
9358
|
+
$("#add-line-todo").addEventListener("click", () => createSelectedLineAnnotation("todo"));
|
|
9359
|
+
$("#chapter-annotations-button").addEventListener("click", () => openChapterAnnotationsDialog().catch((error) => toast(error.message, "error")));
|
|
9360
|
+
$("#chapter-annotations-close").addEventListener("click", () => $("#chapter-annotations-dialog").close());
|
|
9361
|
+
$("#chapter-annotations-done").addEventListener("click", () => $("#chapter-annotations-dialog").close());
|
|
9362
|
+
$("#chapter-annotations-refresh").addEventListener("click", () => loadChapterAnnotations().catch((error) => toast(error.message, "error")));
|
|
8739
9363
|
$("#left-panel-toggle").addEventListener("click", () => {
|
|
8740
9364
|
panelLayout.leftCollapsed = !panelLayout.leftCollapsed;
|
|
8741
9365
|
applyPanelLayout(true);
|
|
@@ -8894,6 +9518,13 @@ $("#cover-file").addEventListener("change", async (event) => {
|
|
|
8894
9518
|
}
|
|
8895
9519
|
});
|
|
8896
9520
|
$("#chapter-type-menu").addEventListener("click", async (event) => {
|
|
9521
|
+
const deleteButton = event.target.closest("[data-delete-chapter]");
|
|
9522
|
+
if (deleteButton) {
|
|
9523
|
+
const chapterId = state.contextChapterId;
|
|
9524
|
+
closeChapterTypeMenu();
|
|
9525
|
+
if (chapterId) await deleteChapter(chapterId);
|
|
9526
|
+
return;
|
|
9527
|
+
}
|
|
8897
9528
|
const button = event.target.closest("[data-chapter-type]");
|
|
8898
9529
|
const chapterId = state.contextChapterId;
|
|
8899
9530
|
if (!button || !chapterId) return;
|
|
@@ -9044,9 +9675,7 @@ $("#search-form").addEventListener("submit", async (event) => {
|
|
|
9044
9675
|
$("#search-results").innerHTML = `<p class="search-results-status">${esc(error.message)}</p>`;
|
|
9045
9676
|
});
|
|
9046
9677
|
});
|
|
9047
|
-
$("#export-button").addEventListener("click", () =>
|
|
9048
|
-
if (state.work) window.location.href = `/api/works/${state.work.id}/export?format=markdown`;
|
|
9049
|
-
});
|
|
9678
|
+
$("#export-button").addEventListener("click", () => downloadWorkManuscript(state.work));
|
|
9050
9679
|
window.addEventListener("beforeunload", (event) => { if (state.dirty || entityEditorDirty || characterSectionEditorDirty) event.preventDefault(); });
|
|
9051
9680
|
|
|
9052
9681
|
initializePage().catch((error) => {
|