@musnows/scriverse 0.3.10 → 0.3.11
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/app.js +5 -2
- package/dist/app.js.map +1 -1
- package/dist/public/app.js +174 -27
- package/dist/public/index.html +16 -4
- package/dist/public/styles.css +9 -0
- package/dist/store.js +70 -16
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +4 -1
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/work-permissions.js +1 -0
- package/dist/work-permissions.js.map +1 -1
- package/package.json +1 -1
package/dist/public/app.js
CHANGED
|
@@ -89,6 +89,12 @@ function canEditProse(work = state.work) {
|
|
|
89
89
|
return canWriteUiModule(work, "editor");
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
function canReplaceProse(work = state.work) {
|
|
93
|
+
return WORK_PERMISSION_MODULES
|
|
94
|
+
.filter((item) => item.id !== "ai-settings")
|
|
95
|
+
.every((item) => canWriteUiModule(work, item.uiModule));
|
|
96
|
+
}
|
|
97
|
+
|
|
92
98
|
function canManageWork(work = state.work) {
|
|
93
99
|
return ["admin", "owner"].includes(String(work?.accessRole));
|
|
94
100
|
}
|
|
@@ -123,6 +129,10 @@ function applyWorkAccessMode() {
|
|
|
123
129
|
if (button) button.classList.toggle("permission-hidden", !canReadModule(item.uiModule));
|
|
124
130
|
}
|
|
125
131
|
$("#module-nav [data-work-settings]").classList.toggle("permission-hidden", Boolean(state.work) && !canManageWork());
|
|
132
|
+
$("#import-file-button").classList.toggle("permission-hidden", proseReadOnly);
|
|
133
|
+
$("#import-file-button").setAttribute("aria-hidden", String(proseReadOnly));
|
|
134
|
+
$("#import-file").disabled = proseReadOnly;
|
|
135
|
+
$("#import-history-button").classList.toggle("permission-hidden", Boolean(state.work) && !canReplaceProse());
|
|
126
136
|
$(".ai-panel").classList.toggle("permission-hidden", aiHidden);
|
|
127
137
|
$("#chapter-title").readOnly = proseReadOnly;
|
|
128
138
|
$("#chapter-content").readOnly = proseReadOnly;
|
|
@@ -198,6 +208,10 @@ let aiReferencesLoadPromise = null;
|
|
|
198
208
|
let aiReferencesLoadWorkId = null;
|
|
199
209
|
let aiConversationsLoadPromise = null;
|
|
200
210
|
let aiConversationsLoadWorkId = null;
|
|
211
|
+
let workScopedUiGeneration = 0;
|
|
212
|
+
let importHistoryRecords = [];
|
|
213
|
+
let importHistoryNextPage = null;
|
|
214
|
+
let importHistoryRequestId = 0;
|
|
201
215
|
|
|
202
216
|
const shelfOnboardingSteps = [
|
|
203
217
|
{ selector: "#home-button", eyebrow: "作品入口", title: "这里是你的创作书架", description: "点击左上角的叙界标志,可以随时回到书架,在不同作品之间切换。", placement: "bottom" },
|
|
@@ -1106,8 +1120,9 @@ function renderAiConversationHistory() {
|
|
|
1106
1120
|
async function loadAiConversations(openLatest = true) {
|
|
1107
1121
|
const workId = state.work?.id;
|
|
1108
1122
|
if (!workId) return;
|
|
1123
|
+
const generation = workScopedUiGeneration;
|
|
1109
1124
|
const conversations = (await apiPage(`/api/works/${workId}/ai-conversations`)).items;
|
|
1110
|
-
if (state.work?.id !== workId) return;
|
|
1125
|
+
if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
|
|
1111
1126
|
state.aiConversations = conversations;
|
|
1112
1127
|
loadedAiConversationsWorkId = workId;
|
|
1113
1128
|
renderAiConversationHistory();
|
|
@@ -1798,8 +1813,11 @@ function confirmDiscardChanges(message = "当前章节有未保存修改,继
|
|
|
1798
1813
|
|
|
1799
1814
|
function chooseExistingWorkImportMode(file) {
|
|
1800
1815
|
const dialog = $("#import-mode-dialog");
|
|
1816
|
+
const canOverwrite = canReplaceProse();
|
|
1801
1817
|
$("#import-mode-file-summary").textContent = `文件:${file.name};当前作品:《${state.work.title}》`;
|
|
1802
1818
|
$("#import-mode-unsaved-warning").classList.toggle("hidden", !state.dirty);
|
|
1819
|
+
$("#import-mode-overwrite").disabled = !canOverwrite;
|
|
1820
|
+
$("#import-mode-overwrite-permission").classList.toggle("hidden", canOverwrite);
|
|
1803
1821
|
dialog.returnValue = "cancel";
|
|
1804
1822
|
dialog.showModal();
|
|
1805
1823
|
return new Promise((resolve) => {
|
|
@@ -2257,34 +2275,45 @@ function renderShelf() {
|
|
|
2257
2275
|
$("#book-add-card").addEventListener("click", openWorkDialog);
|
|
2258
2276
|
}
|
|
2259
2277
|
|
|
2278
|
+
function resetWorkScopedUiCaches() {
|
|
2279
|
+
workScopedUiGeneration += 1;
|
|
2280
|
+
loadedAiModelsWorkId = null;
|
|
2281
|
+
loadedAiReferencesWorkId = null;
|
|
2282
|
+
loadedAiConversationsWorkId = null;
|
|
2283
|
+
aiModelsLoadPromise = null;
|
|
2284
|
+
aiModelsLoadWorkId = null;
|
|
2285
|
+
aiReferencesLoadPromise = null;
|
|
2286
|
+
aiReferencesLoadWorkId = null;
|
|
2287
|
+
aiConversationsLoadPromise = null;
|
|
2288
|
+
aiConversationsLoadWorkId = null;
|
|
2289
|
+
state.models = [];
|
|
2290
|
+
state.characters = [];
|
|
2291
|
+
state.settings = [];
|
|
2292
|
+
state.collapsedVolumeIds.clear();
|
|
2293
|
+
lastSavedChapterSnapshot = null;
|
|
2294
|
+
if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
|
|
2295
|
+
aiContextUsageTimer = null;
|
|
2296
|
+
aiContextUsageRequest += 1;
|
|
2297
|
+
state.aiCitations = [];
|
|
2298
|
+
state.aiReferences = [];
|
|
2299
|
+
state.aiPromptSent = false;
|
|
2300
|
+
state.aiConversationId = null;
|
|
2301
|
+
state.aiConversations = [];
|
|
2302
|
+
renderAiCitations();
|
|
2303
|
+
renderAiReferences();
|
|
2304
|
+
renderAiQuickActions();
|
|
2305
|
+
resetAiFeed();
|
|
2306
|
+
$("#ai-conversation-title").textContent = "新对话";
|
|
2307
|
+
$("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
|
|
2308
|
+
setAiContextMeter(null);
|
|
2309
|
+
renderAiConversationHistory();
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2260
2312
|
async function selectWork(workId, preferredChapterId = null) {
|
|
2261
2313
|
const discarding = state.work?.id !== workId && state.dirty;
|
|
2262
2314
|
if (discarding && !confirmDiscardChanges()) return false;
|
|
2263
2315
|
const nextWork = await api(`/api/works/${workId}?page=1&limit=100`);
|
|
2264
|
-
if (state.work?.id !== nextWork.id)
|
|
2265
|
-
loadedAiModelsWorkId = null;
|
|
2266
|
-
loadedAiReferencesWorkId = null;
|
|
2267
|
-
loadedAiConversationsWorkId = null;
|
|
2268
|
-
state.models = [];
|
|
2269
|
-
state.characters = [];
|
|
2270
|
-
state.settings = [];
|
|
2271
|
-
if (aiContextUsageTimer !== null) clearTimeout(aiContextUsageTimer);
|
|
2272
|
-
aiContextUsageTimer = null;
|
|
2273
|
-
aiContextUsageRequest += 1;
|
|
2274
|
-
state.aiCitations = [];
|
|
2275
|
-
state.aiReferences = [];
|
|
2276
|
-
state.aiPromptSent = false;
|
|
2277
|
-
state.aiConversationId = null;
|
|
2278
|
-
state.aiConversations = [];
|
|
2279
|
-
renderAiCitations();
|
|
2280
|
-
renderAiReferences();
|
|
2281
|
-
renderAiQuickActions();
|
|
2282
|
-
resetAiFeed();
|
|
2283
|
-
$("#ai-conversation-title").textContent = "新对话";
|
|
2284
|
-
$("#ai-model").innerHTML = '<option value="">使用创作助手时加载模型</option>';
|
|
2285
|
-
setAiContextMeter(null);
|
|
2286
|
-
renderAiConversationHistory();
|
|
2287
|
-
}
|
|
2316
|
+
if (state.work?.id !== nextWork.id) resetWorkScopedUiCaches();
|
|
2288
2317
|
if (discarding) setSaveState("就绪");
|
|
2289
2318
|
$("#app").classList.remove("shelf-mode");
|
|
2290
2319
|
$("#shelf-view").classList.add("hidden");
|
|
@@ -3241,8 +3270,9 @@ async function renderBookAiSettings() {
|
|
|
3241
3270
|
async function loadModels() {
|
|
3242
3271
|
const workId = state.work?.id;
|
|
3243
3272
|
if (!workId) return;
|
|
3273
|
+
const generation = workScopedUiGeneration;
|
|
3244
3274
|
const models = await api(`/api/works/${workId}/models`);
|
|
3245
|
-
if (state.work?.id !== workId) return;
|
|
3275
|
+
if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
|
|
3246
3276
|
state.models = models;
|
|
3247
3277
|
loadedAiModelsWorkId = workId;
|
|
3248
3278
|
const select = $("#ai-model");
|
|
@@ -3383,11 +3413,12 @@ async function refreshAiContextUsage() {
|
|
|
3383
3413
|
async function loadAiReferences() {
|
|
3384
3414
|
const workId = state.work?.id;
|
|
3385
3415
|
if (!workId) return;
|
|
3416
|
+
const generation = workScopedUiGeneration;
|
|
3386
3417
|
const [characters, settings] = await Promise.all([
|
|
3387
3418
|
canReadModule("characters") ? apiAllPages(`/api/works/${workId}/characters`) : Promise.resolve([]),
|
|
3388
3419
|
canReadModule("settings") ? apiAllPages(`/api/works/${workId}/settings`) : Promise.resolve([])
|
|
3389
3420
|
]);
|
|
3390
|
-
if (state.work?.id !== workId) return;
|
|
3421
|
+
if (state.work?.id !== workId || generation !== workScopedUiGeneration) return;
|
|
3391
3422
|
state.characters = characters;
|
|
3392
3423
|
state.settings = settings;
|
|
3393
3424
|
loadedAiReferencesWorkId = workId;
|
|
@@ -4741,6 +4772,120 @@ async function showVersions() {
|
|
|
4741
4772
|
$("#versions-dialog").showModal();
|
|
4742
4773
|
}
|
|
4743
4774
|
|
|
4775
|
+
function importHistoryRecordLabel(version) {
|
|
4776
|
+
const restorePrefix = "before-restore:";
|
|
4777
|
+
if (version.fileType === "snapshot" && version.fileName.startsWith(restorePrefix)) {
|
|
4778
|
+
return {
|
|
4779
|
+
title: `恢复操作前备份:${version.fileName.slice(restorePrefix.length)}`,
|
|
4780
|
+
kind: "自动备份",
|
|
4781
|
+
action: "恢复此操作前备份"
|
|
4782
|
+
};
|
|
4783
|
+
}
|
|
4784
|
+
return { title: version.fileName, kind: String(version.fileType).toUpperCase(), action: "恢复到此次导入前" };
|
|
4785
|
+
}
|
|
4786
|
+
|
|
4787
|
+
function renderImportHistory(versions, nextPage = null) {
|
|
4788
|
+
const host = $("#import-history-list");
|
|
4789
|
+
if (!versions.length) {
|
|
4790
|
+
host.innerHTML = '<p class="entity-history-empty">还没有正文导入记录。首次导入后会自动保存导入前快照。</p>';
|
|
4791
|
+
return;
|
|
4792
|
+
}
|
|
4793
|
+
host.innerHTML = versions.map((version) => {
|
|
4794
|
+
const label = importHistoryRecordLabel(version);
|
|
4795
|
+
return `<article class="entity-version-card import-history-card" data-file-version="${esc(version.id)}">
|
|
4796
|
+
<header><strong title="${esc(label.title)}">${esc(label.title)}</strong><span class="import-history-kind">${esc(label.kind)}</span></header>
|
|
4797
|
+
<time>${esc(formatDateTime(version.createdAt))} · ${esc(version.actor || "历史数据")}</time>
|
|
4798
|
+
<p>${version.fileType === "snapshot" ? "恢复操作执行前自动保存的完整正文。" : "保存的是这次文件导入开始前的完整正文。"}</p>
|
|
4799
|
+
<small>自动备份仅包含正文,不能撤销章节关联信息的变化。</small>
|
|
4800
|
+
<button type="button" data-file-version-restore="${esc(version.id)}" data-default-label="${esc(label.action)}">${esc(label.action)}</button>
|
|
4801
|
+
</article>`;
|
|
4802
|
+
}).join("") + (nextPage ? '<button class="import-history-load-more" type="button" data-import-history-load-more>加载更多记录</button>' : "");
|
|
4803
|
+
host.querySelectorAll("[data-file-version-restore]").forEach((button) => button.addEventListener("click", async () => {
|
|
4804
|
+
if (!state.work || !canReplaceProse()) return;
|
|
4805
|
+
const defaultLabel = button.dataset.defaultLabel;
|
|
4806
|
+
if (button.dataset.confirmed !== "true") {
|
|
4807
|
+
host.querySelectorAll("[data-file-version-restore]").forEach((other) => {
|
|
4808
|
+
other.dataset.confirmed = "false";
|
|
4809
|
+
other.classList.remove("is-confirming");
|
|
4810
|
+
other.textContent = other.dataset.defaultLabel;
|
|
4811
|
+
});
|
|
4812
|
+
button.dataset.confirmed = "true";
|
|
4813
|
+
button.classList.add("is-confirming");
|
|
4814
|
+
button.textContent = "再次点击确认恢复";
|
|
4815
|
+
window.setTimeout(() => {
|
|
4816
|
+
if (!button.isConnected || button.dataset.confirmed !== "true") return;
|
|
4817
|
+
button.dataset.confirmed = "false";
|
|
4818
|
+
button.classList.remove("is-confirming");
|
|
4819
|
+
button.textContent = defaultLabel;
|
|
4820
|
+
}, 5000);
|
|
4821
|
+
return;
|
|
4822
|
+
}
|
|
4823
|
+
if (!confirmDiscardChanges("当前章节有未保存修改,恢复正文会丢弃这些本地修改。是否继续?")) return;
|
|
4824
|
+
button.disabled = true;
|
|
4825
|
+
cancelChapterAutoSave();
|
|
4826
|
+
const workId = state.work.id;
|
|
4827
|
+
try {
|
|
4828
|
+
await api(`/api/works/${encodeURIComponent(workId)}/file-versions/${encodeURIComponent(button.dataset.fileVersionRestore)}/restore`, {
|
|
4829
|
+
method: "POST",
|
|
4830
|
+
body: { expectedVersionNo: state.work.versionNo }
|
|
4831
|
+
});
|
|
4832
|
+
state.dirty = false;
|
|
4833
|
+
resetWorkScopedUiCaches();
|
|
4834
|
+
$("#import-history-dialog").close();
|
|
4835
|
+
await loadWorks(workId);
|
|
4836
|
+
toast("正文已恢复;恢复前正文已自动备份");
|
|
4837
|
+
} catch (error) {
|
|
4838
|
+
button.disabled = false;
|
|
4839
|
+
button.dataset.confirmed = "false";
|
|
4840
|
+
button.classList.remove("is-confirming");
|
|
4841
|
+
button.textContent = defaultLabel;
|
|
4842
|
+
if (state.dirty) scheduleChapterAutoSave();
|
|
4843
|
+
toast(error.message, "error");
|
|
4844
|
+
}
|
|
4845
|
+
}));
|
|
4846
|
+
host.querySelector("[data-import-history-load-more]")?.addEventListener("click", async (event) => {
|
|
4847
|
+
const button = event.currentTarget;
|
|
4848
|
+
button.disabled = true;
|
|
4849
|
+
button.textContent = "正在加载…";
|
|
4850
|
+
try {
|
|
4851
|
+
await loadImportHistoryPage(nextPage);
|
|
4852
|
+
} catch (error) {
|
|
4853
|
+
button.disabled = false;
|
|
4854
|
+
button.textContent = "加载更多记录";
|
|
4855
|
+
toast(error.message, "error");
|
|
4856
|
+
}
|
|
4857
|
+
});
|
|
4858
|
+
}
|
|
4859
|
+
|
|
4860
|
+
async function loadImportHistoryPage(page) {
|
|
4861
|
+
const workId = state.work?.id;
|
|
4862
|
+
if (!workId || !page) return;
|
|
4863
|
+
const requestId = ++importHistoryRequestId;
|
|
4864
|
+
const result = await apiPage(`/api/works/${encodeURIComponent(workId)}/file-versions`, page, 25);
|
|
4865
|
+
if (requestId !== importHistoryRequestId || state.work?.id !== workId || !$("#import-history-dialog").open) return;
|
|
4866
|
+
importHistoryRecords = page === 1 ? result.items : [...importHistoryRecords, ...result.items];
|
|
4867
|
+
importHistoryNextPage = result.nextPage;
|
|
4868
|
+
renderImportHistory(importHistoryRecords, importHistoryNextPage);
|
|
4869
|
+
}
|
|
4870
|
+
|
|
4871
|
+
async function openImportHistory() {
|
|
4872
|
+
if (!state.work || !canReplaceProse()) {
|
|
4873
|
+
toast("恢复整本正文需要所有受影响模块的编辑权限", "error");
|
|
4874
|
+
return;
|
|
4875
|
+
}
|
|
4876
|
+
importHistoryRecords = [];
|
|
4877
|
+
importHistoryNextPage = null;
|
|
4878
|
+
importHistoryRequestId += 1;
|
|
4879
|
+
$("#import-history-list").innerHTML = '<p class="entity-history-empty">正在读取导入历史…</p>';
|
|
4880
|
+
$("#import-history-dialog").showModal();
|
|
4881
|
+
try {
|
|
4882
|
+
await loadImportHistoryPage(1);
|
|
4883
|
+
} catch (error) {
|
|
4884
|
+
$("#import-history-dialog").close();
|
|
4885
|
+
toast(error.message, "error");
|
|
4886
|
+
}
|
|
4887
|
+
}
|
|
4888
|
+
|
|
4744
4889
|
async function showChapterInsight() {
|
|
4745
4890
|
if (!state.chapter) return;
|
|
4746
4891
|
const panel = $("#chapter-insight");
|
|
@@ -5057,6 +5202,8 @@ $("#new-volume-button").addEventListener("click", () => openVolumeDialog());
|
|
|
5057
5202
|
$("#insight-button").addEventListener("click", () => showChapterInsight().catch((error) => toast(error.message, "error")));
|
|
5058
5203
|
$("#versions-button").addEventListener("click", showVersions);
|
|
5059
5204
|
$("#versions-close").addEventListener("click", () => $("#versions-dialog").close());
|
|
5205
|
+
$("#import-history-button").addEventListener("click", () => openImportHistory());
|
|
5206
|
+
$("#import-history-close").addEventListener("click", () => $("#import-history-dialog").close());
|
|
5060
5207
|
$("#entity-history-close").addEventListener("click", () => $("#entity-history-dialog").close());
|
|
5061
5208
|
$("#ai-tool-call-close").addEventListener("click", () => $("#ai-tool-call-dialog").close());
|
|
5062
5209
|
$("#setting-editor-back").addEventListener("click", () => { void closeEntityEditor(); });
|
package/dist/public/index.html
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
<script src="/theme-init.js?v=20260720-route-skeleton"></script>
|
|
10
10
|
<link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
|
|
11
11
|
<link rel="manifest" href="/site.webmanifest">
|
|
12
|
-
<link rel="stylesheet" href="/styles.css?v=20260722-
|
|
12
|
+
<link rel="stylesheet" href="/styles.css?v=20260722-prose-replacement-permission">
|
|
13
13
|
</head>
|
|
14
14
|
<body class="auth-pending">
|
|
15
15
|
<section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
<div id="left-panel-resize" class="panel-resize-handle" role="separator" aria-label="调整作品侧栏宽度" aria-orientation="vertical" tabindex="0"></div>
|
|
92
92
|
<div class="left-actions">
|
|
93
93
|
<div class="left-primary-actions">
|
|
94
|
-
<label class="file-button" aria-label="导入 TXT / DOCX">
|
|
94
|
+
<label id="import-file-button" class="file-button" aria-label="导入 TXT / DOCX">
|
|
95
95
|
<span class="import-file-label import-file-label-full" aria-hidden="true">导入 TXT / DOCX</span>
|
|
96
96
|
<span class="import-file-label import-file-label-compact" aria-hidden="true">导入TXT/DOCX</span>
|
|
97
97
|
<span class="import-file-label import-file-label-short" aria-hidden="true">导入</span>
|
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
</label>
|
|
100
100
|
<button id="left-panel-toggle" class="panel-collapse-button" type="button" aria-label="收起作品侧栏" aria-expanded="true">‹</button>
|
|
101
101
|
</div>
|
|
102
|
+
<button id="import-history-button" class="secondary-button import-history-button" type="button" aria-controls="import-history-dialog" aria-haspopup="dialog">导入历史与恢复</button>
|
|
102
103
|
<button id="new-volume-button" class="secondary-button" type="button">新建分卷</button>
|
|
103
104
|
<button id="new-chapter-button" class="secondary-button" type="button">新建章节</button>
|
|
104
105
|
</div>
|
|
@@ -328,7 +329,7 @@
|
|
|
328
329
|
<p id="import-mode-unsaved-warning" class="import-mode-unsaved-warning hidden" role="alert">当前章节有未保存修改,继续导入会丢失这些本地修改。</p>
|
|
329
330
|
<div class="import-mode-options" aria-label="导入方式说明">
|
|
330
331
|
<section><strong>追加正文</strong><span>保留现有卷章,把新文件解析出的卷章添加到目录末尾。</span></section>
|
|
331
|
-
<section><strong>覆盖正文</strong><span>删除现有正文目录后写入新文件;导入前会保留可恢复的版本快照。</span></section>
|
|
332
|
+
<section><strong>覆盖正文</strong><span>删除现有正文目录后写入新文件;导入前会保留可恢复的版本快照。</span><small id="import-mode-overwrite-permission" class="import-mode-permission hidden">覆盖会影响章节关联资料,需要所有受影响模块均为可编辑。</small></section>
|
|
332
333
|
</div>
|
|
333
334
|
</div>
|
|
334
335
|
<div class="dialog-actions">
|
|
@@ -480,6 +481,17 @@
|
|
|
480
481
|
<div id="versions-list" class="versions-list"></div>
|
|
481
482
|
</dialog>
|
|
482
483
|
|
|
484
|
+
<dialog id="import-history-dialog" class="dialog wide-dialog" aria-labelledby="import-history-title" aria-describedby="import-history-description">
|
|
485
|
+
<div class="dialog-header">
|
|
486
|
+
<div><span class="eyebrow">正文安全</span><h2 id="import-history-title">导入历史与正文恢复</h2></div>
|
|
487
|
+
<button id="import-history-close" class="dialog-close" aria-label="关闭导入历史" type="button">×</button>
|
|
488
|
+
</div>
|
|
489
|
+
<div class="import-history-body">
|
|
490
|
+
<p id="import-history-description" class="import-history-note">每条记录都保存了该次导入开始前的正文。恢复仅作用于分卷、章节标题和正文,并会重新建立分卷和章节;大纲、伏笔、首次登场等章节关联信息不在快照中,无法通过这里恢复。</p>
|
|
491
|
+
<div id="import-history-list" class="entity-history-list" aria-live="polite"></div>
|
|
492
|
+
</div>
|
|
493
|
+
</dialog>
|
|
494
|
+
|
|
483
495
|
<dialog id="users-dialog" class="dialog wide-dialog" aria-labelledby="users-dialog-title">
|
|
484
496
|
<div class="dialog-header"><div><span class="eyebrow">系统管理员</span><h2 id="users-dialog-title">用户管理</h2></div><button id="users-dialog-close" class="dialog-close" aria-label="关闭" type="button">×</button></div>
|
|
485
497
|
<div class="access-dialog-body">
|
|
@@ -611,6 +623,6 @@
|
|
|
611
623
|
</dialog>
|
|
612
624
|
|
|
613
625
|
<div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
|
|
614
|
-
<script type="module" src="/app.js?v=20260722-
|
|
626
|
+
<script type="module" src="/app.js?v=20260722-prose-replacement-permission"></script>
|
|
615
627
|
</body>
|
|
616
628
|
</html>
|
package/dist/public/styles.css
CHANGED
|
@@ -706,6 +706,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
706
706
|
.left-primary-actions { display: flex; grid-column: 1 / -1; width: 100%; align-items: center; gap: 8px; }
|
|
707
707
|
.left-primary-actions .file-button { flex: 1 1 auto; width: auto; min-width: 0; }
|
|
708
708
|
.left-primary-actions #left-panel-toggle { flex: 0 0 30px; width: 30px; height: 30px; }
|
|
709
|
+
.import-history-button { grid-column: 1 / -1; }
|
|
709
710
|
.file-button, .secondary-button { display: grid; place-items: center; min-height: 30px; border: 1px solid var(--line); border-radius: 4px; font-size: 11px; background: transparent; }
|
|
710
711
|
.file-button { container-type: inline-size; overflow: hidden; white-space: nowrap; }
|
|
711
712
|
.import-file-label-compact, .import-file-label-short { display: none; }
|
|
@@ -1418,6 +1419,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
1418
1419
|
.import-mode-options section { display: grid; gap: 5px; min-width: 0; padding: 12px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-soft); }
|
|
1419
1420
|
.import-mode-options strong { color: var(--ink); font-size: 12px; }
|
|
1420
1421
|
.import-mode-options span { color: var(--muted); font-size: 10px; line-height: 1.55; }
|
|
1422
|
+
.import-mode-permission { color: var(--accent-dark); font-size: 9px; line-height: 1.55; }
|
|
1421
1423
|
@media (max-width: 560px) {
|
|
1422
1424
|
.import-mode-options { grid-template-columns: 1fr; }
|
|
1423
1425
|
.import-mode-dialog .dialog-actions { flex-wrap: wrap; }
|
|
@@ -1577,6 +1579,13 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
1577
1579
|
.version-row { display: grid; grid-template-columns: 75px 1fr auto; gap: 14px; padding: 14px 0; border-bottom: 1px solid var(--line); align-items: start; }
|
|
1578
1580
|
.version-row p { margin: 0; color: var(--muted); font-size: 11px; white-space: pre-wrap; max-height: 70px; overflow: hidden; }
|
|
1579
1581
|
.entity-history-list { display: grid; gap: 10px; padding: 18px 24px 28px; max-height: 65vh; overflow: auto; }
|
|
1582
|
+
.import-history-body { min-height: 220px; }
|
|
1583
|
+
.import-history-note { margin: 0; padding: 16px 24px 0; color: var(--muted); font-size: 10px; line-height: 1.65; }
|
|
1584
|
+
.import-history-body .entity-history-list { padding-top: 14px; }
|
|
1585
|
+
.import-history-card header strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1586
|
+
.import-history-card .import-history-kind { flex: none; }
|
|
1587
|
+
.import-history-load-more { justify-self: center; min-height: 34px; padding: 7px 14px; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); font-size: 10px; }
|
|
1588
|
+
.import-history-load-more:hover:not(:disabled) { border-color: var(--accent); color: var(--accent-dark); }
|
|
1580
1589
|
.entity-version-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 18px; padding: 14px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-soft); }
|
|
1581
1590
|
.entity-version-card.is-current { border-color: color-mix(in srgb, var(--accent) 60%, var(--line)); box-shadow: inset 3px 0 var(--accent); }
|
|
1582
1591
|
.entity-version-card header { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
package/dist/store.js
CHANGED
|
@@ -18,6 +18,65 @@ export const versionedEntityTypes = [
|
|
|
18
18
|
"chapter-outline",
|
|
19
19
|
"foreshadow"
|
|
20
20
|
];
|
|
21
|
+
function isRecord(value) {
|
|
22
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
function invalidFileSnapshot() {
|
|
25
|
+
throw new AppError(409, "FILE_VERSION_INVALID", "正文历史快照已损坏,未执行恢复");
|
|
26
|
+
}
|
|
27
|
+
function parseRestorableFileSnapshot(value, workId) {
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(value);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
invalidFileSnapshot();
|
|
34
|
+
}
|
|
35
|
+
if (!isRecord(parsed) || parsed.id !== workId || !Array.isArray(parsed.volumes) || parsed.volumes.length > 10_000) {
|
|
36
|
+
return invalidFileSnapshot();
|
|
37
|
+
}
|
|
38
|
+
let chapterCount = 0;
|
|
39
|
+
let contentLength = 0;
|
|
40
|
+
const volumes = parsed.volumes.map((volumeValue) => {
|
|
41
|
+
if (!isRecord(volumeValue) || typeof volumeValue.title !== "string" || typeof volumeValue.kind !== "string"
|
|
42
|
+
|| typeof volumeValue.source !== "string" || typeof volumeValue.sortOrder !== "number"
|
|
43
|
+
|| !Number.isFinite(volumeValue.sortOrder) || !Array.isArray(volumeValue.chapters)) {
|
|
44
|
+
return invalidFileSnapshot();
|
|
45
|
+
}
|
|
46
|
+
const description = volumeValue.description === undefined ? "" : volumeValue.description;
|
|
47
|
+
const keywords = volumeValue.keywords === undefined ? [] : volumeValue.keywords;
|
|
48
|
+
if (typeof description !== "string" || !Array.isArray(keywords) || !keywords.every((keyword) => typeof keyword === "string")) {
|
|
49
|
+
return invalidFileSnapshot();
|
|
50
|
+
}
|
|
51
|
+
const chapters = volumeValue.chapters.map((chapterValue) => {
|
|
52
|
+
if (!isRecord(chapterValue) || typeof chapterValue.title !== "string" || typeof chapterValue.content !== "string"
|
|
53
|
+
|| typeof chapterValue.sortOrder !== "number" || !Number.isFinite(chapterValue.sortOrder)
|
|
54
|
+
|| !["正文", "设定", "作者的话", "其他"].includes(String(chapterValue.chapterType))) {
|
|
55
|
+
return invalidFileSnapshot();
|
|
56
|
+
}
|
|
57
|
+
chapterCount += 1;
|
|
58
|
+
contentLength += chapterValue.content.length;
|
|
59
|
+
if (chapterCount > 100_000 || contentLength > 20_000_000)
|
|
60
|
+
return invalidFileSnapshot();
|
|
61
|
+
return {
|
|
62
|
+
title: chapterValue.title,
|
|
63
|
+
content: chapterValue.content,
|
|
64
|
+
sortOrder: chapterValue.sortOrder,
|
|
65
|
+
chapterType: chapterValue.chapterType
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
title: volumeValue.title,
|
|
70
|
+
kind: volumeValue.kind,
|
|
71
|
+
source: volumeValue.source,
|
|
72
|
+
description,
|
|
73
|
+
keywords: [...keywords],
|
|
74
|
+
sortOrder: volumeValue.sortOrder,
|
|
75
|
+
chapters
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
return { volumes };
|
|
79
|
+
}
|
|
21
80
|
function requiredString(row, key) {
|
|
22
81
|
return String(row[key] ?? "");
|
|
23
82
|
}
|
|
@@ -655,7 +714,7 @@ export class Store {
|
|
|
655
714
|
.all(`SELECT version.id, version.work_id, version.file_name, version.file_type, version.word_count, version.paragraph_count,
|
|
656
715
|
version.warnings_json, version.created_at, user.display_name AS actor_display_name, user.username AS actor_username
|
|
657
716
|
FROM file_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
|
|
658
|
-
WHERE version.work_id = ? ORDER BY version.created_at DESC`, workId)
|
|
717
|
+
WHERE version.work_id = ? ORDER BY version.created_at DESC, version.id DESC`, workId)
|
|
659
718
|
.map((row) => ({
|
|
660
719
|
id: requiredString(row, "id"),
|
|
661
720
|
workId: requiredString(row, "work_id"),
|
|
@@ -674,7 +733,7 @@ export class Store {
|
|
|
674
733
|
const rows = this.db.all(`SELECT version.id, version.work_id, version.file_name, version.file_type, version.word_count, version.paragraph_count,
|
|
675
734
|
version.warnings_json, version.created_at, user.display_name AS actor_display_name, user.username AS actor_username
|
|
676
735
|
FROM file_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
|
|
677
|
-
WHERE version.work_id = ? ORDER BY version.created_at DESC${page.sql}`, workId, ...page.params);
|
|
736
|
+
WHERE version.work_id = ? ORDER BY version.created_at DESC, version.id DESC${page.sql}`, workId, ...page.params);
|
|
678
737
|
return paginated(rows.map((row) => ({
|
|
679
738
|
id: requiredString(row, "id"),
|
|
680
739
|
workId: requiredString(row, "work_id"),
|
|
@@ -692,8 +751,7 @@ export class Store {
|
|
|
692
751
|
const version = this.db.get("SELECT * FROM file_versions WHERE id = ? AND work_id = ?", fileVersionId, workId);
|
|
693
752
|
if (!version)
|
|
694
753
|
throw notFound("文件版本");
|
|
695
|
-
const
|
|
696
|
-
const volumes = Array.isArray(snapshot.volumes) ? snapshot.volumes : [];
|
|
754
|
+
const { volumes } = parseRestorableFileSnapshot(requiredString(version, "snapshot_json"), workId);
|
|
697
755
|
return this.db.transaction(() => {
|
|
698
756
|
const current = this.getWork(workId);
|
|
699
757
|
this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
|
|
@@ -714,20 +772,16 @@ export class Store {
|
|
|
714
772
|
this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
|
|
715
773
|
for (const volume of volumes) {
|
|
716
774
|
const volumeId = id("volume");
|
|
717
|
-
const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
|
|
718
775
|
this.insertVolumeWithId(workId, volumeId, {
|
|
719
|
-
title:
|
|
720
|
-
kind:
|
|
721
|
-
source:
|
|
722
|
-
description:
|
|
723
|
-
keywords:
|
|
724
|
-
sortOrder:
|
|
776
|
+
title: volume.title,
|
|
777
|
+
kind: volume.kind,
|
|
778
|
+
source: volume.source,
|
|
779
|
+
description: volume.description,
|
|
780
|
+
keywords: volume.keywords,
|
|
781
|
+
sortOrder: volume.sortOrder
|
|
725
782
|
}, "restore", fileVersionId, `恢复文件版本 ${fileVersionId}`);
|
|
726
|
-
for (const chapter of chapters) {
|
|
727
|
-
|
|
728
|
-
? String(chapter.chapterType)
|
|
729
|
-
: "正文");
|
|
730
|
-
this.insertChapter(workId, volumeId, String(chapter.title ?? "未命名章节"), String(chapter.content ?? ""), Number(chapter.sortOrder ?? 0), "restore", fileVersionId, chapterType);
|
|
783
|
+
for (const chapter of volume.chapters) {
|
|
784
|
+
this.insertChapter(workId, volumeId, chapter.title, chapter.content, chapter.sortOrder, "restore", fileVersionId, chapter.chapterType);
|
|
731
785
|
}
|
|
732
786
|
}
|
|
733
787
|
this.db.run("UPDATE works SET version_no = version_no + 1, updated_at = ? WHERE id = ?", timestamp, workId);
|