@musnows/scriverse 0.3.8 → 0.3.10
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 +270 -85
- package/dist/app.js.map +1 -1
- package/dist/cli-contract.js +4 -4
- package/dist/cli-contract.js.map +1 -1
- package/dist/cli-core.js +37 -6
- package/dist/cli-core.js.map +1 -1
- package/dist/database.js +41 -0
- package/dist/database.js.map +1 -1
- package/dist/public/app.js +455 -122
- package/dist/public/index.html +40 -4
- package/dist/public/styles.css +55 -12
- package/dist/public/work-permissions.d.ts +14 -0
- package/dist/public/work-permissions.js +71 -0
- package/dist/store.js +432 -112
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +214 -32
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/dist/work-permissions.js +128 -0
- package/dist/work-permissions.js.map +1 -0
- package/package.json +1 -1
package/dist/public/app.js
CHANGED
|
@@ -19,6 +19,7 @@ import { splitRelationshipKeywordInput, splitRelationshipKeywords, uniqueRelatio
|
|
|
19
19
|
import { tokenizeVisibleSpaces } from "/whitespace-visualization.js?v=20260718-visible-whitespace";
|
|
20
20
|
import { buildRaceForest, eligibleRaceParents, racePathLabel } from "/race-hierarchy.js?v=20260721-race-hierarchy";
|
|
21
21
|
import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
|
|
22
|
+
import { WORK_PERMISSION_MODULES, canReadUiModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260722-module-permissions";
|
|
22
23
|
|
|
23
24
|
const state = {
|
|
24
25
|
user: null,
|
|
@@ -81,18 +82,56 @@ function analysisTaskStatusLabel(status) {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
function canEditWork(work = state.work) {
|
|
84
|
-
return
|
|
85
|
+
return WORK_PERMISSION_MODULES.some((item) => canWriteUiModule(work, item.uiModule));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function canEditProse(work = state.work) {
|
|
89
|
+
return canWriteUiModule(work, "editor");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function canManageWork(work = state.work) {
|
|
93
|
+
return ["admin", "owner"].includes(String(work?.accessRole));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function canEditModule(module, work = state.work) {
|
|
97
|
+
return canWriteUiModule(work, module);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function canReadModule(module, work = state.work) {
|
|
101
|
+
return canReadUiModule(work, module);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function canReadAggregateContent(work = state.work) {
|
|
105
|
+
return ["editor", "settings", "characters", "races", "organizations", "timeline", "relationships", "outlines"]
|
|
106
|
+
.every((module) => canReadModule(module, work));
|
|
85
107
|
}
|
|
86
108
|
|
|
87
109
|
function applyWorkAccessMode() {
|
|
88
110
|
const viewOnly = Boolean(state.work) && !canEditWork();
|
|
111
|
+
const proseReadOnly = Boolean(state.work) && !canEditProse();
|
|
112
|
+
const proseHidden = Boolean(state.work) && !canReadModule("editor");
|
|
113
|
+
const aiHidden = Boolean(state.work) && !canReadModule("tasks");
|
|
114
|
+
const aiReadOnly = Boolean(state.work) && !canEditModule("tasks");
|
|
115
|
+
const moduleReadOnly = Boolean(state.work) && !canEditModule(state.module);
|
|
89
116
|
$("#app").classList.toggle("view-only-mode", viewOnly);
|
|
90
|
-
|
|
91
|
-
$("#
|
|
92
|
-
$("#
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
117
|
+
$("#app").classList.toggle("prose-read-only-mode", proseReadOnly);
|
|
118
|
+
$("#app").classList.toggle("prose-hidden-mode", proseHidden);
|
|
119
|
+
$("#app").classList.toggle("ai-hidden-mode", aiHidden);
|
|
120
|
+
document.body.classList.toggle("work-viewer-mode", moduleReadOnly);
|
|
121
|
+
for (const item of WORK_PERMISSION_MODULES) {
|
|
122
|
+
const button = $(`#module-nav [data-module="${item.uiModule}"]`);
|
|
123
|
+
if (button) button.classList.toggle("permission-hidden", !canReadModule(item.uiModule));
|
|
124
|
+
}
|
|
125
|
+
$("#module-nav [data-work-settings]").classList.toggle("permission-hidden", Boolean(state.work) && !canManageWork());
|
|
126
|
+
$(".ai-panel").classList.toggle("permission-hidden", aiHidden);
|
|
127
|
+
$("#chapter-title").readOnly = proseReadOnly;
|
|
128
|
+
$("#chapter-content").readOnly = proseReadOnly;
|
|
129
|
+
$("#chapter-title").setAttribute("aria-readonly", String(proseReadOnly));
|
|
130
|
+
$("#chapter-content").setAttribute("aria-readonly", String(proseReadOnly));
|
|
131
|
+
$("#ai-prompt").readOnly = aiReadOnly;
|
|
132
|
+
$("#ai-prompt").setAttribute("aria-readonly", String(aiReadOnly));
|
|
133
|
+
$("#ai-send").classList.toggle("permission-hidden", aiReadOnly);
|
|
134
|
+
if (proseReadOnly) {
|
|
96
135
|
cancelChapterAutoSave();
|
|
97
136
|
state.dirty = false;
|
|
98
137
|
}
|
|
@@ -144,6 +183,8 @@ const panelLayoutStorageKey = "ai-novel-panel-layout-v1";
|
|
|
144
183
|
const panelLayoutDefaults = Object.freeze({ leftWidth: 280, aiWidth: 360, leftCollapsed: false, aiCollapsed: false });
|
|
145
184
|
let restoringPageRoute = true;
|
|
146
185
|
let memberDialogWork = null;
|
|
186
|
+
let memberDialogMembers = [];
|
|
187
|
+
let memberDialogDirectory = [];
|
|
147
188
|
let onboardingStep = 0;
|
|
148
189
|
let onboardingAutoScheduled = false;
|
|
149
190
|
let onboardingPositionFrame = null;
|
|
@@ -492,7 +533,8 @@ function showEntityEditorPage(type) {
|
|
|
492
533
|
}
|
|
493
534
|
|
|
494
535
|
function markEntityEditorDirty() {
|
|
495
|
-
|
|
536
|
+
const module = entityEditorType === "setting" ? "settings" : "characters";
|
|
537
|
+
if (entityEditorType && canEditModule(module)) entityEditorDirty = true;
|
|
496
538
|
}
|
|
497
539
|
|
|
498
540
|
function confirmEntityEditorDiscard(message) {
|
|
@@ -1424,15 +1466,75 @@ applyTypographySettings(typographySettings);
|
|
|
1424
1466
|
applyColorTheme(currentColorTheme());
|
|
1425
1467
|
applyPanelLayout();
|
|
1426
1468
|
|
|
1469
|
+
function optimisticVersionForPath(path) {
|
|
1470
|
+
const normalizedPath = String(path).split("?")[0];
|
|
1471
|
+
const find = (items, id) => items.find((item) => String(item?.id ?? item?.chapterId ?? "") === id)?.versionNo;
|
|
1472
|
+
const workMatch = normalizedPath.match(/^\/api\/works\/([^/]+)(?:\/(?:cover|import|file-versions\/[^/]+\/restore))?$/u);
|
|
1473
|
+
if (workMatch) {
|
|
1474
|
+
const workId = decodeURIComponent(workMatch[1]);
|
|
1475
|
+
return state.works.find((item) => item.id === workId)?.versionNo ?? (state.work?.id === workId ? state.work.versionNo : undefined);
|
|
1476
|
+
}
|
|
1477
|
+
const resourceMatch = normalizedPath.match(/^\/api\/(volumes|chapters|settings|races|organizations|timeline-tracks|timeline|relationships|foreshadows|characters|character-sections)\/([^/]+)(?:\/(?:restore|move|split))?$/u);
|
|
1478
|
+
if (resourceMatch) {
|
|
1479
|
+
const resourceId = decodeURIComponent(resourceMatch[2]);
|
|
1480
|
+
const collection = {
|
|
1481
|
+
settings: state.settings,
|
|
1482
|
+
races: state.races,
|
|
1483
|
+
organizations: state.organizations,
|
|
1484
|
+
"timeline-tracks": state.timelineTracks,
|
|
1485
|
+
characters: state.characters
|
|
1486
|
+
}[resourceMatch[1]] ?? [];
|
|
1487
|
+
if (resourceMatch[1] === "chapters" && state.chapter?.id === resourceId) return state.chapter.versionNo;
|
|
1488
|
+
if (resourceMatch[1] === "volumes") return find(state.work?.volumes ?? [], resourceId);
|
|
1489
|
+
if (resourceMatch[1] === "character-sections") return find(characterEditorSections, resourceId);
|
|
1490
|
+
if (resourceMatch[1] === "characters" && characterEditorItem?.id === resourceId) return characterEditorItem.versionNo;
|
|
1491
|
+
return find(collection, resourceId);
|
|
1492
|
+
}
|
|
1493
|
+
const outlineMatch = normalizedPath.match(/^\/api\/chapters\/([^/]+)\/outline$/u);
|
|
1494
|
+
if (outlineMatch) return find(state.outlines ?? [], decodeURIComponent(outlineMatch[1]));
|
|
1495
|
+
const entityRestoreMatch = normalizedPath.match(/^\/api\/entity-versions\/(work|volume|setting|race|organization|timeline-track|timeline-event|relationship|chapter-outline|foreshadow)\/([^/]+)\/restore$/u);
|
|
1496
|
+
if (entityRestoreMatch) {
|
|
1497
|
+
const entityType = entityRestoreMatch[1];
|
|
1498
|
+
const entityId = decodeURIComponent(entityRestoreMatch[2]);
|
|
1499
|
+
if (entityType === "work") return state.works.find((item) => item.id === entityId)?.versionNo ?? (state.work?.id === entityId ? state.work.versionNo : undefined);
|
|
1500
|
+
if (entityType === "volume") return find(state.work?.volumes ?? [], entityId);
|
|
1501
|
+
if (entityType === "chapter-outline") return find(state.outlines ?? [], entityId);
|
|
1502
|
+
const collection = {
|
|
1503
|
+
setting: state.settings,
|
|
1504
|
+
race: state.races,
|
|
1505
|
+
organization: state.organizations,
|
|
1506
|
+
"timeline-track": state.timelineTracks
|
|
1507
|
+
}[entityType] ?? [];
|
|
1508
|
+
return find(collection, entityId);
|
|
1509
|
+
}
|
|
1510
|
+
return undefined;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function attachOptimisticVersion(path, method, body) {
|
|
1514
|
+
if (!["PATCH", "PUT", "DELETE", "POST"].includes(method)) return body;
|
|
1515
|
+
if (body instanceof FormData) {
|
|
1516
|
+
if (!body.has("expectedVersionNo")) {
|
|
1517
|
+
const versionNo = optimisticVersionForPath(path);
|
|
1518
|
+
if (Number.isInteger(versionNo) && versionNo > 0) body.append("expectedVersionNo", String(versionNo));
|
|
1519
|
+
}
|
|
1520
|
+
return body;
|
|
1521
|
+
}
|
|
1522
|
+
const currentBody = body && typeof body === "object" && !Array.isArray(body) ? body : {};
|
|
1523
|
+
if (currentBody.expectedVersionNo !== undefined) return currentBody;
|
|
1524
|
+
const versionNo = optimisticVersionForPath(path);
|
|
1525
|
+
return Number.isInteger(versionNo) && versionNo > 0 ? { ...currentBody, expectedVersionNo: versionNo } : body;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1427
1528
|
async function api(path, options = {}) {
|
|
1428
1529
|
const method = String(options.method ?? "GET").toUpperCase();
|
|
1530
|
+
const body = attachOptimisticVersion(path, method, options.body);
|
|
1429
1531
|
const headers = { ...(options.headers ?? {}) };
|
|
1430
1532
|
if (state.csrfToken && !["GET", "HEAD", "OPTIONS"].includes(method)) headers["X-CSRF-Token"] = state.csrfToken;
|
|
1431
|
-
if (!(
|
|
1432
|
-
const response = await fetch(path,
|
|
1533
|
+
if (!(body instanceof FormData)) headers["Content-Type"] = "application/json";
|
|
1534
|
+
const response = await fetch(path, body instanceof FormData ? { ...options, body, headers } : {
|
|
1433
1535
|
...options,
|
|
1434
1536
|
headers,
|
|
1435
|
-
body:
|
|
1537
|
+
body: body && typeof body !== "string" ? JSON.stringify(body) : body
|
|
1436
1538
|
});
|
|
1437
1539
|
if (!response.ok) {
|
|
1438
1540
|
const payload = await response.json().catch(() => ({ error: { message: `请求失败:${response.status}` } }));
|
|
@@ -1616,7 +1718,7 @@ function cancelChapterAutoSave() {
|
|
|
1616
1718
|
}
|
|
1617
1719
|
|
|
1618
1720
|
function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
|
|
1619
|
-
if (!state.chapter || !
|
|
1721
|
+
if (!state.chapter || !canEditProse()) return;
|
|
1620
1722
|
cancelChapterAutoSave();
|
|
1621
1723
|
setSaveState("等待自动保存", true);
|
|
1622
1724
|
chapterAutoSaveTimer = setTimeout(() => {
|
|
@@ -1626,7 +1728,7 @@ function scheduleChapterAutoSave(delay = chapterAutoSaveDelay) {
|
|
|
1626
1728
|
}
|
|
1627
1729
|
|
|
1628
1730
|
async function persistChapter({ automatic = false } = {}) {
|
|
1629
|
-
if (!
|
|
1731
|
+
if (!canEditProse()) return null;
|
|
1630
1732
|
if (!state.chapter) {
|
|
1631
1733
|
if (!automatic) toast("请先选择章节", "error");
|
|
1632
1734
|
return null;
|
|
@@ -1694,6 +1796,19 @@ function confirmDiscardChanges(message = "当前章节有未保存修改,继
|
|
|
1694
1796
|
return window.confirm(message);
|
|
1695
1797
|
}
|
|
1696
1798
|
|
|
1799
|
+
function chooseExistingWorkImportMode(file) {
|
|
1800
|
+
const dialog = $("#import-mode-dialog");
|
|
1801
|
+
$("#import-mode-file-summary").textContent = `文件:${file.name};当前作品:《${state.work.title}》`;
|
|
1802
|
+
$("#import-mode-unsaved-warning").classList.toggle("hidden", !state.dirty);
|
|
1803
|
+
dialog.returnValue = "cancel";
|
|
1804
|
+
dialog.showModal();
|
|
1805
|
+
return new Promise((resolve) => {
|
|
1806
|
+
dialog.addEventListener("close", () => {
|
|
1807
|
+
resolve(["append", "overwrite"].includes(dialog.returnValue) ? dialog.returnValue : null);
|
|
1808
|
+
}, { once: true });
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1697
1812
|
function updateDocumentTitle(work = null) {
|
|
1698
1813
|
const workTitle = String(work?.title ?? "").trim();
|
|
1699
1814
|
document.title = workTitle ? `${workTitle} · 叙界` : platformDocumentTitle;
|
|
@@ -1814,13 +1929,14 @@ function captureSettingsReturnContext() {
|
|
|
1814
1929
|
function renderSettingsHub() {
|
|
1815
1930
|
const hasWork = Boolean(state.work);
|
|
1816
1931
|
const canManageWork = hasWork && ["admin", "owner"].includes(String(state.work.accessRole));
|
|
1932
|
+
const canReadAggregate = hasWork && canReadAggregateContent();
|
|
1817
1933
|
const isAdmin = state.user?.role === "admin";
|
|
1818
1934
|
$("#platform-ai-button").classList.toggle("hidden", !isAdmin);
|
|
1819
1935
|
$("#user-management-button").classList.toggle("hidden", !isAdmin);
|
|
1820
1936
|
$("#platform-ui-settings-button").classList.toggle("hidden", !isAdmin);
|
|
1821
1937
|
$("#collaboration-button").disabled = !canManageWork;
|
|
1822
|
-
$("#top-search-button").disabled = !
|
|
1823
|
-
$("#export-button").disabled = !
|
|
1938
|
+
$("#top-search-button").disabled = !canReadAggregate;
|
|
1939
|
+
$("#export-button").disabled = !canReadAggregate;
|
|
1824
1940
|
$("#settings-return").textContent = settingsReturnContext?.view === "shelf" || !hasWork ? "返回书架" : "返回当前作品";
|
|
1825
1941
|
$("#settings-work-note").textContent = hasWork
|
|
1826
1942
|
? `当前作品:《${state.work.title}》。导出将作用于这部作品。`
|
|
@@ -1882,61 +1998,99 @@ async function openPlatformUiSettingsDialog() {
|
|
|
1882
1998
|
}
|
|
1883
1999
|
}
|
|
1884
2000
|
|
|
2001
|
+
function renderMemberPermissionGrid(value) {
|
|
2002
|
+
const permissions = normalizeModulePermissions(value, "custom");
|
|
2003
|
+
$("#member-permission-grid").innerHTML = WORK_PERMISSION_MODULES.map((item) => `<label class="member-permission-row">
|
|
2004
|
+
<span>${esc(item.label)}</span>
|
|
2005
|
+
<select data-member-permission="${esc(item.id)}" aria-label="${esc(item.label)}权限">
|
|
2006
|
+
<option value="none" ${permissions[item.id] === "none" ? "selected" : ""}>无权限</option>
|
|
2007
|
+
<option value="read" ${permissions[item.id] === "read" ? "selected" : ""}>只读</option>
|
|
2008
|
+
<option value="write" ${permissions[item.id] === "write" ? "selected" : ""}>可编辑</option>
|
|
2009
|
+
</select>
|
|
2010
|
+
</label>`).join("");
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
function selectedMemberPermissions() {
|
|
2014
|
+
const permissions = emptyModulePermissions();
|
|
2015
|
+
$("#member-permission-grid").querySelectorAll("[data-member-permission]").forEach((select) => {
|
|
2016
|
+
permissions[select.dataset.memberPermission] = select.value;
|
|
2017
|
+
});
|
|
2018
|
+
return permissions;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
function selectMemberForConfiguration(userId) {
|
|
2022
|
+
const fieldset = $("#member-permission-fieldset");
|
|
2023
|
+
const member = memberDialogMembers.find((item) => item.userId === userId);
|
|
2024
|
+
fieldset.disabled = !userId;
|
|
2025
|
+
renderMemberPermissionGrid(member?.permissions ?? emptyModulePermissions());
|
|
2026
|
+
$("#member-permission-submit").textContent = member ? "更新模块权限" : "添加成员并保存";
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
function renderMemberSelector(selectedUserId = "") {
|
|
2030
|
+
const ownerIds = new Set(memberDialogMembers.filter((member) => member.role === "owner").map((member) => member.userId));
|
|
2031
|
+
const people = new Map(memberDialogDirectory.map((user) => [user.userId, user]));
|
|
2032
|
+
for (const member of memberDialogMembers) if (member.role !== "owner") people.set(member.userId, member);
|
|
2033
|
+
const options = [...people.values()].filter((user) => !ownerIds.has(user.userId));
|
|
2034
|
+
$("#member-user-select").innerHTML = options.length
|
|
2035
|
+
? `<option value="">选择用户</option>${options.map((user) => {
|
|
2036
|
+
const existing = memberDialogMembers.some((member) => member.userId === user.userId && member.role !== "owner");
|
|
2037
|
+
return `<option value="${esc(user.userId)}" ${selectedUserId === user.userId ? "selected" : ""}>${esc(user.displayName)} · @${esc(user.username)}${existing ? " · 已加入" : ""}</option>`;
|
|
2038
|
+
}).join("")}`
|
|
2039
|
+
: '<option value="">没有可配置的用户</option>';
|
|
2040
|
+
$("#member-user-select").disabled = !options.length;
|
|
2041
|
+
selectMemberForConfiguration(selectedUserId);
|
|
2042
|
+
}
|
|
2043
|
+
|
|
1885
2044
|
function renderMembers(members) {
|
|
2045
|
+
memberDialogMembers = members;
|
|
1886
2046
|
const work = memberDialogWork ?? state.work;
|
|
1887
2047
|
const canManage = ["admin", "owner"].includes(String(work?.accessRole));
|
|
1888
|
-
$("#members-list").innerHTML = members.map((member) =>
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
2048
|
+
$("#members-list").innerHTML = members.map((member) => {
|
|
2049
|
+
const descriptionId = `member-role-summary-${member.userId}`;
|
|
2050
|
+
return `<article class="access-row">
|
|
2051
|
+
<div class="access-person">${userAvatarHtml(member, "access-avatar")}<div class="access-person-copy"><strong>${esc(member.displayName)} · @${esc(member.username)}</strong><small id="${esc(descriptionId)}">${member.role === "owner" ? "作品创建者 · 拥有全部模块管理权限" : esc(permissionSummary(member.permissions))}${member.status === "disabled" ? " · 已停用" : ""}</small></div></div>
|
|
2052
|
+
${member.role === "owner" || !canManage ? "<span></span>" : `<button type="button" data-configure-member="${esc(member.userId)}" aria-describedby="${esc(descriptionId)}">配置权限</button>`}
|
|
2053
|
+
${member.role === "owner" || !canManage ? "<span></span>" : `<button type="button" data-remove-member="${esc(member.userId)}">移除</button>`}
|
|
2054
|
+
</article>`;
|
|
2055
|
+
}).join("");
|
|
1893
2056
|
bindUserAvatarFallbacks($("#members-list"));
|
|
1894
|
-
$("#members-list").querySelectorAll("[data-member
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
const updated = await api(`/api/works/${encodeURIComponent(work.id)}/members/${encodeURIComponent(select.dataset.memberRole)}`, { method: "PATCH", body: { role: select.value } });
|
|
1899
|
-
renderMembers(updated);
|
|
1900
|
-
toast("成员权限已更新");
|
|
1901
|
-
} catch (error) {
|
|
1902
|
-
select.value = previousRole;
|
|
1903
|
-
toast(error.message, "error");
|
|
1904
|
-
}
|
|
2057
|
+
$("#members-list").querySelectorAll("[data-configure-member]").forEach((button) => button.addEventListener("click", () => {
|
|
2058
|
+
$("#member-user-select").value = button.dataset.configureMember;
|
|
2059
|
+
selectMemberForConfiguration(button.dataset.configureMember);
|
|
2060
|
+
$("#member-user-select").focus();
|
|
1905
2061
|
}));
|
|
1906
2062
|
$("#members-list").querySelectorAll("[data-remove-member]").forEach((button) => button.addEventListener("click", async () => {
|
|
1907
2063
|
if (!work) return;
|
|
1908
2064
|
try {
|
|
1909
2065
|
const updated = await api(`/api/works/${encodeURIComponent(work.id)}/members/${encodeURIComponent(button.dataset.removeMember)}`, { method: "DELETE" });
|
|
1910
2066
|
renderMembers(updated);
|
|
1911
|
-
|
|
2067
|
+
renderMemberSelector();
|
|
1912
2068
|
toast("协作者已移除");
|
|
1913
2069
|
} catch (error) { toast(error.message, "error"); }
|
|
1914
2070
|
}));
|
|
1915
2071
|
}
|
|
1916
2072
|
|
|
1917
|
-
async function fillMemberCandidates(members) {
|
|
1918
|
-
const directory = await api("/api/users/directory");
|
|
1919
|
-
const memberIds = new Set(members.map((member) => member.userId));
|
|
1920
|
-
const candidates = directory.filter((user) => !memberIds.has(user.userId));
|
|
1921
|
-
$("#member-user-select").innerHTML = candidates.length
|
|
1922
|
-
? `<option value="">选择用户</option>${candidates.map((user) => `<option value="${esc(user.userId)}">${esc(user.displayName)} · @${esc(user.username)}</option>`).join("")}`
|
|
1923
|
-
: '<option value="">没有可邀请的用户</option>';
|
|
1924
|
-
$("#member-user-select").disabled = !candidates.length;
|
|
1925
|
-
}
|
|
1926
|
-
|
|
1927
2073
|
async function openMembersDialog(targetWork = state.work) {
|
|
1928
2074
|
if (!targetWork) return;
|
|
1929
2075
|
memberDialogWork = targetWork;
|
|
1930
2076
|
const canManage = ["admin", "owner"].includes(String(targetWork.accessRole));
|
|
1931
2077
|
$("#members-dialog-eyebrow").textContent = `作品权限 · 《${targetWork.title}》`;
|
|
1932
|
-
$("#members-dialog-title").textContent = "
|
|
2078
|
+
$("#members-dialog-title").textContent = "成员模块权限";
|
|
1933
2079
|
$("#members-list").innerHTML = '<p class="empty-state">正在读取成员……</p>';
|
|
1934
|
-
$("#member-
|
|
2080
|
+
$("#member-permission-form").classList.toggle("hidden", !canManage);
|
|
2081
|
+
memberDialogMembers = [];
|
|
2082
|
+
memberDialogDirectory = [];
|
|
2083
|
+
renderMemberPermissionGrid(emptyModulePermissions());
|
|
2084
|
+
$("#member-permission-fieldset").disabled = true;
|
|
1935
2085
|
$("#members-dialog").showModal();
|
|
1936
2086
|
try {
|
|
1937
|
-
const members = await
|
|
2087
|
+
const [members, directory] = await Promise.all([
|
|
2088
|
+
api(`/api/works/${encodeURIComponent(targetWork.id)}/members`),
|
|
2089
|
+
canManage ? api("/api/users/directory") : Promise.resolve([])
|
|
2090
|
+
]);
|
|
2091
|
+
memberDialogDirectory = directory;
|
|
1938
2092
|
renderMembers(members);
|
|
1939
|
-
if (canManage)
|
|
2093
|
+
if (canManage) renderMemberSelector();
|
|
1940
2094
|
} catch (error) { $("#members-dialog").close(); toast(error.message, "error"); }
|
|
1941
2095
|
}
|
|
1942
2096
|
|
|
@@ -2090,9 +2244,9 @@ function renderShelf() {
|
|
|
2090
2244
|
<span class="book-cover-fallback">${esc(Array.from(work.title)[0] ?? "书")}</span>
|
|
2091
2245
|
${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面">` : ""}
|
|
2092
2246
|
</span>
|
|
2093
|
-
<span class="book-info"><strong>${esc(work.title)}</strong><small>${esc(work.author || "未署名")} · ${work.chapterCount} 章 · ${work.wordCount} 字</small><span>${esc(work.description || "尚未填写作品简介")}</span><em class="book-access-badge">${work.accessRole === "viewer" ? "
|
|
2247
|
+
<span class="book-info"><strong>${esc(work.title)}</strong><small>${esc(work.author || "未署名")} · ${work.chapterCount} 章 · ${work.wordCount} 字</small><span>${esc(work.description || "尚未填写作品简介")}</span><em class="book-access-badge">${work.accessRole === "viewer" ? "全部只读" : work.accessRole === "settings-editor" ? "设定协作" : work.accessRole === "editor" ? "全部可编辑" : work.accessRole === "custom" ? "自定义权限" : work.accessRole === "admin" ? "管理员访问" : "我的作品"}</em></span>
|
|
2094
2248
|
</button>
|
|
2095
|
-
${
|
|
2249
|
+
${canManageWork(work) ? `<button class="book-card-settings" type="button" data-edit-work="${esc(work.id)}" aria-label="作品设置" title="作品设置">设置</button>` : ""}
|
|
2096
2250
|
</article>`).join("")}
|
|
2097
2251
|
<button class="book-card book-add-card" id="book-add-card" type="button" aria-label="新建作品" data-testid="book-add-card"><span>+</span><strong>新建作品</strong><small>从零开始或导入 TXT / DOCX</small></button>`;
|
|
2098
2252
|
shelf.querySelectorAll("[data-open-work]").forEach((button) => button.addEventListener("click", () => selectWork(button.dataset.openWork)));
|
|
@@ -2140,16 +2294,18 @@ async function selectWork(workId, preferredChapterId = null) {
|
|
|
2140
2294
|
settingsReturnContext = null;
|
|
2141
2295
|
state.work = nextWork;
|
|
2142
2296
|
state.chapter = null;
|
|
2297
|
+
if (!canReadModule(state.module)) state.module = firstReadableUiModule(state.work) ?? "editor";
|
|
2143
2298
|
applyWorkAccessMode();
|
|
2144
2299
|
updateDocumentTitle(state.work);
|
|
2145
2300
|
$("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
|
|
2146
|
-
$("#top-search-button").disabled =
|
|
2301
|
+
$("#top-search-button").disabled = !canReadAggregateContent();
|
|
2147
2302
|
renderTree();
|
|
2148
2303
|
const chapters = state.work.volumes.flatMap((volume) => volume.chapters);
|
|
2149
2304
|
const targetChapter = chapters.find((chapter) => chapter.id === preferredChapterId) ?? chapters[0];
|
|
2150
2305
|
if (state.module === "editor" && preferredChapterId) await selectChapter(preferredChapterId);
|
|
2151
2306
|
else if (state.module === "editor" && targetChapter) await selectChapter(targetChapter.id);
|
|
2152
|
-
else if (state.module === "editor") showWelcome(true);
|
|
2307
|
+
else if (state.module === "editor" && canReadModule("editor")) showWelcome(true);
|
|
2308
|
+
else if (!canReadModule(state.module)) showWelcome(true);
|
|
2153
2309
|
else await showModule(state.module);
|
|
2154
2310
|
return true;
|
|
2155
2311
|
}
|
|
@@ -2177,7 +2333,7 @@ function renderTree() {
|
|
|
2177
2333
|
renderTree();
|
|
2178
2334
|
});
|
|
2179
2335
|
button.addEventListener("contextmenu", (event) => {
|
|
2180
|
-
if (!
|
|
2336
|
+
if (!canEditProse()) return;
|
|
2181
2337
|
event.preventDefault();
|
|
2182
2338
|
openVolumeDialog(state.work.volumes.find((volume) => volume.id === button.dataset.volumeToggle));
|
|
2183
2339
|
});
|
|
@@ -2185,7 +2341,7 @@ function renderTree() {
|
|
|
2185
2341
|
$("#novel-tree").querySelectorAll("[data-chapter-id]").forEach((button) => {
|
|
2186
2342
|
button.addEventListener("click", () => selectChapter(button.dataset.chapterId));
|
|
2187
2343
|
button.addEventListener("contextmenu", (event) => {
|
|
2188
|
-
if (!
|
|
2344
|
+
if (!canEditProse()) return;
|
|
2189
2345
|
event.preventDefault();
|
|
2190
2346
|
openChapterTypeMenu(button.dataset.chapterId, event.clientX, event.clientY);
|
|
2191
2347
|
});
|
|
@@ -2219,6 +2375,7 @@ async function selectChapter(chapterId) {
|
|
|
2219
2375
|
state.chapter = await api(`/api/chapters/${chapterId}`);
|
|
2220
2376
|
lastSavedChapterSnapshot = { chapterId: state.chapter.id, title: state.chapter.title, content: state.chapter.content };
|
|
2221
2377
|
state.module = "editor";
|
|
2378
|
+
applyWorkAccessMode();
|
|
2222
2379
|
markActiveModule("editor");
|
|
2223
2380
|
$("#welcome-view").classList.add("hidden");
|
|
2224
2381
|
$("#module-view").classList.add("hidden");
|
|
@@ -2235,7 +2392,7 @@ async function selectChapter(chapterId) {
|
|
|
2235
2392
|
scheduleChapterLineNumbers();
|
|
2236
2393
|
$("#chapter-insight").classList.add("hidden");
|
|
2237
2394
|
updateChapterStats();
|
|
2238
|
-
if (!
|
|
2395
|
+
if (!canEditProse()) setSaveState("正文只读");
|
|
2239
2396
|
else if (spacingChanged) scheduleChapterAutoSave(120);
|
|
2240
2397
|
else setSaveState("已保存");
|
|
2241
2398
|
renderTree();
|
|
@@ -2293,10 +2450,18 @@ const moduleMeta = {
|
|
|
2293
2450
|
|
|
2294
2451
|
async function showModule(module) {
|
|
2295
2452
|
if (!state.work) return showWelcome();
|
|
2296
|
-
if (!
|
|
2453
|
+
if (!canReadModule(module)) {
|
|
2454
|
+
const fallback = firstReadableUiModule(state.work);
|
|
2455
|
+
if (!fallback) {
|
|
2456
|
+
showWelcome(true);
|
|
2457
|
+
return toast("当前账户尚未获授权访问任何作品模块", "error");
|
|
2458
|
+
}
|
|
2459
|
+
module = fallback;
|
|
2460
|
+
}
|
|
2297
2461
|
if (module !== "editor" && state.module === "editor" && !confirmDiscardChanges()) return;
|
|
2298
2462
|
if (module !== "editor" && state.module === "editor" && state.dirty) setSaveState("已放弃修改");
|
|
2299
2463
|
state.module = module;
|
|
2464
|
+
applyWorkAccessMode();
|
|
2300
2465
|
markActiveModule(module);
|
|
2301
2466
|
if (module === "editor") {
|
|
2302
2467
|
if (state.chapter) await selectChapter(state.chapter.id);
|
|
@@ -2315,7 +2480,7 @@ async function showModule(module) {
|
|
|
2315
2480
|
$("#module-title").textContent = meta[1];
|
|
2316
2481
|
$("#module-description").textContent = meta[2];
|
|
2317
2482
|
$("#module-create-button").textContent = meta[3];
|
|
2318
|
-
$("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !
|
|
2483
|
+
$("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditModule(module));
|
|
2319
2484
|
$("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
|
|
2320
2485
|
try {
|
|
2321
2486
|
if (module === "settings") await renderSettings();
|
|
@@ -2404,6 +2569,36 @@ function bindEntityHistoryButtons(refresh) {
|
|
|
2404
2569
|
}));
|
|
2405
2570
|
}
|
|
2406
2571
|
|
|
2572
|
+
function openEntityMergeDialog({ typeLabel, source, candidates, endpoint, body, refresh, impact }) {
|
|
2573
|
+
const targetOptions = candidates
|
|
2574
|
+
.filter((candidate) => candidate.id !== source.id)
|
|
2575
|
+
.map((candidate) => [candidate.id, candidate.name]);
|
|
2576
|
+
openDialog(`合并${typeLabel}`,
|
|
2577
|
+
`<p class="merge-dialog-note">“${esc(source.name)}”将合并到所选档案,目标档案会保留。${esc(impact)}</p>` +
|
|
2578
|
+
field("targetId", `目标${typeLabel}`, "select", targetOptions[0]?.[0] ?? "", targetOptions),
|
|
2579
|
+
async (form) => {
|
|
2580
|
+
const target = candidates.find((candidate) => candidate.id === form.get("targetId"));
|
|
2581
|
+
if (!target) throw new Error(`请选择目标${typeLabel}`);
|
|
2582
|
+
await api(endpoint(source), { method: "POST", body: body(target) });
|
|
2583
|
+
await refresh();
|
|
2584
|
+
await loadAiReferences();
|
|
2585
|
+
toast(`已将“${source.name}”合并到“${target.name}”`);
|
|
2586
|
+
}, "人工资料管理", { submitLabel: "确认合并" });
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
async function deleteManagedEntity({ typeLabel, item, endpoint, refresh, warning = "" }) {
|
|
2590
|
+
const detail = warning ? `\n${warning}` : "";
|
|
2591
|
+
if (!window.confirm(`确认删除${typeLabel}“${item.name}”吗?${detail}`)) return;
|
|
2592
|
+
try {
|
|
2593
|
+
await api(endpoint(item), { method: "DELETE" });
|
|
2594
|
+
await refresh();
|
|
2595
|
+
await loadAiReferences();
|
|
2596
|
+
toast(`已删除${typeLabel}“${item.name}”`);
|
|
2597
|
+
} catch (error) {
|
|
2598
|
+
toast(error.message, "error");
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2407
2602
|
async function renderSettings() {
|
|
2408
2603
|
const records = (await apiPage(`/api/works/${state.work.id}/settings`)).items;
|
|
2409
2604
|
state.settings = records;
|
|
@@ -2424,10 +2619,10 @@ async function renderSettings() {
|
|
|
2424
2619
|
async function renderCharacters() {
|
|
2425
2620
|
[state.characters, state.races, state.organizations] = await Promise.all([
|
|
2426
2621
|
apiPage(`/api/works/${state.work.id}/characters`).then((result) => result.items),
|
|
2427
|
-
apiAllPages(`/api/works/${state.work.id}/races`),
|
|
2428
|
-
apiAllPages(`/api/works/${state.work.id}/organizations`)
|
|
2622
|
+
canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
|
|
2623
|
+
canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
|
|
2429
2624
|
]);
|
|
2430
|
-
const auditPanel = `<section class="character-audit-panel"><div><strong>角色身份确认</strong><small>让 AI 查询角色档案并搜索正文,找出可能被误建成两个档案的同一角色。AI 只提交审核建议,不会自动合并。</small></div><button id="create-character-audit-task" class="ghost-button" type="button" ${state.characters.length < 2 ? "disabled" : ""}>AI 角色查重</button></section
|
|
2625
|
+
const auditPanel = canEditModule("tasks") ? `<section class="character-audit-panel"><div><strong>角色身份确认</strong><small>让 AI 查询角色档案并搜索正文,找出可能被误建成两个档案的同一角色。AI 只提交审核建议,不会自动合并。</small></div><button id="create-character-audit-task" class="ghost-button" type="button" ${state.characters.length < 2 ? "disabled" : ""}>AI 角色查重</button></section>` : "";
|
|
2431
2626
|
$("#module-content").innerHTML = auditPanel + (state.characters.length ? `<div class="card-grid">${state.characters.map((item) => {
|
|
2432
2627
|
const details = normalizeCharacterDetails(item.attributes?.details);
|
|
2433
2628
|
return `
|
|
@@ -2439,7 +2634,7 @@ async function renderCharacters() {
|
|
|
2439
2634
|
<div class="organization-links"><b>所属组织</b>${(item.organizations ?? []).length ? item.organizations.map((organization) => `<span class="pill organization-pill">${esc(organization.name)}</span>`).join("") : '<span class="organization-empty">未加入组织</span>'}</div>
|
|
2440
2635
|
${item.profile?.summary ? `<p class="character-summary">${esc(item.profile.summary)}</p>` : `<p>${esc(Object.entries(item.currentState).map(([key, value]) => `${key}:${value}`).join("\n") || "尚未记录当前状态")}</p>`}
|
|
2441
2636
|
${item.profileSectionCount ? `<small class="character-section-count">${item.profileSectionCount} 个设定章节</small>` : ""}
|
|
2442
|
-
<div class="card-actions"><button data-edit-character="${esc(item.id)}">编辑</button
|
|
2637
|
+
<div class="card-actions"><button data-edit-character="${esc(item.id)}">编辑</button>${canEditModule("characters") && state.characters.length > 1 ? `<button data-merge-character="${esc(item.id)}">合并</button>` : ""}${canEditModule("characters") ? `<button class="danger-button" data-delete-character="${esc(item.id)}">删除</button>` : ""}</div></article>`;
|
|
2443
2638
|
}).join("")}</div>`
|
|
2444
2639
|
: emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
|
|
2445
2640
|
$("#create-character-audit-task")?.addEventListener("click", async () => {
|
|
@@ -2464,12 +2659,40 @@ async function renderCharacters() {
|
|
|
2464
2659
|
});
|
|
2465
2660
|
});
|
|
2466
2661
|
$("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterEditor(state.characters.find((item) => item.id === button.dataset.editCharacter))));
|
|
2662
|
+
$("#module-content").querySelectorAll("[data-merge-character]").forEach((button) => button.addEventListener("click", () => {
|
|
2663
|
+
const source = state.characters.find((item) => item.id === button.dataset.mergeCharacter);
|
|
2664
|
+
if (!source) return;
|
|
2665
|
+
openEntityMergeDialog({
|
|
2666
|
+
typeLabel: "角色",
|
|
2667
|
+
source,
|
|
2668
|
+
candidates: state.characters,
|
|
2669
|
+
endpoint: (item) => `/api/characters/${encodeURIComponent(item.id)}/merge`,
|
|
2670
|
+
body: (target) => ({
|
|
2671
|
+
targetCharacterId: target.id,
|
|
2672
|
+
expectedTargetVersionNo: target.versionNo,
|
|
2673
|
+
expectedSourceVersionNo: source.versionNo
|
|
2674
|
+
}),
|
|
2675
|
+
refresh: renderCharacters,
|
|
2676
|
+
impact: "来源角色的别名、组织、档案章节、时间线与人物关系会迁移到目标角色。"
|
|
2677
|
+
});
|
|
2678
|
+
}));
|
|
2679
|
+
$("#module-content").querySelectorAll("[data-delete-character]").forEach((button) => button.addEventListener("click", () => {
|
|
2680
|
+
const item = state.characters.find((character) => character.id === button.dataset.deleteCharacter);
|
|
2681
|
+
if (!item) return;
|
|
2682
|
+
void deleteManagedEntity({
|
|
2683
|
+
typeLabel: "角色",
|
|
2684
|
+
item,
|
|
2685
|
+
endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}`,
|
|
2686
|
+
refresh: renderCharacters,
|
|
2687
|
+
warning: "相关人物关系会删除,时间线中的参与者引用会移除。"
|
|
2688
|
+
});
|
|
2689
|
+
}));
|
|
2467
2690
|
}
|
|
2468
2691
|
|
|
2469
2692
|
async function renderRaces() {
|
|
2470
2693
|
[state.races, state.characters] = await Promise.all([
|
|
2471
2694
|
apiAllPages(`/api/works/${state.work.id}/races`),
|
|
2472
|
-
apiAllPages(`/api/works/${state.work.id}/characters`)
|
|
2695
|
+
canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
|
|
2473
2696
|
]);
|
|
2474
2697
|
const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
|
|
2475
2698
|
<summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
|
|
@@ -2479,29 +2702,77 @@ async function renderRaces() {
|
|
|
2479
2702
|
<p>${esc(item.description || "尚未填写种族简介")}</p>
|
|
2480
2703
|
<div class="race-settings">${item.effectiveSettings.length ? item.effectiveSettings.map((setting) => `<span class="pill${setting.inherited ? " inherited" : ""}" title="${esc(setting.inherited ? `继承自 ${setting.sourceRaceName}` : `定义于 ${setting.sourceRaceName}`)}">${esc(setting.value)}<small>${esc(setting.sourceRaceName)}</small></span>`).join("") : '<span class="pill">暂无共同设定</span>'}</div>
|
|
2481
2704
|
<p class="race-members">直接角色:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
|
|
2482
|
-
<div class="card-actions"><button data-edit-race="${esc(item.id)}">编辑</button><button data-entity-history="race" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button
|
|
2705
|
+
<div class="card-actions"><button data-edit-race="${esc(item.id)}">编辑</button><button data-entity-history="race" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button>${canEditModule("races") && state.races.length > 1 ? `<button data-merge-race="${esc(item.id)}">合并</button>` : ""}${canEditModule("races") ? `<button class="danger-button" data-delete-race="${esc(item.id)}">删除</button>` : ""}</div>
|
|
2483
2706
|
</article>
|
|
2484
2707
|
${item.children.length ? `<div class="race-tree-children">${item.children.map(renderRaceNode).join("")}</div>` : ""}
|
|
2485
2708
|
</div>
|
|
2486
2709
|
</details>`;
|
|
2487
2710
|
$("#module-content").innerHTML = state.races.length ? `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>` : emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
|
|
2488
2711
|
$("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => openRaceDialog(state.races.find((item) => item.id === button.dataset.editRace))));
|
|
2712
|
+
$("#module-content").querySelectorAll("[data-merge-race]").forEach((button) => button.addEventListener("click", () => {
|
|
2713
|
+
const source = state.races.find((item) => item.id === button.dataset.mergeRace);
|
|
2714
|
+
if (!source) return;
|
|
2715
|
+
openEntityMergeDialog({
|
|
2716
|
+
typeLabel: "种族",
|
|
2717
|
+
source,
|
|
2718
|
+
candidates: state.races,
|
|
2719
|
+
endpoint: (item) => `/api/races/${encodeURIComponent(item.id)}/merge`,
|
|
2720
|
+
body: (target) => ({ targetRaceId: target.id }),
|
|
2721
|
+
refresh: renderRaces,
|
|
2722
|
+
impact: "来源种族的角色、子种族、简介与共同设定会迁移到目标种族。"
|
|
2723
|
+
});
|
|
2724
|
+
}));
|
|
2725
|
+
$("#module-content").querySelectorAll("[data-delete-race]").forEach((button) => button.addEventListener("click", () => {
|
|
2726
|
+
const item = state.races.find((race) => race.id === button.dataset.deleteRace);
|
|
2727
|
+
if (!item) return;
|
|
2728
|
+
void deleteManagedEntity({
|
|
2729
|
+
typeLabel: "种族",
|
|
2730
|
+
item,
|
|
2731
|
+
endpoint: (race) => `/api/races/${encodeURIComponent(race.id)}`,
|
|
2732
|
+
refresh: renderRaces,
|
|
2733
|
+
warning: "已绑定角色将变为未指定种族;有子种族时需先迁移或合并。"
|
|
2734
|
+
});
|
|
2735
|
+
}));
|
|
2489
2736
|
bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
|
|
2490
2737
|
}
|
|
2491
2738
|
|
|
2492
2739
|
async function renderOrganizations() {
|
|
2493
2740
|
[state.organizations, state.characters] = await Promise.all([
|
|
2494
2741
|
apiAllPages(`/api/works/${state.work.id}/organizations`),
|
|
2495
|
-
apiAllPages(`/api/works/${state.work.id}/characters`)
|
|
2742
|
+
canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
|
|
2496
2743
|
]);
|
|
2497
2744
|
$("#module-content").innerHTML = state.organizations.length ? `<div class="card-grid organization-grid">${state.organizations.map((item) => `
|
|
2498
2745
|
<article class="record-card organization-card"><small>${item.memberIds.length} 位成员 · ${item.settings.length} 条设定</small>
|
|
2499
2746
|
<h3>${esc(item.name)}</h3><p>${esc(item.description || "尚未填写组织简介")}</p>
|
|
2500
2747
|
<div class="organization-settings">${item.settings.map((setting) => `<span class="pill">${esc(setting)}</span>`).join("") || '<span class="pill">暂无组织设定</span>'}</div>
|
|
2501
2748
|
<p class="organization-members">成员:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
|
|
2502
|
-
<div class="card-actions"><button data-edit-organization="${esc(item.id)}">编辑</button><button data-entity-history="organization" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button
|
|
2749
|
+
<div class="card-actions"><button data-edit-organization="${esc(item.id)}">编辑</button><button data-entity-history="organization" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button>${canEditModule("organizations") && state.organizations.length > 1 ? `<button data-merge-organization="${esc(item.id)}">合并</button>` : ""}${canEditModule("organizations") ? `<button class="danger-button" data-delete-organization="${esc(item.id)}">删除</button>` : ""}</div>
|
|
2503
2750
|
</article>`).join("")}</div>` : emptyModule("还没有组织", "创建国家、机构、阵营或团队,并维护组织设定与成员。");
|
|
2504
2751
|
$("#module-content").querySelectorAll("[data-edit-organization]").forEach((button) => button.addEventListener("click", () => openOrganizationDialog(state.organizations.find((item) => item.id === button.dataset.editOrganization))));
|
|
2752
|
+
$("#module-content").querySelectorAll("[data-merge-organization]").forEach((button) => button.addEventListener("click", () => {
|
|
2753
|
+
const source = state.organizations.find((item) => item.id === button.dataset.mergeOrganization);
|
|
2754
|
+
if (!source) return;
|
|
2755
|
+
openEntityMergeDialog({
|
|
2756
|
+
typeLabel: "组织",
|
|
2757
|
+
source,
|
|
2758
|
+
candidates: state.organizations,
|
|
2759
|
+
endpoint: (item) => `/api/organizations/${encodeURIComponent(item.id)}/merge`,
|
|
2760
|
+
body: (target) => ({ targetOrganizationId: target.id }),
|
|
2761
|
+
refresh: renderOrganizations,
|
|
2762
|
+
impact: "来源组织的成员、简介与组织设定会迁移到目标组织。"
|
|
2763
|
+
});
|
|
2764
|
+
}));
|
|
2765
|
+
$("#module-content").querySelectorAll("[data-delete-organization]").forEach((button) => button.addEventListener("click", () => {
|
|
2766
|
+
const item = state.organizations.find((organization) => organization.id === button.dataset.deleteOrganization);
|
|
2767
|
+
if (!item) return;
|
|
2768
|
+
void deleteManagedEntity({
|
|
2769
|
+
typeLabel: "组织",
|
|
2770
|
+
item,
|
|
2771
|
+
endpoint: (organization) => `/api/organizations/${encodeURIComponent(organization.id)}`,
|
|
2772
|
+
refresh: renderOrganizations,
|
|
2773
|
+
warning: "角色与该组织的成员关系会一并移除。"
|
|
2774
|
+
});
|
|
2775
|
+
}));
|
|
2505
2776
|
bindEntityHistoryButtons(async () => { await renderOrganizations(); await loadAiReferences(); });
|
|
2506
2777
|
}
|
|
2507
2778
|
|
|
@@ -2527,7 +2798,12 @@ async function renderTimeline() {
|
|
|
2527
2798
|
const eventIds = [...$("#module-content").querySelectorAll("[data-event-select]:checked")].map((input) => input.dataset.eventSelect);
|
|
2528
2799
|
if (eventIds.length < 2) return toast("请至少选择两个时间事件", "error");
|
|
2529
2800
|
openDialog("合并时间事件", field("name", "合并后的事件名称") + field("description", "合并说明(留空则拼接原说明)", "textarea"), async (form) => {
|
|
2530
|
-
await api(`/api/works/${state.work.id}/timeline/merge`, { method: "POST", body: {
|
|
2801
|
+
await api(`/api/works/${state.work.id}/timeline/merge`, { method: "POST", body: {
|
|
2802
|
+
eventIds,
|
|
2803
|
+
name: form.get("name"),
|
|
2804
|
+
description: form.get("description") || undefined,
|
|
2805
|
+
expectedVersionNos: Object.fromEntries(eventIds.map((eventId) => [eventId, Number(events.find((event) => event.id === eventId)?.versionNo)]))
|
|
2806
|
+
} });
|
|
2531
2807
|
await renderTimeline();
|
|
2532
2808
|
}, "保留参与者与证据");
|
|
2533
2809
|
});
|
|
@@ -2565,7 +2841,7 @@ async function renderOutlines() {
|
|
|
2565
2841
|
}
|
|
2566
2842
|
|
|
2567
2843
|
async function renderRelationships() {
|
|
2568
|
-
state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
|
|
2844
|
+
state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
|
|
2569
2845
|
const relationships = (await apiPage(`/api/works/${state.work.id}/relationships`)).items;
|
|
2570
2846
|
const nameOf = (id) => state.characters.find((item) => item.id === id)?.name ?? "未知角色";
|
|
2571
2847
|
state.galaxy?.destroy();
|
|
@@ -2596,9 +2872,13 @@ async function renderRelationships() {
|
|
|
2596
2872
|
}
|
|
2597
2873
|
|
|
2598
2874
|
async function renderReviews() {
|
|
2875
|
+
const canReadCharacters = canReadModule("characters");
|
|
2876
|
+
const canResolveReview = canEditModule("reviews");
|
|
2877
|
+
const canMergeCharacters = canResolveReview
|
|
2878
|
+
&& ["characters", "races", "organizations", "timeline", "relationships"].every((module) => canEditModule(module));
|
|
2599
2879
|
const [reviews, characters] = await Promise.all([
|
|
2600
2880
|
apiPage(`/api/works/${state.work.id}/reviews`).then((result) => result.items),
|
|
2601
|
-
apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`)
|
|
2881
|
+
canReadCharacters ? apiAllPages(`/api/works/${state.work.id}/characters?includeMerged=1`) : Promise.resolve([])
|
|
2602
2882
|
]);
|
|
2603
2883
|
const characterById = new Map(characters.map((character) => [character.id, character]));
|
|
2604
2884
|
const duplicateCard = (item) => {
|
|
@@ -2606,17 +2886,21 @@ async function renderReviews() {
|
|
|
2606
2886
|
const sides = refs.map((reference) => ({ reference, character: characterById.get(reference.id) }));
|
|
2607
2887
|
const sideHtml = sides.map(({ character }) => `<section><strong>${esc(character.name)}</strong><small>v${esc(String(character.versionNo))} · ${esc(character.species || "种族未知")}</small><div>${character.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("") || '<span class="organization-empty">无别名</span>'}</div><p>${esc(character.attributes?.identity || character.profile?.summary || "尚未记录身份说明")}</p></section>`).join("");
|
|
2608
2888
|
const evidenceHtml = (item.evidence ?? []).map((evidence) => `<li><strong>${esc(evidence.chapterTitle || evidence.chapterId || "原文")}</strong><q>${esc(evidence.quote || "")}</q>${evidence.supports ? `<small>${esc(evidence.supports)}</small>` : ""}</li>`).join("");
|
|
2609
|
-
const
|
|
2889
|
+
const mergeActions = item.status === "pending" && sides.length === 2 && canMergeCharacters ? `
|
|
2610
2890
|
<button data-merge-review="${esc(item.id)}" data-merge-target="${esc(sides[0].character.id)}" data-merge-source="${esc(sides[1].character.id)}" data-target-version="${esc(String(sides[0].reference.versionNo))}" data-source-version="${esc(String(sides[1].reference.versionNo))}">合并为 ${esc(sides[0].character.name)}</button>
|
|
2611
|
-
<button data-merge-review="${esc(item.id)}" data-merge-target="${esc(sides[1].character.id)}" data-merge-source="${esc(sides[0].character.id)}" data-target-version="${esc(String(sides[1].reference.versionNo))}" data-source-version="${esc(String(sides[0].reference.versionNo))}">合并为 ${esc(sides[1].character.name)}</button
|
|
2612
|
-
|
|
2613
|
-
|
|
2891
|
+
<button data-merge-review="${esc(item.id)}" data-merge-target="${esc(sides[1].character.id)}" data-merge-source="${esc(sides[0].character.id)}" data-target-version="${esc(String(sides[1].reference.versionNo))}" data-source-version="${esc(String(sides[0].reference.versionNo))}">合并为 ${esc(sides[1].character.name)}</button>` : "";
|
|
2892
|
+
const keepSeparateAction = item.status === "pending" && canResolveReview
|
|
2893
|
+
? `<button data-keep-characters-separate="${esc(item.id)}">确认是不同角色</button>`
|
|
2894
|
+
: "";
|
|
2895
|
+
const actions = mergeActions || keepSeparateAction
|
|
2896
|
+
? `<div class="card-actions character-duplicate-actions">${mergeActions}${keepSeparateAction}</div>`
|
|
2897
|
+
: "";
|
|
2614
2898
|
return `<article class="record-card character-duplicate-review"><small>角色查重 · ${esc(item.severity)} · ${esc(item.status)}</small><h3>${esc(item.title)}</h3><div class="character-duplicate-pair">${sideHtml}</div><p>${esc(item.description)}${item.suggestion ? `\n建议:${esc(item.suggestion)}` : ""}</p>${evidenceHtml ? `<ul class="character-duplicate-evidence">${evidenceHtml}</ul>` : ""}${actions}${item.resolutionNote ? `<p class="review-resolution-note">处理结果:${esc(item.resolutionNote)}</p>` : ""}</article>`;
|
|
2615
2899
|
};
|
|
2616
2900
|
$("#module-content").innerHTML = reviews.length ? `<div class="card-grid">${reviews.map((item) => item.itemType === "character-duplicate" ? duplicateCard(item) : `
|
|
2617
2901
|
<article class="record-card"><small>${esc(item.itemType)} · ${esc(item.severity)} · ${esc(item.status)}</small><h3>${esc(item.title)}</h3>
|
|
2618
2902
|
<p>${esc(item.description)}${item.suggestion ? `\n建议:${esc(item.suggestion)}` : ""}</p>
|
|
2619
|
-
${item.status === "pending" ? `<div class="card-actions"><button data-review-status="fixed" data-review-id="${esc(item.id)}">标为已修复</button><button data-review-status="ignored" data-review-id="${esc(item.id)}">忽略</button></div>` : ""}</article>`).join("")}</div>`
|
|
2903
|
+
${item.status === "pending" && canResolveReview ? `<div class="card-actions"><button data-review-status="fixed" data-review-id="${esc(item.id)}">标为已修复</button><button data-review-status="ignored" data-review-id="${esc(item.id)}">忽略</button></div>` : ""}</article>`).join("")}</div>`
|
|
2620
2904
|
: emptyModule("没有待审核事项", "候选设定、冲突与低置信度结论会集中显示在这里。");
|
|
2621
2905
|
$("#module-content").querySelectorAll("[data-review-id]").forEach((button) => button.addEventListener("click", async () => {
|
|
2622
2906
|
await api(`/api/reviews/${button.dataset.reviewId}`, { method: "PATCH", body: { status: button.dataset.reviewStatus } });
|
|
@@ -2659,12 +2943,15 @@ async function renderReviews() {
|
|
|
2659
2943
|
async function renderTasks() {
|
|
2660
2944
|
const [tasks, settings] = await Promise.all([
|
|
2661
2945
|
apiPage(`/api/works/${state.work.id}/tasks`).then((result) => result.items),
|
|
2662
|
-
|
|
2946
|
+
canReadModule("ai-settings")
|
|
2947
|
+
? api(`/api/works/${state.work.id}/ai-settings`)
|
|
2948
|
+
: Promise.resolve({ autoRunEnabled: false, autoRunConcurrency: 2, autoRunBatchLimit: 20 })
|
|
2663
2949
|
]);
|
|
2950
|
+
const canConfigureAutoRun = canEditModule("tasks") && canEditModule("ai-settings");
|
|
2664
2951
|
const pendingCount = tasks.filter((item) => item.status === "pending").length;
|
|
2665
2952
|
const runningCount = tasks.filter((item) => item.status === "running").length;
|
|
2666
2953
|
$("#module-content").innerHTML = `
|
|
2667
|
-
<section class="task-auto-run-panel" aria-labelledby="task-auto-run-title">
|
|
2954
|
+
<section class="task-auto-run-panel ${canConfigureAutoRun ? "" : "hidden"}" aria-labelledby="task-auto-run-title">
|
|
2668
2955
|
<div class="task-auto-run-copy">
|
|
2669
2956
|
<strong id="task-auto-run-title">自动执行待分析任务</strong>
|
|
2670
2957
|
<small>只执行已经进入“待执行”队列的任务,不会自动创建人物关系、世界观或其他分析。</small>
|
|
@@ -2882,6 +3169,10 @@ async function renderBookAiSettings() {
|
|
|
2882
3169
|
"afterend",
|
|
2883
3170
|
`<label><input name="agent-tool" type="checkbox" value="read_character_sections" ${agentTools.has("read_character_sections") ? "checked" : ""}><span><strong>读取人物 Markdown 章节</strong><small>根据知识查询返回的章节 ID 精读人物背景、能力与经历原文。</small></span></label>`
|
|
2884
3171
|
);
|
|
3172
|
+
if (!canEditModule("ai-settings")) {
|
|
3173
|
+
host.querySelectorAll("textarea, input, select").forEach((control) => { control.disabled = true; });
|
|
3174
|
+
host.querySelectorAll(".primary-button").forEach((button) => button.classList.add("permission-hidden"));
|
|
3175
|
+
}
|
|
2885
3176
|
$("#save-work-system-prompt").addEventListener("click", async () => {
|
|
2886
3177
|
const button = $("#save-work-system-prompt");
|
|
2887
3178
|
button.disabled = true;
|
|
@@ -3093,8 +3384,8 @@ async function loadAiReferences() {
|
|
|
3093
3384
|
const workId = state.work?.id;
|
|
3094
3385
|
if (!workId) return;
|
|
3095
3386
|
const [characters, settings] = await Promise.all([
|
|
3096
|
-
apiAllPages(`/api/works/${workId}/characters`),
|
|
3097
|
-
apiAllPages(`/api/works/${workId}/settings`)
|
|
3387
|
+
canReadModule("characters") ? apiAllPages(`/api/works/${workId}/characters`) : Promise.resolve([]),
|
|
3388
|
+
canReadModule("settings") ? apiAllPages(`/api/works/${workId}/settings`) : Promise.resolve([])
|
|
3098
3389
|
]);
|
|
3099
3390
|
if (state.work?.id !== workId) return;
|
|
3100
3391
|
state.characters = characters;
|
|
@@ -3331,8 +3622,8 @@ function openWorkSettingsDialog(work) {
|
|
|
3331
3622
|
if (!work) return;
|
|
3332
3623
|
const canManageAccess = ["admin", "owner"].includes(String(work.accessRole));
|
|
3333
3624
|
const accessField = `<section class="work-access-field" aria-labelledby="work-access-title">
|
|
3334
|
-
<div><strong id="work-access-title"
|
|
3335
|
-
${canManageAccess ? '<button id="work-access-manage" class="ghost-button" type="button"
|
|
3625
|
+
<div><strong id="work-access-title">成员权限</strong><small>选择成员后,可为每个作品模块单独设置无权限、只读或可编辑。</small><div class="work-access-options" aria-label="成员权限配置方式"><span>按成员配置</span><span>按模块授权</span><span>读写分离</span></div></div>
|
|
3626
|
+
${canManageAccess ? '<button id="work-access-manage" class="ghost-button" type="button">配置成员权限</button>' : '<small>仅作品创建者或系统管理员可以调整访问权限。</small>'}
|
|
3336
3627
|
</section>`;
|
|
3337
3628
|
openDialog("作品信息",
|
|
3338
3629
|
workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + accessField,
|
|
@@ -3361,6 +3652,7 @@ function openWorkSettingsDialog(work) {
|
|
|
3361
3652
|
|
|
3362
3653
|
async function openChapterDialog() {
|
|
3363
3654
|
if (!state.work) return openWorkDialog();
|
|
3655
|
+
if (!canEditProse()) return toast("当前权限只能编辑设定资料,正文为只读", "error");
|
|
3364
3656
|
if (!state.work.volumes.length) {
|
|
3365
3657
|
await api(`/api/works/${state.work.id}/volumes`, { method: "POST", body: { title: "正文", kind: "main" } });
|
|
3366
3658
|
state.work = await api(`/api/works/${state.work.id}`);
|
|
@@ -3375,6 +3667,7 @@ async function openChapterDialog() {
|
|
|
3375
3667
|
|
|
3376
3668
|
function openVolumeDialog(item) {
|
|
3377
3669
|
if (!state.work) return openWorkDialog();
|
|
3670
|
+
if (!canEditProse()) return toast("当前权限只能编辑设定资料,不能修改分卷", "error");
|
|
3378
3671
|
const kindOptions = [["main", "正文卷"], ["prequel", "前传"], ["extra", "番外"], ["epilogue", "后记"], ["appendix", "附录"]];
|
|
3379
3672
|
openDialog(item ? "编辑分卷" : "新建分卷",
|
|
3380
3673
|
field("title", "分卷名称", "text", item?.title) +
|
|
@@ -3406,13 +3699,13 @@ function openSettingEditor(item = null) {
|
|
|
3406
3699
|
$("#setting-change-note").value = "";
|
|
3407
3700
|
$("#setting-change-note-field").classList.toggle("hidden", !item);
|
|
3408
3701
|
$("#setting-editor-submit").textContent = item ? "保存新版本" : "创建设定";
|
|
3409
|
-
const viewOnly = !
|
|
3702
|
+
const viewOnly = !canEditModule("settings");
|
|
3410
3703
|
$("#setting-editor-form").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = viewOnly; });
|
|
3411
3704
|
$("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
|
|
3412
3705
|
$("#setting-editor-submit").classList.toggle("hidden", viewOnly);
|
|
3413
3706
|
$("#setting-editor-form").onsubmit = async (event) => {
|
|
3414
3707
|
event.preventDefault();
|
|
3415
|
-
if (!
|
|
3708
|
+
if (!canEditModule("settings")) return;
|
|
3416
3709
|
const form = new FormData(event.currentTarget);
|
|
3417
3710
|
const submit = $("#setting-editor-submit");
|
|
3418
3711
|
submit.disabled = true;
|
|
@@ -3494,11 +3787,11 @@ function renderCharacterEditorRelationships() {
|
|
|
3494
3787
|
const relationLabel = [category, relationship.subtype].filter(Boolean).join(" · ") || "未细分";
|
|
3495
3788
|
const keywords = Array.isArray(relationship.keywords) ? relationship.keywords : [];
|
|
3496
3789
|
return `<article class="character-relationship-row">
|
|
3497
|
-
<div class="character-relationship-heading"><div><strong>${esc(nameOf(otherCharacterId))}</strong><span>${direction} ${esc(relationLabel)}</span></div>${
|
|
3790
|
+
<div class="character-relationship-heading"><div><strong>${esc(nameOf(otherCharacterId))}</strong><span>${direction} ${esc(relationLabel)}</span></div>${canEditModule("relationships") ? `<button type="button" data-character-relationship-edit="${esc(relationship.id)}">编辑关系</button>` : ""}</div>
|
|
3498
3791
|
<div class="character-relationship-keywords"><small>关系关键词</small><div>${keywords.map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || '<span class="character-relationship-empty-keywords">未填写关键词</span>'}</div></div>
|
|
3499
3792
|
</article>`;
|
|
3500
3793
|
}).join("");
|
|
3501
|
-
host.innerHTML = `<div class="character-relationship-toolbar"><p>与 ${esc(characterEditorItem.name)} 有关的其他人物及关系关键词。</p>${
|
|
3794
|
+
host.innerHTML = `<div class="character-relationship-toolbar"><p>与 ${esc(characterEditorItem.name)} 有关的其他人物及关系关键词。</p>${canEditModule("relationships") ? '<button type="button" class="ghost-button" data-character-relationship-create>新建关系</button>' : ""}</div>${rows || '<p class="character-relationship-status">暂未记录与其他人物的关系。</p>'}`;
|
|
3502
3795
|
host.querySelectorAll("[data-character-relationship-edit]").forEach((button) => button.addEventListener("click", () => {
|
|
3503
3796
|
const relationship = characterEditorRelationships.find((item) => item.id === button.dataset.characterRelationshipEdit);
|
|
3504
3797
|
if (relationship) void openRelationshipDialog(relationship, { characterId });
|
|
@@ -3706,7 +3999,7 @@ async function showCharacterSectionVersions(sectionId) {
|
|
|
3706
3999
|
host.querySelectorAll("[data-character-section-restore]").forEach((button) => button.addEventListener("click", async () => {
|
|
3707
4000
|
button.disabled = true;
|
|
3708
4001
|
try {
|
|
3709
|
-
|
|
4002
|
+
await api(`/api/character-sections/${sectionId}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.characterSectionRestore) } });
|
|
3710
4003
|
characterEditorSections = await api(`/api/characters/${characterEditorItem.id}/sections`);
|
|
3711
4004
|
renderCharacterMarkdownSections();
|
|
3712
4005
|
await Promise.all([renderCharacters(), loadAiReferences()]);
|
|
@@ -3728,9 +4021,9 @@ function renderCharacterMarkdownSections() {
|
|
|
3728
4021
|
host.innerHTML = '<div class="character-editor-empty-field"><b>Markdown 档案章节</b><span>创建人物档案后即可添加背景故事、能力、经历和研究记录。</span></div>';
|
|
3729
4022
|
return;
|
|
3730
4023
|
}
|
|
3731
|
-
const toolbar = `<div class="character-markdown-list-toolbar"><div><b>Markdown 档案章节</b><span>长篇内容独立保存、渲染、检索和版本管理。</span></div>${
|
|
4024
|
+
const toolbar = `<div class="character-markdown-list-toolbar"><div><b>Markdown 档案章节</b><span>长篇内容独立保存、渲染、检索和版本管理。</span></div>${canEditModule("characters") ? '<button type="button" class="primary-button" data-character-section-create>新建章节</button>' : ""}</div>`;
|
|
3732
4025
|
const sections = characterEditorSections.map((section) => `<article class="character-markdown-section">
|
|
3733
|
-
<header><div><span>${esc(characterSectionTypeLabels[section.sectionType] ?? section.sectionType)}</span><h4>${esc(section.title)}</h4>${section.summary ? `<p>${esc(section.summary)}</p>` : ""}</div><div>${
|
|
4026
|
+
<header><div><span>${esc(characterSectionTypeLabels[section.sectionType] ?? section.sectionType)}</span><h4>${esc(section.title)}</h4>${section.summary ? `<p>${esc(section.summary)}</p>` : ""}</div><div>${canEditModule("characters") ? `<button type="button" data-character-section-edit="${esc(section.id)}">编辑</button>` : ""}<button type="button" data-character-section-versions="${esc(section.id)}">版本</button>${canEditModule("characters") ? `<button type="button" data-character-section-delete="${esc(section.id)}">删除</button>` : ""}</div></header>
|
|
3734
4027
|
<div class="character-markdown-document message-body">${renderMarkdown(section.contentMarkdown) || '<p class="character-markdown-empty">本章节暂无正文。</p>'}</div>
|
|
3735
4028
|
<div data-character-section-versions-host="${esc(section.id)}"></div>
|
|
3736
4029
|
</article>`).join("");
|
|
@@ -3788,14 +4081,20 @@ function renderCharacterEditorFields(item) {
|
|
|
3788
4081
|
characterEditorSection("basic", "基础资料", "用于检索、去重和建立人物在作品中的基本归属。",
|
|
3789
4082
|
field("name", "标准名", "text", item?.name) +
|
|
3790
4083
|
field("aliases", "别名", "item-list", item?.aliases ?? []) +
|
|
3791
|
-
(
|
|
4084
|
+
(!canReadModule("races")
|
|
4085
|
+
? '<div class="character-editor-empty-field"><b>种族</b><span>当前账户没有种族模块读取权限,原有绑定不会被修改。</span></div>'
|
|
4086
|
+
: state.races.length
|
|
3792
4087
|
? field("raceId", "种族", "select", item?.raceId ?? "", raceOptions)
|
|
3793
4088
|
: '<div class="character-editor-empty-field"><b>种族</b><span>尚未创建种族,请先在“种族”模块建立档案。</span></div>') +
|
|
3794
|
-
(
|
|
4089
|
+
(!canReadModule("organizations")
|
|
4090
|
+
? '<div class="character-editor-empty-field"><b>所属组织</b><span>当前账户没有组织模块读取权限,原有绑定不会被修改。</span></div>'
|
|
4091
|
+
: organizationOptions.length
|
|
3795
4092
|
? field("organizationIds", "所属组织(可多选)", "chips", item?.organizationIds ?? [], organizationOptions)
|
|
3796
4093
|
: '<div class="character-editor-empty-field"><b>所属组织</b><span>尚未创建组织,可稍后在“组织”模块中补充。</span></div>') +
|
|
3797
4094
|
field("visibility", "可见范围", "select", item?.visibility ?? "author", [["author", "仅作者"], ["collaborators", "协作者"], ["public", "公开"]]) +
|
|
3798
|
-
|
|
4095
|
+
(canReadModule("editor")
|
|
4096
|
+
? field("firstChapterId", "首次登场章节", "select", item?.firstChapterId ?? "", chapterOptions)
|
|
4097
|
+
: '<div class="character-editor-empty-field"><b>首次登场章节</b><span>当前账户没有正文读取权限,原有绑定不会被修改。</span></div>')),
|
|
3799
4098
|
characterEditorSection("profile", "人物档案", "记录人物定位、行为动力和便于创作时快速理解的简介。",
|
|
3800
4099
|
field("identity", "身份与定位", "text", item?.attributes?.identity) +
|
|
3801
4100
|
field("motivation", "核心动机", "textarea", item?.profile?.motivation) +
|
|
@@ -3831,11 +4130,9 @@ function collectCharacterBody(form) {
|
|
|
3831
4130
|
const item = characterEditorItem;
|
|
3832
4131
|
const profile = { ...(item?.profile ?? {}) };
|
|
3833
4132
|
delete profile.sections;
|
|
3834
|
-
|
|
4133
|
+
const body = {
|
|
3835
4134
|
name: String(form.get("name") ?? "").trim(),
|
|
3836
4135
|
aliases: form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean),
|
|
3837
|
-
raceId: form.get("raceId") || null,
|
|
3838
|
-
organizationIds: form.getAll("organizationIds").map(String),
|
|
3839
4136
|
attributes: {
|
|
3840
4137
|
...(item?.attributes ?? {}),
|
|
3841
4138
|
identity: String(form.get("identity") ?? "").trim(),
|
|
@@ -3849,9 +4146,12 @@ function collectCharacterBody(form) {
|
|
|
3849
4146
|
currentState: buildCharacterState(form.getAll("stateKey"), form.getAll("stateValue"), item?.currentState ?? {}),
|
|
3850
4147
|
lockedFields: form.getAll("lockedFields").map((value) => String(value).trim()).filter(Boolean),
|
|
3851
4148
|
visibility: String(form.get("visibility") ?? "author"),
|
|
3852
|
-
firstChapterId: form.get("firstChapterId") || null,
|
|
3853
4149
|
changeNote: String(form.get("changeNote") ?? "").trim()
|
|
3854
4150
|
};
|
|
4151
|
+
if (canReadModule("races")) body.raceId = form.get("raceId") || null;
|
|
4152
|
+
if (canReadModule("organizations")) body.organizationIds = form.getAll("organizationIds").map(String);
|
|
4153
|
+
if (canReadModule("editor")) body.firstChapterId = form.get("firstChapterId") || null;
|
|
4154
|
+
return body;
|
|
3855
4155
|
}
|
|
3856
4156
|
|
|
3857
4157
|
function renderCharacterHistory() {
|
|
@@ -3923,8 +4223,8 @@ async function showCharacterHistory() {
|
|
|
3923
4223
|
|
|
3924
4224
|
async function openCharacterEditor(item = null) {
|
|
3925
4225
|
[state.races, state.organizations] = await Promise.all([
|
|
3926
|
-
apiAllPages(`/api/works/${state.work.id}/races`),
|
|
3927
|
-
apiAllPages(`/api/works/${state.work.id}/organizations`)
|
|
4226
|
+
canReadModule("races") ? apiAllPages(`/api/works/${state.work.id}/races`) : Promise.resolve([]),
|
|
4227
|
+
canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
|
|
3928
4228
|
]);
|
|
3929
4229
|
characterEditorItem = item ?? null;
|
|
3930
4230
|
characterEditorVersions = [];
|
|
@@ -3940,7 +4240,7 @@ async function openCharacterEditor(item = null) {
|
|
|
3940
4240
|
$("#character-history-button").title = item ? "查看、比较和回滚历史版本" : "创建人物档案后即可查看版本历史";
|
|
3941
4241
|
setCharacterHistoryVisible(false);
|
|
3942
4242
|
renderCharacterEditorFields(item);
|
|
3943
|
-
const viewOnly = !
|
|
4243
|
+
const viewOnly = !canEditModule("characters");
|
|
3944
4244
|
if (viewOnly) {
|
|
3945
4245
|
$("#character-editor-eyebrow").textContent = "人物档案";
|
|
3946
4246
|
$("#character-editor-fields").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = true; });
|
|
@@ -3952,12 +4252,12 @@ async function openCharacterEditor(item = null) {
|
|
|
3952
4252
|
button.onclick = () => activateCharacterEditorTab(button.dataset.characterEditorTab);
|
|
3953
4253
|
});
|
|
3954
4254
|
const relationshipTab = document.querySelector("[data-character-editor-tab='relationships']");
|
|
3955
|
-
relationshipTab.disabled = !item;
|
|
3956
|
-
relationshipTab.title = item ? "查看和编辑人物关系" : "创建人物档案后即可维护人物关系";
|
|
4255
|
+
relationshipTab.disabled = !item || !canReadModule("relationships");
|
|
4256
|
+
relationshipTab.title = !canReadModule("relationships") ? "当前账户没有关系模块读取权限" : item ? "查看和编辑人物关系" : "创建人物档案后即可维护人物关系";
|
|
3957
4257
|
const form = $("#character-editor-form");
|
|
3958
4258
|
form.onsubmit = async (event) => {
|
|
3959
4259
|
event.preventDefault();
|
|
3960
|
-
if (!
|
|
4260
|
+
if (!canEditModule("characters")) return;
|
|
3961
4261
|
const submit = $("#character-editor-submit");
|
|
3962
4262
|
submit.disabled = true;
|
|
3963
4263
|
try {
|
|
@@ -3979,13 +4279,13 @@ async function openCharacterEditor(item = null) {
|
|
|
3979
4279
|
};
|
|
3980
4280
|
showEntityEditorPage("character");
|
|
3981
4281
|
if (item) {
|
|
3982
|
-
void loadCharacterEditorRelationships(item.id);
|
|
4282
|
+
if (canReadModule("relationships")) void loadCharacterEditorRelationships(item.id);
|
|
3983
4283
|
void loadCharacterMarkdownSections(item.id);
|
|
3984
4284
|
}
|
|
3985
4285
|
}
|
|
3986
4286
|
|
|
3987
4287
|
async function openRaceDialog(item) {
|
|
3988
|
-
state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
|
|
4288
|
+
state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
|
|
3989
4289
|
const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
|
|
3990
4290
|
const parentOptions = [["", "无(根种族)"], ...eligibleRaceParents(state.races, item?.id)
|
|
3991
4291
|
.sort((left, right) => racePathLabel(left).localeCompare(racePathLabel(right), "zh-CN"))
|
|
@@ -3998,7 +4298,8 @@ async function openRaceDialog(item) {
|
|
|
3998
4298
|
(memberOptions.length ? field("memberIds", "属于该种族的角色(可多选)", "chips", item?.memberIds ?? [], memberOptions) : ""),
|
|
3999
4299
|
async (form) => {
|
|
4000
4300
|
const settings = form.getAll("settings").map((value) => String(value).trim()).filter(Boolean);
|
|
4001
|
-
const body = { name: form.get("name"), parentRaceId: form.get("parentRaceId") || null, description: form.get("description"), settings
|
|
4301
|
+
const body = { name: form.get("name"), parentRaceId: form.get("parentRaceId") || null, description: form.get("description"), settings };
|
|
4302
|
+
if (canReadModule("characters")) body.memberIds = form.getAll("memberIds").map(String);
|
|
4002
4303
|
await api(item ? `/api/races/${item.id}` : `/api/works/${state.work.id}/races`, { method: item ? "PATCH" : "POST", body });
|
|
4003
4304
|
await renderRaces();
|
|
4004
4305
|
await loadAiReferences();
|
|
@@ -4006,7 +4307,7 @@ async function openRaceDialog(item) {
|
|
|
4006
4307
|
}
|
|
4007
4308
|
|
|
4008
4309
|
async function openOrganizationDialog(item) {
|
|
4009
|
-
state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
|
|
4310
|
+
state.characters = canReadModule("characters") ? await apiAllPages(`/api/works/${state.work.id}/characters`) : [];
|
|
4010
4311
|
const memberOptions = state.characters.map((character) => [character.id, `${character.name}${character.aliases.length ? `(${character.aliases.join("、")})` : ""}`]);
|
|
4011
4312
|
openDialog(item ? "编辑组织" : "新建组织",
|
|
4012
4313
|
field("name", "组织名称", "text", item?.name) +
|
|
@@ -4015,7 +4316,8 @@ async function openOrganizationDialog(item) {
|
|
|
4015
4316
|
(memberOptions.length ? field("memberIds", "组织成员(可多选)", "chips", item?.memberIds ?? [], memberOptions) : ""),
|
|
4016
4317
|
async (form) => {
|
|
4017
4318
|
const settings = form.getAll("settings").map((value) => String(value).trim()).filter(Boolean);
|
|
4018
|
-
const body = { name: form.get("name"), description: form.get("description"), settings
|
|
4319
|
+
const body = { name: form.get("name"), description: form.get("description"), settings };
|
|
4320
|
+
if (canReadModule("characters")) body.memberIds = form.getAll("memberIds").map(String);
|
|
4019
4321
|
await api(item ? `/api/organizations/${item.id}` : `/api/works/${state.work.id}/organizations`, { method: item ? "PATCH" : "POST", body });
|
|
4020
4322
|
await renderOrganizations();
|
|
4021
4323
|
await loadAiReferences();
|
|
@@ -4033,7 +4335,7 @@ function openTimelineDialog(item, preferredTrackId = null) {
|
|
|
4033
4335
|
const trackOptions = [["", "未分组"], ...state.timelineTracks.map((track) => [track.id, track.name])];
|
|
4034
4336
|
openDialog(item ? "编辑大事件" : "新建大事件", field("trackId", "所属独立时间轴", "select", item?.trackId ?? preferredTrackId ?? "", trackOptions) + field("name", "事件名称", "text", item?.name) + field("timeLabel", "时间描述", "text", item?.timeLabel ?? "时间待定") + field("timeSort", "排序值(留空表示时间待定)", "number", item?.timeSort ?? "") + field("eventType", "事件类型", "text", item?.eventType ?? "other") + field("location", "地点", "text", item?.location) + field("description", "事件简述", "textarea", item?.description), async (form) => {
|
|
4035
4337
|
const rawSort = String(form.get("timeSort") ?? "").trim();
|
|
4036
|
-
const body = { trackId: form.get("trackId") || null, name: form.get("name"), timeLabel: form.get("timeLabel"), timeSort: rawSort ? Number(rawSort) : null, eventType: form.get("eventType"), location: form.get("location"), description: form.get("description"), status: item?.status ?? "confirmed" };
|
|
4338
|
+
const body = { trackId: form.get("trackId") || null, name: form.get("name"), timeLabel: form.get("timeLabel"), timeSort: rawSort ? Number(rawSort) : null, eventType: form.get("eventType"), location: form.get("location"), description: form.get("description"), status: item?.status ?? "confirmed", ...(item ? { expectedVersionNo: item.versionNo } : {}) };
|
|
4037
4339
|
await api(item ? `/api/timeline/${item.id}` : `/api/works/${state.work.id}/timeline`, { method: item ? "PATCH" : "POST", body });
|
|
4038
4340
|
await renderTimeline();
|
|
4039
4341
|
}, item ? "人工调整" : "作者确认事件");
|
|
@@ -4049,7 +4351,7 @@ function openOutlineDialog(item) {
|
|
|
4049
4351
|
field("status", "规划状态", "select", item.status ?? "draft", [["draft", "草稿"], ["ready", "可执行"], ["completed", "已完成"]]),
|
|
4050
4352
|
async (form) => {
|
|
4051
4353
|
await api(`/api/chapters/${item.chapterId}/outline`, { method: "PUT", body: {
|
|
4052
|
-
goal: form.get("goal"), conflict: form.get("conflict"), turningPoint: form.get("turningPoint"), notes: form.get("notes"), status: form.get("status")
|
|
4354
|
+
goal: form.get("goal"), conflict: form.get("conflict"), turningPoint: form.get("turningPoint"), notes: form.get("notes"), status: form.get("status"), expectedVersionNo: item.versionNo
|
|
4053
4355
|
} });
|
|
4054
4356
|
await renderOutlines();
|
|
4055
4357
|
toast("章节规划已保存");
|
|
@@ -4092,7 +4394,8 @@ function openForeshadowDialog(item) {
|
|
|
4092
4394
|
const occurrences = [...preservedOccurrences, ...editedOccurrences];
|
|
4093
4395
|
const body = {
|
|
4094
4396
|
title: form.get("title"), description: form.get("description"), importance: form.get("importance"), status: form.get("status"),
|
|
4095
|
-
plannedPayoffChapterId: form.get("payoffChapterId") || null, resolutionNote: form.get("resolutionNote"), occurrences
|
|
4397
|
+
plannedPayoffChapterId: form.get("payoffChapterId") || null, resolutionNote: form.get("resolutionNote"), occurrences,
|
|
4398
|
+
...(item ? { expectedVersionNo: item.versionNo } : {})
|
|
4096
4399
|
};
|
|
4097
4400
|
await api(item ? `/api/foreshadows/${item.id}` : `/api/works/${state.work.id}/foreshadows`, { method: item ? "PATCH" : "POST", body });
|
|
4098
4401
|
await renderOutlines();
|
|
@@ -4105,12 +4408,13 @@ function openTimelineSplitDialog(item) {
|
|
|
4105
4408
|
await api(`/api/timeline/${item.id}/split`, { method: "POST", body: { parts: [
|
|
4106
4409
|
{ name: form.get("firstName"), description: form.get("firstDescription") },
|
|
4107
4410
|
{ name: form.get("secondName"), description: form.get("secondDescription") }
|
|
4108
|
-
] } });
|
|
4411
|
+
], expectedVersionNo: item.versionNo } });
|
|
4109
4412
|
await renderTimeline();
|
|
4110
4413
|
}, "原证据同步保留");
|
|
4111
4414
|
}
|
|
4112
4415
|
|
|
4113
4416
|
async function openRelationshipDialog(item, options = {}) {
|
|
4417
|
+
if (!canReadModule("characters")) return toast("配置人物关系前需要角色模块读取权限", "error");
|
|
4114
4418
|
state.characters = await apiAllPages(`/api/works/${state.work.id}/characters`);
|
|
4115
4419
|
if (state.characters.length < 2) return toast("至少需要两个角色才能创建关系", "error");
|
|
4116
4420
|
const characterOptions = state.characters.map((item) => [item.id, item.name]);
|
|
@@ -4118,7 +4422,7 @@ async function openRelationshipDialog(item, options = {}) {
|
|
|
4118
4422
|
const defaultTo = characterOptions.find(([id]) => id !== defaultFrom)?.[0] ?? characterOptions[1][0];
|
|
4119
4423
|
openDialog(item ? "编辑人物关系" : "新建人物关系", field("from", "起点人物", "select", item?.fromCharacterId ?? defaultFrom, characterOptions) + field("to", "终点人物", "select", item?.toCharacterId ?? defaultTo, characterOptions) + field("category", "关系大类", "select", item?.category ?? "social", [["family", "亲属"], ["social", "社交"], ["emotional", "情感"], ["conflict", "冲突"], ["uncertain", "未确定"]]) + field("subtype", "关系子类", "text", item?.subtype) + field("keywords", "关系关键词", "keyword-chips", item?.keywords ?? []) + field("confidence", "置信度(0-1)", "number", item?.confidence ?? "1") + field("directed", "有方向性", "checkbox", item?.directed ?? false), async (form) => {
|
|
4120
4424
|
const keywords = uniqueRelationshipKeywords(form.getAll("keywords").map(String));
|
|
4121
|
-
await api(item ? `/api/relationships/${item.id}` : `/api/works/${state.work.id}/relationships`, { method: item ? "PATCH" : "POST", body: { fromCharacterId: form.get("from"), toCharacterId: form.get("to"), category: form.get("category"), subtype: form.get("subtype"), keywords, confidence: Number(form.get("confidence")), directed: form.get("directed") === "on", confirmationStatus: item?.confirmationStatus ?? "confirmed" } });
|
|
4425
|
+
await api(item ? `/api/relationships/${item.id}` : `/api/works/${state.work.id}/relationships`, { method: item ? "PATCH" : "POST", body: { fromCharacterId: form.get("from"), toCharacterId: form.get("to"), category: form.get("category"), subtype: form.get("subtype"), keywords, confidence: Number(form.get("confidence")), directed: form.get("directed") === "on", confirmationStatus: item?.confirmationStatus ?? "confirmed", ...(item ? { expectedVersionNo: item.versionNo } : {}) } });
|
|
4122
4426
|
await refreshRelationshipSurfaces(options.characterId ?? null);
|
|
4123
4427
|
}, item ? "关系档案" : "人工确认关系");
|
|
4124
4428
|
}
|
|
@@ -4421,7 +4725,7 @@ function appendSuggestion(suggestion, createdAt = null, messageId = null) {
|
|
|
4421
4725
|
async function showVersions() {
|
|
4422
4726
|
if (!state.chapter) return;
|
|
4423
4727
|
const versions = await api(`/api/chapters/${state.chapter.id}/versions`);
|
|
4424
|
-
$("#versions-list").innerHTML = versions.map((version) => `<div class="version-row"><div><b>v${version.versionNo}</b><small>${esc(version.source)} · ${esc(version.actor || "历史数据")}</small></div><p>${esc(version.content.slice(0, 300) || "空白章节")}</p
|
|
4728
|
+
$("#versions-list").innerHTML = versions.map((version) => `<div class="version-row"><div><b>v${version.versionNo}</b><small>${esc(version.source)} · ${esc(version.actor || "历史数据")}</small></div><p>${esc(version.content.slice(0, 300) || "空白章节")}</p>${canEditProse() ? `<button class="ghost-button" data-restore-version="${version.versionNo}">恢复</button>` : ""}</div>`).join("");
|
|
4425
4729
|
$("#versions-list").querySelectorAll("[data-restore-version]").forEach((button) => button.addEventListener("click", async () => {
|
|
4426
4730
|
if (!window.confirm(`将版本 v${button.dataset.restoreVersion} 恢复为一个新的保存版本?`)) return;
|
|
4427
4731
|
state.chapter = await api(`/api/chapters/${state.chapter.id}/restore`, { method: "POST", body: { versionNo: Number(button.dataset.restoreVersion) } });
|
|
@@ -4713,18 +5017,34 @@ $("#platform-ui-settings-form").addEventListener("submit", async (event) => {
|
|
|
4713
5017
|
}
|
|
4714
5018
|
});
|
|
4715
5019
|
$("#members-dialog-close").addEventListener("click", () => $("#members-dialog").close());
|
|
4716
|
-
$("#members-dialog").addEventListener("close", () => {
|
|
4717
|
-
|
|
5020
|
+
$("#members-dialog").addEventListener("close", () => {
|
|
5021
|
+
memberDialogWork = null;
|
|
5022
|
+
memberDialogMembers = [];
|
|
5023
|
+
memberDialogDirectory = [];
|
|
5024
|
+
});
|
|
5025
|
+
$("#member-user-select").addEventListener("change", () => selectMemberForConfiguration($("#member-user-select").value));
|
|
5026
|
+
$("#member-permission-form").querySelectorAll("[data-permission-preset]").forEach((button) => button.addEventListener("click", () => {
|
|
5027
|
+
$("#member-permission-grid").querySelectorAll("[data-member-permission]").forEach((select) => {
|
|
5028
|
+
select.value = button.dataset.permissionPreset;
|
|
5029
|
+
});
|
|
5030
|
+
}));
|
|
5031
|
+
$("#member-permission-form").addEventListener("submit", async (event) => {
|
|
4718
5032
|
event.preventDefault();
|
|
4719
5033
|
const userId = $("#member-user-select").value;
|
|
4720
5034
|
const work = memberDialogWork ?? state.work;
|
|
4721
5035
|
if (!work || !userId) return;
|
|
4722
5036
|
try {
|
|
4723
|
-
const
|
|
4724
|
-
const
|
|
5037
|
+
const existing = memberDialogMembers.some((member) => member.userId === userId && member.role !== "owner");
|
|
5038
|
+
const permissions = selectedMemberPermissions();
|
|
5039
|
+
const members = await api(existing
|
|
5040
|
+
? `/api/works/${encodeURIComponent(work.id)}/members/${encodeURIComponent(userId)}`
|
|
5041
|
+
: `/api/works/${encodeURIComponent(work.id)}/members`, {
|
|
5042
|
+
method: existing ? "PATCH" : "POST",
|
|
5043
|
+
body: existing ? { permissions } : { userId, permissions }
|
|
5044
|
+
});
|
|
4725
5045
|
renderMembers(members);
|
|
4726
|
-
|
|
4727
|
-
toast(
|
|
5046
|
+
renderMemberSelector(userId);
|
|
5047
|
+
toast(existing ? "成员模块权限已更新" : "成员已添加并保存模块权限");
|
|
4728
5048
|
} catch (error) { toast(error.message, "error"); }
|
|
4729
5049
|
});
|
|
4730
5050
|
$("#platform-new-provider").addEventListener("click", () => openProviderDialog());
|
|
@@ -4873,22 +5193,35 @@ $("#ai-mention-menu").addEventListener("click", (event) => {
|
|
|
4873
5193
|
if (button) selectAiMention(button);
|
|
4874
5194
|
});
|
|
4875
5195
|
$("#import-file").addEventListener("change", async (event) => {
|
|
4876
|
-
|
|
4877
|
-
if (!
|
|
5196
|
+
const file = event.target.files[0];
|
|
5197
|
+
if (!state.work || !file) return;
|
|
5198
|
+
if (!canEditProse()) {
|
|
5199
|
+
event.target.value = "";
|
|
5200
|
+
toast("当前权限只能编辑设定资料,不能导入正文", "error");
|
|
5201
|
+
return;
|
|
5202
|
+
}
|
|
5203
|
+
const mode = await chooseExistingWorkImportMode(file);
|
|
5204
|
+
if (!mode) {
|
|
4878
5205
|
event.target.value = "";
|
|
4879
5206
|
return;
|
|
4880
5207
|
}
|
|
5208
|
+
cancelChapterAutoSave();
|
|
4881
5209
|
const body = new FormData();
|
|
4882
|
-
body.append("file",
|
|
5210
|
+
body.append("file", file);
|
|
5211
|
+
body.append("mode", mode);
|
|
5212
|
+
body.append("expectedVersionNo", String(state.work.versionNo));
|
|
4883
5213
|
try {
|
|
4884
5214
|
const result = await api(`/api/works/${state.work.id}/import`, { method: "POST", body });
|
|
4885
|
-
setSaveState("
|
|
5215
|
+
setSaveState(mode === "append" ? "已追加" : "已覆盖");
|
|
4886
5216
|
state.work = result.tree;
|
|
4887
5217
|
renderTree();
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
if (
|
|
4891
|
-
} catch (error) {
|
|
5218
|
+
const completion = mode === "append" ? "正文追加完成" : "正文覆盖完成";
|
|
5219
|
+
toast(result.warnings.length ? `${completion}:${result.warnings.join(";")}` : completion);
|
|
5220
|
+
if (result.firstImportedChapterId) await selectChapter(result.firstImportedChapterId);
|
|
5221
|
+
} catch (error) {
|
|
5222
|
+
toast(error.message, "error");
|
|
5223
|
+
if (state.dirty) scheduleChapterAutoSave();
|
|
5224
|
+
}
|
|
4892
5225
|
event.target.value = "";
|
|
4893
5226
|
});
|
|
4894
5227
|
$("#new-import-file").addEventListener("change", async (event) => {
|